From 3902e6482a6e08ef46804efb01dd0ed36bebe386 Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Wed, 11 Jun 2025 02:02:05 +0530 Subject: [PATCH 01/67] refactor(kb): use chonkie locally (#475) --- apps/sim/lib/documents/document-processor.ts | 20 ++++++++------------ apps/sim/lib/env.ts | 1 - 2 files changed, 8 insertions(+), 13 deletions(-) diff --git a/apps/sim/lib/documents/document-processor.ts b/apps/sim/lib/documents/document-processor.ts index fb7a223be4a..ea3c12fe702 100644 --- a/apps/sim/lib/documents/document-processor.ts +++ b/apps/sim/lib/documents/document-processor.ts @@ -1,4 +1,4 @@ -import { RecursiveChunker } from 'chonkie/cloud' +import { RecursiveChunker } from 'chonkie' import type { RecursiveChunk } from 'chonkie/types' import { env } from '@/lib/env' import { isSupportedFileType, parseBuffer, parseFile } from '@/lib/file-parsers' @@ -78,7 +78,11 @@ async function parseDocument( fileUrl: string, filename: string, mimeType: string -): Promise<{ content: string; processingMethod: 'file-parser' | 'mistral-ocr'; s3Url?: string }> { +): Promise<{ + content: string + processingMethod: 'file-parser' | 'mistral-ocr' + s3Url?: string +}> { const processingMethod = determineProcessingMethod(mimeType, filename) logger.info(`Processing document "${filename}" using ${processingMethod}`) @@ -237,15 +241,8 @@ async function chunkContent( content: string, options: DocumentProcessingOptions ): Promise { - const apiKey = env.CHONKIE_API_KEY - if (!apiKey) { - throw new Error('CHONKIE_API_KEY not configured') - } - - const chunker = new RecursiveChunker(apiKey, { + const chunker = await RecursiveChunker.create({ chunkSize: options.chunkSize || 512, - recipe: options.recipe || 'default', - lang: options.lang || 'en', minCharactersPerChunk: options.minCharactersPerChunk || 24, }) @@ -255,7 +252,7 @@ async function chunkContent( chunkSize: options.chunkSize || 512, }) - const chunks = await chunker.chunk({ text: content }) + const chunks = await chunker.chunk(content) logger.info(`Successfully created ${chunks.length} chunks`) return chunks as RecursiveChunk[] @@ -266,7 +263,6 @@ async function chunkContent( ) } } - /** * Calculate token count estimation (rough approximation: 4 chars per token) */ diff --git a/apps/sim/lib/env.ts b/apps/sim/lib/env.ts index 38c7b60c7fe..9718ee106bd 100644 --- a/apps/sim/lib/env.ts +++ b/apps/sim/lib/env.ts @@ -72,7 +72,6 @@ export const env = createEnv({ FREE_PLAN_LOG_RETENTION_DAYS: z.string().optional(), NODE_ENV: z.string().optional(), GITHUB_TOKEN: z.string().optional(), - CHONKIE_API_KEY: z.string().min(1).optional(), ELEVENLABS_API_KEY: z.string().min(1).optional(), // OAuth blocks (all optional) From e557499783cbd73600d5ef1a2a3b1382527c4936 Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Wed, 11 Jun 2025 02:04:48 +0530 Subject: [PATCH 02/67] feat(parsers): text and markdown parsers (#473) * feat: text and markdown parsers * fix: don't readfile on buffer, convert buffer to string instead --- apps/sim/lib/file-parsers/index.test.ts | 69 ++++++++++++++++++++++++- apps/sim/lib/file-parsers/index.ts | 14 +++++ apps/sim/lib/file-parsers/md-parser.ts | 45 ++++++++++++++++ apps/sim/lib/file-parsers/txt-parser.ts | 45 ++++++++++++++++ 4 files changed, 172 insertions(+), 1 deletion(-) create mode 100644 apps/sim/lib/file-parsers/md-parser.ts create mode 100644 apps/sim/lib/file-parsers/txt-parser.ts diff --git a/apps/sim/lib/file-parsers/index.test.ts b/apps/sim/lib/file-parsers/index.test.ts index 535ab10c9b5..98b59e230ba 100644 --- a/apps/sim/lib/file-parsers/index.test.ts +++ b/apps/sim/lib/file-parsers/index.test.ts @@ -37,6 +37,22 @@ const mockDocxParseFile = vi.fn().mockResolvedValue({ }, }) +const mockTxtParseFile = vi.fn().mockResolvedValue({ + content: 'Parsed TXT content', + metadata: { + characterCount: 100, + tokenCount: 10, + }, +}) + +const mockMdParseFile = vi.fn().mockResolvedValue({ + content: 'Parsed MD content', + metadata: { + characterCount: 100, + tokenCount: 10, + }, +}) + // Create mock module implementation const createMockModule = () => { // Create mock parsers @@ -44,6 +60,8 @@ const createMockModule = () => { pdf: { parseFile: mockPdfParseFile }, csv: { parseFile: mockCsvParseFile }, docx: { parseFile: mockDocxParseFile }, + txt: { parseFile: mockTxtParseFile }, + md: { parseFile: mockMdParseFile }, } // Create the mock module implementation @@ -122,6 +140,18 @@ describe('File Parsers', () => { })), })) + vi.doMock('./txt-parser', () => ({ + TxtParser: vi.fn().mockImplementation(() => ({ + parseFile: mockTxtParseFile, + })), + })) + + vi.doMock('./md-parser', () => ({ + MdParser: vi.fn().mockImplementation(() => ({ + parseFile: mockMdParseFile, + })), + })) + // Silence console output during tests global.console = { ...console, @@ -211,6 +241,40 @@ describe('File Parsers', () => { expect(result).toEqual(expectedResult) }) + it('should parse TXT files successfully', async () => { + const expectedResult = { + content: 'Parsed TXT content', + metadata: { + characterCount: 100, + tokenCount: 10, + }, + } + + mockTxtParseFile.mockResolvedValueOnce(expectedResult) + mockExistsSync.mockReturnValue(true) + + const { parseFile } = await import('./index') + const result = await parseFile('/test/files/document.txt') + + expect(result).toEqual(expectedResult) + }) + + it('should parse MD files successfully', async () => { + const expectedResult = { + content: 'Parsed MD content', + metadata: { + characterCount: 100, + tokenCount: 10, + }, + } + + mockMdParseFile.mockResolvedValueOnce(expectedResult) + mockExistsSync.mockReturnValue(true) + + const { parseFile } = await import('./index') + const result = await parseFile('/test/files/document.md') + }) + it('should throw error for unsupported file types', async () => { // Make sure the file "exists" for this test mockExistsSync.mockReturnValue(true) @@ -240,13 +304,14 @@ describe('File Parsers', () => { expect(isSupportedFileType('pdf')).toBe(true) expect(isSupportedFileType('csv')).toBe(true) expect(isSupportedFileType('docx')).toBe(true) + expect(isSupportedFileType('txt')).toBe(true) + expect(isSupportedFileType('md')).toBe(true) }) it('should return false for unsupported file types', async () => { const { isSupportedFileType } = await import('./index') expect(isSupportedFileType('png')).toBe(false) - expect(isSupportedFileType('txt')).toBe(false) expect(isSupportedFileType('unknown')).toBe(false) }) @@ -255,6 +320,8 @@ describe('File Parsers', () => { expect(isSupportedFileType('PDF')).toBe(true) expect(isSupportedFileType('CSV')).toBe(true) + expect(isSupportedFileType('TXT')).toBe(true) + expect(isSupportedFileType('MD')).toBe(true) }) it('should handle errors gracefully', async () => { diff --git a/apps/sim/lib/file-parsers/index.ts b/apps/sim/lib/file-parsers/index.ts index 15ff2e50da2..26cd63f8832 100644 --- a/apps/sim/lib/file-parsers/index.ts +++ b/apps/sim/lib/file-parsers/index.ts @@ -75,6 +75,20 @@ function getParserInstances(): Record { } catch (error) { logger.error('Failed to load DOCX parser:', error) } + + try { + const { TxtParser } = require('./txt-parser') + parserInstances.txt = new TxtParser() + } catch (error) { + logger.error('Failed to load TXT parser:', error) + } + + try { + const { MdParser } = require('./md-parser') + parserInstances.md = new MdParser() + } catch (error) { + logger.error('Failed to load MD parser:', error) + } } catch (error) { logger.error('Error loading file parsers:', error) } diff --git a/apps/sim/lib/file-parsers/md-parser.ts b/apps/sim/lib/file-parsers/md-parser.ts new file mode 100644 index 00000000000..7080edd6ed0 --- /dev/null +++ b/apps/sim/lib/file-parsers/md-parser.ts @@ -0,0 +1,45 @@ +import { readFile } from 'fs/promises' +import { createLogger } from '@/lib/logs/console-logger' +import type { FileParseResult, FileParser } from './types' + +const logger = createLogger('MdParser') + +export class MdParser implements FileParser { + async parseFile(filePath: string): Promise { + try { + // Validate input + if (!filePath) { + throw new Error('No file path provided') + } + + // Read the file + const buffer = await readFile(filePath) + + // Use parseBuffer for consistent implementation + return this.parseBuffer(buffer) + } catch (error) { + logger.error('MD file error:', error) + throw new Error(`Failed to parse MD file: ${(error as Error).message}`) + } + } + + async parseBuffer(buffer: Buffer): Promise { + try { + logger.info('Parsing buffer, size:', buffer.length) + + // Extract content + const result = buffer.toString('utf-8') + + return { + content: result, + metadata: { + characterCount: result.length, + tokenCount: result.length / 4, + }, + } + } catch (error) { + logger.error('MD buffer parsing error:', error) + throw new Error(`Failed to parse MD buffer: ${(error as Error).message}`) + } + } +} diff --git a/apps/sim/lib/file-parsers/txt-parser.ts b/apps/sim/lib/file-parsers/txt-parser.ts new file mode 100644 index 00000000000..dfde1f1faac --- /dev/null +++ b/apps/sim/lib/file-parsers/txt-parser.ts @@ -0,0 +1,45 @@ +import { readFile } from 'fs/promises' +import { createLogger } from '@/lib/logs/console-logger' +import type { FileParseResult, FileParser } from './types' + +const logger = createLogger('TxtParser') + +export class TxtParser implements FileParser { + async parseFile(filePath: string): Promise { + try { + // Validate input + if (!filePath) { + throw new Error('No file path provided') + } + + // Read the file + const buffer = await readFile(filePath) + + // Use parseBuffer for consistent implementation + return this.parseBuffer(buffer) + } catch (error) { + logger.error('TXT file error:', error) + throw new Error(`Failed to parse TXT file: ${(error as Error).message}`) + } + } + + async parseBuffer(buffer: Buffer): Promise { + try { + logger.info('Parsing buffer, size:', buffer.length) + + // Extract content + const result = buffer.toString('utf-8') + + return { + content: result, + metadata: { + characterCount: result.length, + tokenCount: result.length / 4, + }, + } + } catch (error) { + logger.error('TXT buffer parsing error:', error) + throw new Error(`Failed to parse TXT buffer: ${(error as Error).message}`) + } + } +} From 3d9c9936ec8a8ccb1543e20df54dd374ebada2f7 Mon Sep 17 00:00:00 2001 From: Adam Gough <77861281+aadamgough@users.noreply.github.com> Date: Tue, 10 Jun 2025 16:24:16 -0700 Subject: [PATCH 03/67] fix(knowledge-wh): fixed authentication error on webhook trigger fix(knowledge-wh): fixed authentication error on webhook trigger --- .../documents/[documentId]/chunks/route.ts | 23 +++++++++++-------- apps/sim/app/api/knowledge/search/route.ts | 22 ++++++++++-------- apps/sim/lib/webhooks/utils.ts | 2 +- apps/sim/tools/knowledge/search.ts | 17 ++++++++++---- apps/sim/tools/knowledge/upload_chunk.ts | 15 ++++++++---- 5 files changed, 51 insertions(+), 28 deletions(-) diff --git a/apps/sim/app/api/knowledge/[id]/documents/[documentId]/chunks/route.ts b/apps/sim/app/api/knowledge/[id]/documents/[documentId]/chunks/route.ts index ba5fa510911..715fbad6496 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/[documentId]/chunks/route.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/[documentId]/chunks/route.ts @@ -4,6 +4,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { getSession } from '@/lib/auth' import { createLogger } from '@/lib/logs/console-logger' +import { getUserId } from '@/app/api/auth/oauth/utils' import { db } from '@/db' import { document, embedding } from '@/db/schema' import { checkDocumentAccess, generateEmbeddings } from '../../../../utils' @@ -158,13 +159,19 @@ export async function POST( const { id: knowledgeBaseId, documentId } = await params try { - const session = await getSession() - if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthorized chunk creation attempt`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + const body = await req.json() + const { workflowId, ...searchParams } = body + + const userId = await getUserId(requestId, workflowId) + + if (!userId) { + const errorMessage = workflowId ? 'Workflow not found' : 'Unauthorized' + const statusCode = workflowId ? 404 : 401 + logger.warn(`[${requestId}] Authentication failed: ${errorMessage}`) + return NextResponse.json({ error: errorMessage }, { status: statusCode }) } - const accessCheck = await checkDocumentAccess(knowledgeBaseId, documentId, session.user.id) + const accessCheck = await checkDocumentAccess(knowledgeBaseId, documentId, userId) if (!accessCheck.hasAccess) { if (accessCheck.notFound) { @@ -174,7 +181,7 @@ export async function POST( return NextResponse.json({ error: accessCheck.reason }, { status: 404 }) } logger.warn( - `[${requestId}] User ${session.user.id} attempted unauthorized chunk creation: ${accessCheck.reason}` + `[${requestId}] User ${userId} attempted unauthorized chunk creation: ${accessCheck.reason}` ) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } @@ -194,10 +201,8 @@ export async function POST( return NextResponse.json({ error: 'Cannot add chunks to failed document' }, { status: 400 }) } - const body = await req.json() - try { - const validatedData = CreateChunkSchema.parse(body) + const validatedData = CreateChunkSchema.parse(searchParams) // Generate embedding for the content first (outside transaction for performance) logger.info(`[${requestId}] Generating embedding for manual chunk`) diff --git a/apps/sim/app/api/knowledge/search/route.ts b/apps/sim/app/api/knowledge/search/route.ts index c5b90aa3e76..7b6b69b3f42 100644 --- a/apps/sim/app/api/knowledge/search/route.ts +++ b/apps/sim/app/api/knowledge/search/route.ts @@ -1,10 +1,10 @@ import { and, eq, isNull, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' -import { getSession } from '@/lib/auth' import { retryWithExponentialBackoff } from '@/lib/documents/utils' import { env } from '@/lib/env' import { createLogger } from '@/lib/logs/console-logger' +import { getUserId } from '@/app/api/auth/oauth/utils' import { db } from '@/db' import { embedding, knowledgeBase } from '@/db/schema' @@ -87,16 +87,20 @@ export async function POST(request: NextRequest) { try { logger.info(`[${requestId}] Processing vector search request`) - const session = await getSession() - if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthorized vector search attempt`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - const body = await request.json() + const { workflowId, ...searchParams } = body + + const userId = await getUserId(requestId, workflowId) + + if (!userId) { + const errorMessage = workflowId ? 'Workflow not found' : 'Unauthorized' + const statusCode = workflowId ? 404 : 401 + logger.warn(`[${requestId}] Authentication failed: ${errorMessage}`) + return NextResponse.json({ error: errorMessage }, { status: statusCode }) + } try { - const validatedData = VectorSearchSchema.parse(body) + const validatedData = VectorSearchSchema.parse(searchParams) // Verify the knowledge base exists and user has access const kb = await db @@ -105,7 +109,7 @@ export async function POST(request: NextRequest) { .where( and( eq(knowledgeBase.id, validatedData.knowledgeBaseId), - eq(knowledgeBase.userId, session.user.id), + eq(knowledgeBase.userId, userId), isNull(knowledgeBase.deletedAt) ) ) diff --git a/apps/sim/lib/webhooks/utils.ts b/apps/sim/lib/webhooks/utils.ts index 29b6b9184a8..a4cdc9915b8 100644 --- a/apps/sim/lib/webhooks/utils.ts +++ b/apps/sim/lib/webhooks/utils.ts @@ -674,7 +674,7 @@ export async function executeWorkflowFromPayload( serializedWorkflow, processedBlockStates, decryptedEnvVars, - input, // Use the provided input (might be single event or batch) + input, workflowVariables ) diff --git a/apps/sim/tools/knowledge/search.ts b/apps/sim/tools/knowledge/search.ts index 1dd828837e8..f3a2b64e292 100644 --- a/apps/sim/tools/knowledge/search.ts +++ b/apps/sim/tools/knowledge/search.ts @@ -29,11 +29,18 @@ export const knowledgeSearchTool: ToolConfig = { headers: () => ({ 'Content-Type': 'application/json', }), - body: (params) => ({ - knowledgeBaseId: params.knowledgeBaseId, - query: params.query, - topK: params.topK ? Number.parseInt(params.topK.toString()) : 10, - }), + body: (params) => { + const workflowId = params._context?.workflowId + + const requestBody = { + knowledgeBaseId: params.knowledgeBaseId, + query: params.query, + topK: params.topK ? Number.parseInt(params.topK.toString()) : 10, + ...(workflowId && { workflowId }), + } + + return requestBody + }, isInternalRoute: true, }, transformResponse: async (response): Promise => { diff --git a/apps/sim/tools/knowledge/upload_chunk.ts b/apps/sim/tools/knowledge/upload_chunk.ts index cf29896b095..6543c63f322 100644 --- a/apps/sim/tools/knowledge/upload_chunk.ts +++ b/apps/sim/tools/knowledge/upload_chunk.ts @@ -30,10 +30,17 @@ export const knowledgeUploadChunkTool: ToolConfig ({ 'Content-Type': 'application/json', }), - body: (params) => ({ - content: params.content, - enabled: true, - }), + body: (params) => { + const workflowId = params._context?.workflowId + + const requestBody = { + content: params.content, + enabled: true, + ...(workflowId && { workflowId }), + } + + return requestBody + }, isInternalRoute: true, }, transformResponse: async (response): Promise => { From 9ef2764d700f7cba16916e859542db7c59e95259 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Tue, 10 Jun 2025 17:53:54 -0700 Subject: [PATCH 04/67] feat(tools): add huggingface tools/blcok (#472) * add hugging face tool * docs: add Hugging Face tool documentation * fix: format and lint Hugging Face integration files * docs: add manual intro section to Hugging Face documentation * feat: replace Record with proper HuggingFaceRequestBody interface * accidental local files added * restore some docs * make layout full for model field * change huggingface logo * add manual content * fix lint --------- Co-authored-by: Vikhyath Mondreti --- apps/docs/content/docs/tools/huggingface.mdx | 99 ++++++++++ apps/docs/content/docs/tools/meta.json | 1 + apps/sim/blocks/blocks/huggingface.ts | 125 ++++++++++++ apps/sim/blocks/registry.ts | 2 + apps/sim/components/icons.tsx | 43 +++++ apps/sim/tools/huggingface/chat.ts | 193 +++++++++++++++++++ apps/sim/tools/huggingface/index.ts | 3 + apps/sim/tools/huggingface/types.ts | 39 ++++ apps/sim/tools/registry.ts | 2 + 9 files changed, 507 insertions(+) create mode 100644 apps/docs/content/docs/tools/huggingface.mdx create mode 100644 apps/sim/blocks/blocks/huggingface.ts create mode 100644 apps/sim/tools/huggingface/chat.ts create mode 100644 apps/sim/tools/huggingface/index.ts create mode 100644 apps/sim/tools/huggingface/types.ts diff --git a/apps/docs/content/docs/tools/huggingface.mdx b/apps/docs/content/docs/tools/huggingface.mdx new file mode 100644 index 00000000000..5b35a945dc4 --- /dev/null +++ b/apps/docs/content/docs/tools/huggingface.mdx @@ -0,0 +1,99 @@ +--- +title: Hugging Face +description: Use Hugging Face Inference API +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + + `} +/> + +{/* MANUAL-CONTENT-START:intro */} +[HuggingFace](https://huggingface.co/) is a leading AI platform that provides access to thousands of pre-trained machine learning models and powerful inference capabilities. With its extensive model hub and robust API, HuggingFace offers comprehensive tools for both research and production AI applications. +With HuggingFace, you can: + +Access pre-trained models: Utilize models for text generation, translation, image processing, and more +Generate AI completions: Create content using state-of-the-art language models through the Inference API +Natural language processing: Process and analyze text with specialized NLP models +Deploy at scale: Host and serve models for production applications +Customize models: Fine-tune existing models for specific use cases + +In Sim Studio, the HuggingFace integration enables your agents to programmatically generate completions using the HuggingFace Inference API. This allows for powerful automation scenarios such as content generation, text analysis, code completion, and creative writing. Your agents can generate completions with natural language prompts, access specialized models for different tasks, and integrate AI-generated content into workflows. This integration bridges the gap between your AI workflows and machine learning capabilities, enabling seamless AI-powered automation with one of the world's most comprehensive ML platforms. +{/* MANUAL-CONTENT-END */} + +## Usage Instructions + +Generate completions using Hugging Face Inference API with access to various open-source models. Leverage cutting-edge AI models for chat completions, content generation, and AI-powered conversations with customizable parameters. + + +## Tools + +### `huggingface_chat` + +Generate completions using Hugging Face Inference API + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Hugging Face API token | +| `provider` | string | Yes | The provider to use for the API request \(e.g., novita, cerebras, etc.\) | +| `model` | string | Yes | Model to use for chat completions \(e.g., deepseek/deepseek-v3-0324\) | +| `content` | string | Yes | The user message content to send to the model | +| `systemPrompt` | string | No | System prompt to guide the model behavior | +| `maxTokens` | number | No | Maximum number of tokens to generate | +| `temperature` | number | No | Sampling temperature \(0-2\). Higher values make output more random | +| `stream` | boolean | No | Whether to stream the response | + +#### Output + +| Parameter | Type | +| --------- | ---- | +| `content` | string | +| `model` | string | +| `usage` | string | +| `completion_tokens` | string | +| `total_tokens` | string | + + + +## Block Configuration + +### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `systemPrompt` | string | No | System Prompt - Enter system prompt to guide the model behavior... | + + + +### Outputs + +| Output | Type | Description | +| ------ | ---- | ----------- | +| `response` | object | Output from response | +| ↳ `content` | string | content of the response | +| ↳ `model` | string | model of the response | +| ↳ `usage` | json | usage of the response | + + +## Notes + +- Category: `tools` +- Type: `huggingface` diff --git a/apps/docs/content/docs/tools/meta.json b/apps/docs/content/docs/tools/meta.json index 380fb990b8d..80332879640 100644 --- a/apps/docs/content/docs/tools/meta.json +++ b/apps/docs/content/docs/tools/meta.json @@ -19,6 +19,7 @@ "google_search", "google_sheets", "guesty", + "huggingface", "image_generator", "jina", "jira", diff --git a/apps/sim/blocks/blocks/huggingface.ts b/apps/sim/blocks/blocks/huggingface.ts new file mode 100644 index 00000000000..8aa50ec516d --- /dev/null +++ b/apps/sim/blocks/blocks/huggingface.ts @@ -0,0 +1,125 @@ +import { HuggingFaceIcon } from '@/components/icons' +import type { HuggingFaceChatResponse } from '@/tools/huggingface/types' +import type { BlockConfig } from '../types' + +export const HuggingFaceBlock: BlockConfig = { + type: 'huggingface', + name: 'Hugging Face', + description: 'Use Hugging Face Inference API', + longDescription: + 'Generate completions using Hugging Face Inference API with access to various open-source models. Leverage cutting-edge AI models for chat completions, content generation, and AI-powered conversations with customizable parameters.', + docsLink: 'https://docs.simstudio.ai/tools/huggingface', + category: 'tools', + bgColor: '#181C1E', + icon: HuggingFaceIcon, + subBlocks: [ + { + id: 'systemPrompt', + title: 'System Prompt', + type: 'long-input', + layout: 'full', + placeholder: 'Enter system prompt to guide the model behavior...', + rows: 3, + }, + { + id: 'content', + title: 'User Prompt', + type: 'long-input', + layout: 'full', + placeholder: 'Enter your message here...', + rows: 3, + }, + { + id: 'provider', + title: 'Provider', + type: 'dropdown', + layout: 'half', + options: [ + { label: 'Novita', id: 'novita' }, + { label: 'Cerebras', id: 'cerebras' }, + { label: 'Cohere', id: 'cohere' }, + { label: 'Fal AI', id: 'fal' }, + { label: 'Fireworks', id: 'fireworks' }, + { label: 'Hyperbolic', id: 'hyperbolic' }, + { label: 'HF Inference', id: 'hf-inference' }, + { label: 'Nebius', id: 'nebius' }, + { label: 'Nscale', id: 'nscale' }, + { label: 'Replicate', id: 'replicate' }, + { label: 'SambaNova', id: 'sambanova' }, + { label: 'Together', id: 'together' }, + ], + value: () => 'novita', + }, + { + id: 'model', + title: 'Model', + type: 'short-input', + layout: 'full', + placeholder: + 'e.g., deepseek/deepseek-v3-0324, llama3.1-8b, meta-llama/Llama-3.2-3B-Instruct-Turbo', + description: 'The model must be available for the selected provider.', + }, + { + id: 'temperature', + title: 'Temperature', + type: 'slider', + layout: 'half', + min: 0, + max: 2, + value: () => '0.7', + }, + { + id: 'maxTokens', + title: 'Max Tokens', + type: 'short-input', + layout: 'half', + placeholder: 'e.g., 1000', + }, + { + id: 'apiKey', + title: 'API Token', + type: 'short-input', + layout: 'full', + placeholder: 'Enter your Hugging Face API token', + password: true, + }, + ], + tools: { + access: ['huggingface_chat'], + config: { + tool: () => 'huggingface_chat', + params: (params) => { + const toolParams = { + apiKey: params.apiKey, + provider: params.provider, + model: params.model, + content: params.content, + systemPrompt: params.systemPrompt, + temperature: params.temperature ? Number.parseFloat(params.temperature) : undefined, + maxTokens: params.maxTokens ? Number.parseInt(params.maxTokens) : undefined, + stream: false, // Always false + } + + return toolParams + }, + }, + }, + inputs: { + systemPrompt: { type: 'string', required: false }, + content: { type: 'string', required: true }, + provider: { type: 'string', required: true }, + model: { type: 'string', required: true }, + temperature: { type: 'string', required: false }, + maxTokens: { type: 'string', required: false }, + apiKey: { type: 'string', required: true }, + }, + outputs: { + response: { + type: { + content: 'string', + model: 'string', + usage: 'json', + }, + }, + }, +} diff --git a/apps/sim/blocks/registry.ts b/apps/sim/blocks/registry.ts index d758c9c6f6b..aab7f1419f9 100644 --- a/apps/sim/blocks/registry.ts +++ b/apps/sim/blocks/registry.ts @@ -25,6 +25,7 @@ import { GoogleCalendarBlock } from './blocks/google_calendar' import { GoogleDocsBlock } from './blocks/google_docs' import { GoogleDriveBlock } from './blocks/google_drive' import { GoogleSheetsBlock } from './blocks/google_sheets' +import { HuggingFaceBlock } from './blocks/huggingface' // import { GuestyBlock } from './blocks/guesty' import { ImageGeneratorBlock } from './blocks/image_generator' import { JinaBlock } from './blocks/jina' @@ -124,6 +125,7 @@ export const registry: Record = { whatsapp: WhatsAppBlock, x: XBlock, youtube: YouTubeBlock, + huggingface: HuggingFaceBlock, } // Helper functions to access the registry diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index 2237b27e1e9..1f061476d34 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -2827,3 +2827,46 @@ export function PackageSearchIcon(props: SVGProps) { ) } +export function HuggingFaceIcon(props: SVGProps) { + return ( + + + + + + + + + ) +} diff --git a/apps/sim/tools/huggingface/chat.ts b/apps/sim/tools/huggingface/chat.ts new file mode 100644 index 00000000000..82438a4b12b --- /dev/null +++ b/apps/sim/tools/huggingface/chat.ts @@ -0,0 +1,193 @@ +import type { ToolConfig } from '../types' +import type { + HuggingFaceChatParams, + HuggingFaceChatResponse, + HuggingFaceMessage, + HuggingFaceRequestBody, +} from './types' + +export const chatTool: ToolConfig = { + id: 'huggingface_chat', + name: 'Hugging Face Chat', + description: 'Generate completions using Hugging Face Inference API', + version: '1.0', + + params: { + apiKey: { + type: 'string', + required: true, + requiredForToolCall: true, + description: 'Hugging Face API token', + }, + provider: { + type: 'string', + required: true, + description: 'The provider to use for the API request (e.g., novita, cerebras, etc.)', + }, + model: { + type: 'string', + required: true, + description: 'Model to use for chat completions (e.g., deepseek/deepseek-v3-0324)', + }, + content: { + type: 'string', + required: true, + description: 'The user message content to send to the model', + }, + systemPrompt: { + type: 'string', + required: false, + description: 'System prompt to guide the model behavior', + }, + maxTokens: { + type: 'number', + required: false, + description: 'Maximum number of tokens to generate', + }, + temperature: { + type: 'number', + required: false, + description: 'Sampling temperature (0-2). Higher values make output more random', + }, + stream: { + type: 'boolean', + required: false, + description: 'Whether to stream the response', + }, + }, + + request: { + method: 'POST', + url: (params) => { + // Provider-specific endpoint mapping + const endpointMap: Record = { + novita: '/v3/openai/chat/completions', + cerebras: '/v1/chat/completions', + cohere: '/v1/chat/completions', + fal: '/v1/chat/completions', + fireworks: '/v1/chat/completions', + hyperbolic: '/v1/chat/completions', + 'hf-inference': '/v1/chat/completions', + nebius: '/v1/chat/completions', + nscale: '/v1/chat/completions', + replicate: '/v1/chat/completions', + sambanova: '/v1/chat/completions', + together: '/v1/chat/completions', + } + + const endpoint = endpointMap[params.provider] || '/v1/chat/completions' + return `https://router.huggingface.co/${params.provider}${endpoint}` + }, + headers: (params) => ({ + Authorization: `Bearer ${params.apiKey}`, + 'Content-Type': 'application/json', + }), + body: (params) => { + const messages: HuggingFaceMessage[] = [] + + // Add system prompt if provided + if (params.systemPrompt) { + messages.push({ + role: 'system', + content: params.systemPrompt, + }) + } + + // Add user message + messages.push({ + role: 'user', + content: params.content, + }) + + const body: HuggingFaceRequestBody = { + model: params.model, + messages: messages, + stream: params.stream || false, + } + + // Add optional parameters if provided + if (params.temperature !== undefined) { + body.temperature = Number(params.temperature) + } + + if (params.maxTokens !== undefined) { + body.max_tokens = Number(params.maxTokens) + } + + return body + }, + }, + + transformResponse: async (response, params) => { + try { + // Check if the response was successful + if (!response.ok) { + const errorData = await response.json().catch(() => null) + console.error('Hugging Face API error:', { + status: response.status, + statusText: response.statusText, + errorData, + url: response.url, + }) + + const errorMessage = errorData + ? `API error: ${response.status} ${response.statusText} - ${JSON.stringify(errorData)}` + : `API error: ${response.status} ${response.statusText}` + + throw new Error(errorMessage) + } + + const data = await response.json() + + // Validate response structure + if (!data.choices || !data.choices[0] || !data.choices[0].message) { + console.error('Invalid Hugging Face response format:', data) + throw new Error('Invalid response format from Hugging Face API') + } + + return { + success: true, + output: { + content: data.choices[0].message.content, + model: data.model || params?.model || 'unknown', + usage: data.usage + ? { + prompt_tokens: data.usage.prompt_tokens || 0, + completion_tokens: data.usage.completion_tokens || 0, + total_tokens: data.usage.total_tokens || 0, + } + : { + prompt_tokens: 0, + completion_tokens: 0, + total_tokens: 0, + }, + }, + } + } catch (error: any) { + console.error('Failed to process Hugging Face response:', error) + throw error + } + }, + + transformError: (error) => { + let errorMessage = 'Unknown error occurred' + + if (error) { + if (typeof error === 'string') { + errorMessage = error + } else if (error.message) { + errorMessage = error.message + } else if (error.error) { + errorMessage = error.error + } else { + try { + errorMessage = JSON.stringify(error) + } catch (e) { + errorMessage = 'Error occurred but could not be serialized' + } + } + } + + return `Hugging Face chat completion failed: ${errorMessage}` + }, +} diff --git a/apps/sim/tools/huggingface/index.ts b/apps/sim/tools/huggingface/index.ts new file mode 100644 index 00000000000..fdef0ced657 --- /dev/null +++ b/apps/sim/tools/huggingface/index.ts @@ -0,0 +1,3 @@ +import { chatTool } from './chat' + +export const huggingfaceChatTool = chatTool diff --git a/apps/sim/tools/huggingface/types.ts b/apps/sim/tools/huggingface/types.ts new file mode 100644 index 00000000000..a3025bca898 --- /dev/null +++ b/apps/sim/tools/huggingface/types.ts @@ -0,0 +1,39 @@ +import type { ToolResponse } from '../types' + +export interface HuggingFaceUsage { + prompt_tokens: number + completion_tokens: number + total_tokens: number +} + +export interface HuggingFaceMessage { + role: 'user' | 'assistant' | 'system' + content: string +} + +export interface HuggingFaceRequestBody { + model: string + messages: HuggingFaceMessage[] + stream: boolean + temperature?: number + max_tokens?: number +} + +export interface HuggingFaceChatParams { + apiKey: string + provider: string + model: string + content: string + systemPrompt?: string + maxTokens?: number + temperature?: number + stream?: boolean +} + +export interface HuggingFaceChatResponse extends ToolResponse { + output: { + content: string + model: string + usage: HuggingFaceUsage + } +} diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index 00bd7aed5f5..6909ff94ae5 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -50,6 +50,7 @@ import { import { guestyGuestTool, guestyReservationTool } from './guesty' import { requestTool as httpRequest } from './http' import { contactsTool as hubspotContacts } from './hubspot/contacts' +import { huggingfaceChatTool } from './huggingface' import { readUrlTool } from './jina' import { jiraBulkRetrieveTool, jiraRetrieveTool, jiraUpdateTool, jiraWriteTool } from './jira' import { knowledgeSearchTool, knowledgeUploadChunkTool } from './knowledge' @@ -104,6 +105,7 @@ export const tools: Record = { autoblocks_prompt_manager: autoblocksPromptManagerTool, openai_embeddings: openAIEmbeddings, http_request: httpRequest, + huggingface_chat: huggingfaceChatTool, hubspot_contacts: hubspotContacts, salesforce_opportunities: salesforceOpportunities, function_execute: functionExecuteTool, From bf579d88637c04493a6469e1d1c45638a8af8b40 Mon Sep 17 00:00:00 2001 From: Adam Gough <77861281+aadamgough@users.noreply.github.com> Date: Wed, 11 Jun 2025 23:25:41 -0700 Subject: [PATCH 05/67] fix(knowledge-ux): fixed ux for knowledge base (#478) fix(knowledge-ux): fixed ux for knowledge base (#478) --- .../w/[id]/components/loop-node/loop-node.tsx | 2 +- .../parallel-node/parallel-config.ts | 2 +- .../parallel-node/parallel-node.tsx | 2 +- .../components/sub-block/components/code.tsx | 3 +- .../knowledge/[id]/[documentId]/document.tsx | 111 +++++++++++++++- apps/sim/app/w/knowledge/[id]/base.tsx | 2 +- apps/sim/app/w/knowledge/knowledge.tsx | 2 +- apps/sim/hooks/use-knowledge.ts | 120 ++++++++++++++++-- apps/sim/stores/knowledge/store.ts | 19 ++- 9 files changed, 235 insertions(+), 28 deletions(-) diff --git a/apps/sim/app/w/[id]/components/loop-node/loop-node.tsx b/apps/sim/app/w/[id]/components/loop-node/loop-node.tsx index ae0bb86741b..d657fac658b 100644 --- a/apps/sim/app/w/[id]/components/loop-node/loop-node.tsx +++ b/apps/sim/app/w/[id]/components/loop-node/loop-node.tsx @@ -94,7 +94,7 @@ export const LoopNodeComponent = memo(({ data, selected, id }: NodeProps) => { const getNestedStyles = () => { // Base styles const styles: Record = { - backgroundColor: 'transparent', + backgroundColor: 'rgba(0, 0, 0, 0.02)', } // Apply nested styles diff --git a/apps/sim/app/w/[id]/components/parallel-node/parallel-config.ts b/apps/sim/app/w/[id]/components/parallel-node/parallel-config.ts index a1a79eb29bd..fe00c935bec 100644 --- a/apps/sim/app/w/[id]/components/parallel-node/parallel-config.ts +++ b/apps/sim/app/w/[id]/components/parallel-node/parallel-config.ts @@ -6,7 +6,7 @@ export const ParallelTool = { name: 'Parallel', description: 'Parallel Execution', icon: SplitIcon, - bgColor: '#8BC34A', + bgColor: '#FEE12B', data: { label: 'Parallel', parallelType: 'collection' as 'collection' | 'count', diff --git a/apps/sim/app/w/[id]/components/parallel-node/parallel-node.tsx b/apps/sim/app/w/[id]/components/parallel-node/parallel-node.tsx index d5b1cbd4e90..299a75acaed 100644 --- a/apps/sim/app/w/[id]/components/parallel-node/parallel-node.tsx +++ b/apps/sim/app/w/[id]/components/parallel-node/parallel-node.tsx @@ -111,7 +111,7 @@ export const ParallelNodeComponent = memo(({ data, selected, id }: NodeProps) => const getNestedStyles = () => { // Base styles const styles: Record = { - backgroundColor: 'transparent', + backgroundColor: 'rgba(0, 0, 0, 0.02)', } // Apply nested styles diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/code.tsx b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/code.tsx index b04b805bcb3..b6078b4dee9 100644 --- a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/code.tsx +++ b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/code.tsx @@ -81,7 +81,8 @@ export function Code({ (useSubBlockStore((state) => state.getValue(blockId, collapsedStateKey)) as boolean) ?? false const setCollapsedValue = useSubBlockStore((state) => state.setValue) - const showCollapseButton = subBlockId === 'responseFormat' && code.split('\n').length > 5 + const showCollapseButton = + (subBlockId === 'responseFormat' || subBlockId === 'code') && code.split('\n').length > 5 const editorRef = useRef(null) diff --git a/apps/sim/app/w/knowledge/[id]/[documentId]/document.tsx b/apps/sim/app/w/knowledge/[id]/[documentId]/document.tsx index 84888da21a0..6d663f464d9 100644 --- a/apps/sim/app/w/knowledge/[id]/[documentId]/document.tsx +++ b/apps/sim/app/w/knowledge/[id]/[documentId]/document.tsx @@ -1,7 +1,17 @@ 'use client' -import { useEffect, useState } from 'react' -import { Circle, CircleOff, FileText, Plus, Search, Trash2, X } from 'lucide-react' +import { useCallback, useEffect, useState } from 'react' +import { + ChevronLeft, + ChevronRight, + Circle, + CircleOff, + FileText, + Plus, + Search, + Trash2, + X, +} from 'lucide-react' import { Button } from '@/components/ui/button' import { Checkbox } from '@/components/ui/checkbox' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' @@ -59,11 +69,18 @@ export function Document({ const [isLoadingDocument, setIsLoadingDocument] = useState(true) const [error, setError] = useState(null) - // Use the new chunks hook + // Use the updated chunks hook with pagination const { chunks, isLoading: isLoadingChunks, error: chunksError, + currentPage, + totalPages, + hasNextPage, + hasPrevPage, + goToPage, + nextPage, + prevPage, refreshChunks, updateChunk, } = useDocumentChunks(knowledgeBaseId, documentId) @@ -71,6 +88,34 @@ export function Document({ // Combine errors const combinedError = error || chunksError + // Handle pagination navigation + const handlePrevPage = useCallback(() => { + if (hasPrevPage && !isLoadingChunks) { + prevPage()?.catch((err) => { + logger.error('Previous page failed:', err) + }) + } + }, [hasPrevPage, isLoadingChunks, prevPage]) + + const handleNextPage = useCallback(() => { + if (hasNextPage && !isLoadingChunks) { + nextPage()?.catch((err) => { + logger.error('Next page failed:', err) + }) + } + }, [hasNextPage, isLoadingChunks, nextPage]) + + const handleGoToPage = useCallback( + (page: number) => { + if (page !== currentPage && !isLoadingChunks) { + goToPage(page)?.catch((err) => { + logger.error('Go to page failed:', err) + }) + } + }, + [currentPage, isLoadingChunks, goToPage] + ) + // Try to get document from store cache first, then fetch if needed useEffect(() => { const fetchDocument = async () => { @@ -308,7 +353,7 @@ export function Document({ onClick={() => setIsCreateChunkModalOpen(true)} disabled={document?.processingStatus === 'failed'} size='sm' - className='flex items-center gap-1 bg-[#701FFC] font-[480] text-primary-foreground shadow-[0_0_0_0_#701FFC] transition-all duration-200 hover:bg-[#6518E6] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)] disabled:cursor-not-allowed disabled:opacity-50' + className='flex items-center gap-1 bg-[#701FFC] font-[480] text-white shadow-[0_0_0_0_#701FFC] transition-all duration-200 hover:bg-[#6518E6] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)] disabled:cursor-not-allowed disabled:opacity-50' > Create Chunk @@ -566,6 +611,64 @@ export function Document({ + + {/* Pagination Controls */} + {document?.processingStatus === 'completed' && totalPages > 1 && ( +
+
+ + + {/* Page numbers - show a few around current page */} +
+ {Array.from({ length: Math.min(totalPages, 5) }, (_, i) => { + let page: number + if (totalPages <= 5) { + page = i + 1 + } else if (currentPage <= 3) { + page = i + 1 + } else if (currentPage >= totalPages - 2) { + page = totalPages - 4 + i + } else { + page = currentPage - 2 + i + } + + if (page < 1 || page > totalPages) return null + + return ( + + ) + })} +
+ + +
+
+ )} diff --git a/apps/sim/app/w/knowledge/[id]/base.tsx b/apps/sim/app/w/knowledge/[id]/base.tsx index 5c0acac5c72..4eba4ca7fc8 100644 --- a/apps/sim/app/w/knowledge/[id]/base.tsx +++ b/apps/sim/app/w/knowledge/[id]/base.tsx @@ -569,7 +569,7 @@ export function KnowledgeBase({ onClick={handleAddDocuments} disabled={isUploading} size='sm' - className='flex items-center gap-1 bg-[#701FFC] font-[480] text-primary-foreground shadow-[0_0_0_0_#701FFC] transition-all duration-200 hover:bg-[#6518E6] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)]' + className='flex items-center gap-1 bg-[#701FFC] font-[480] text-white shadow-[0_0_0_0_#701FFC] transition-all duration-200 hover:bg-[#6518E6] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)]' > {isUploading ? 'Uploading...' : 'Add Documents'} diff --git a/apps/sim/app/w/knowledge/knowledge.tsx b/apps/sim/app/w/knowledge/knowledge.tsx index b9721ef749a..cc0cfc70cb2 100644 --- a/apps/sim/app/w/knowledge/knowledge.tsx +++ b/apps/sim/app/w/knowledge/knowledge.tsx @@ -92,7 +92,7 @@ export function Knowledge() { + + + + + ) +} diff --git a/apps/sim/app/w/components/sidebar/components/settings-modal/components/subscription/subscription.tsx b/apps/sim/app/w/components/sidebar/components/settings-modal/components/subscription/subscription.tsx index c0a2450d3a9..406f0db287b 100644 --- a/apps/sim/app/w/components/sidebar/components/settings-modal/components/subscription/subscription.tsx +++ b/apps/sim/app/w/components/sidebar/components/settings-modal/components/subscription/subscription.tsx @@ -2,26 +2,12 @@ import { useEffect, useState } from 'react' import { AlertCircle } from 'lucide-react' import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' import { Button } from '@/components/ui/button' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog' -import { Label } from '@/components/ui/label' import { Progress } from '@/components/ui/progress' -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select' import { Skeleton } from '@/components/ui/skeleton' import { useActiveOrganization, useSession, useSubscription } from '@/lib/auth-client' +import { env } from '@/lib/env' import { createLogger } from '@/lib/logs/console-logger' +import { TeamSeatsDialog } from './components/team-seats-dialog' const logger = createLogger('Subscription') @@ -332,7 +318,7 @@ export function Subscription({ setIsTeamDialogOpen(true) } - const confirmTeamUpgrade = async () => { + const confirmTeamUpgrade = async (selectedSeats?: number) => { if (!session?.user) { setError('You need to be logged in to upgrade your team subscription') return @@ -341,10 +327,12 @@ export function Subscription({ setIsUpgradingTeam(true) setError(null) + const seatsToUse = selectedSeats || seats + try { const result = await subscription.upgrade({ plan: 'team', - seats, + seats: seatsToUse, successUrl: window.location.href, cancelUrl: window.location.href, }) @@ -816,54 +804,19 @@ export function Subscription({ )} - - - - Team Subscription - - Set up a team workspace with collaborative features. Each seat costs $40/month and - gets $40 of inference credits. - - - -
- - - -

- Your team will have {seats} {seats === 1 ? 'seat' : 'seats'} with a total of $ - {seats * 40} inference credits per month. -

-
- - - - - -
-
+ { + setSeats(selectedSeats) + await confirmTeamUpgrade(selectedSeats) + }} + confirmButtonText='Upgrade to Team Plan' + /> )} diff --git a/apps/sim/app/w/components/sidebar/components/settings-modal/components/team-management/team-management.tsx b/apps/sim/app/w/components/sidebar/components/settings-modal/components/team-management/team-management.tsx index e9fe686013a..ea6a05d4e3c 100644 --- a/apps/sim/app/w/components/sidebar/components/settings-modal/components/team-management/team-management.tsx +++ b/apps/sim/app/w/components/sidebar/components/settings-modal/components/team-management/team-management.tsx @@ -15,8 +15,10 @@ import { Progress } from '@/components/ui/progress' import { Skeleton } from '@/components/ui/skeleton' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { client, useSession } from '@/lib/auth-client' +import { env } from '@/lib/env' import { createLogger } from '@/lib/logs/console-logger' import { checkEnterprisePlan } from '@/lib/subscription/utils' +import { TeamSeatsDialog } from '../subscription/components/team-seats-dialog' const logger = createLogger('TeamManagement') @@ -115,6 +117,10 @@ export function TeamManagement() { [activeOrganization] ) + const [isAddSeatDialogOpen, setIsAddSeatDialogOpen] = useState(false) + const [newSeatCount, setNewSeatCount] = useState(1) + const [isUpdatingSeats, setIsUpdatingSeats] = useState(false) + const loadData = useCallback(async () => { if (!session?.user) return @@ -281,7 +287,7 @@ export function TeamManagement() { } try { - await updateSeats(currentSeats - 1) + await reduceSeats(currentSeats - 1) await refreshOrganization() } catch (err: any) { setError(err.message || 'Failed to reduce seats') @@ -581,7 +587,7 @@ export function TeamManagement() { if (shouldReduceSeats && subscriptionData) { const currentSeats = subscriptionData.seats || 0 if (currentSeats > 1) { - await updateSeats(currentSeats - 1) + await reduceSeats(currentSeats - 1) } } @@ -637,34 +643,68 @@ export function TeamManagement() { ) } - const updateSeats = useCallback( - async (newSeatCount: number) => { - if (!subscriptionData || !activeOrganization) return + // Handle opening the add seat dialog + const handleAddSeatDialog = () => { + if (subscriptionData) { + setNewSeatCount((subscriptionData.seats || 1) + 1) // Default to current seats + 1 + setIsAddSeatDialogOpen(true) + } + } - // Don't allow enterprise users to modify seats - if (checkEnterprisePlan(subscriptionData)) { - setError('Enterprise plan seats can only be modified by contacting support') - return - } + // Handle reducing seats + const reduceSeats = async (newSeatCount: number) => { + if (!subscriptionData || !activeOrganization) return - try { - setIsLoading(true) - setError(null) + try { + setIsLoading(true) + setError(null) - const { error } = await client.subscription.upgrade({ - plan: 'team', - referenceId: activeOrganization.id, - successUrl: window.location.href, - cancelUrl: window.location.href, - seats: newSeatCount, - }) - if (error) throw new Error(error.message || 'Failed to update seats') - } finally { - setIsLoading(false) + const { error } = await client.subscription.upgrade({ + plan: 'team', + referenceId: activeOrganization.id, + subscriptionId: subscriptionData.id, + seats: newSeatCount, + successUrl: window.location.href, + cancelUrl: window.location.href, + }) + if (error) throw new Error(error.message || 'Failed to reduce seats') + } finally { + setIsLoading(false) + } + } + + // Confirm seat addition + const confirmAddSeats = async (selectedSeats?: number) => { + if (!subscriptionData || !activeOrganization) return + + const seatsToUse = selectedSeats || newSeatCount + + try { + setIsUpdatingSeats(true) + setError(null) + + const { error } = await client.subscription.upgrade({ + plan: 'team', + referenceId: activeOrganization.id, + subscriptionId: subscriptionData.id, + seats: seatsToUse, + successUrl: window.location.href, + cancelUrl: window.location.href, + }) + + if (error) { + setError(error.message || 'Failed to update seats') + } else { + // Close the dialog after successful upgrade + setIsAddSeatDialogOpen(false) + await refreshOrganization() } - }, - [subscriptionData, activeOrganization] - ) + } catch (err: any) { + setError(err.message || 'Failed to update seats') + } finally { + setIsUpdatingSeats(false) + } + } if (isLoading && !activeOrganization && !(hasTeamPlan || hasEnterprisePlan)) { return @@ -935,18 +975,7 @@ export function TeamManagement() { + + + + + New Workflow + + + + New Folder + + + + + {/* Folder creation dialog */} + + + + Create New Folder + +
+
+ + setFolderName(e.target.value)} + placeholder='Enter folder name...' + autoFocus + required + /> +
+
+ + +
+
+
+
+ + ) + } + + return ( + <> + + + + + + + + New Workflow + + + + New Folder + + + + + {/* Folder creation dialog */} + + + + Create New Folder + +
+
+ + setFolderName(e.target.value)} + placeholder='Enter folder name...' + autoFocus + required + /> +
+
+ + +
+
+
+
+ + ) +} diff --git a/apps/sim/app/w/components/sidebar/components/folder-context-menu/folder-context-menu.tsx b/apps/sim/app/w/components/sidebar/components/folder-context-menu/folder-context-menu.tsx new file mode 100644 index 00000000000..fcda8a79bec --- /dev/null +++ b/apps/sim/app/w/components/sidebar/components/folder-context-menu/folder-context-menu.tsx @@ -0,0 +1,209 @@ +'use client' + +import { useState } from 'react' +import { File, Folder, MoreHorizontal, Pencil, Trash2 } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { useFolderStore } from '@/stores/folders/store' +import { useWorkflowRegistry } from '@/stores/workflows/registry/store' + +interface FolderContextMenuProps { + folderId: string + folderName: string + onCreateWorkflow: (folderId: string) => void + onRename?: (folderId: string, newName: string) => void + onDelete?: (folderId: string) => void +} + +export function FolderContextMenu({ + folderId, + folderName, + onCreateWorkflow, + onRename, + onDelete, +}: FolderContextMenuProps) { + const [showSubfolderDialog, setShowSubfolderDialog] = useState(false) + const [showRenameDialog, setShowRenameDialog] = useState(false) + const [subfolderName, setSubfolderName] = useState('') + const [renameName, setRenameName] = useState(folderName) + const [isCreating, setIsCreating] = useState(false) + const [isRenaming, setIsRenaming] = useState(false) + + const { activeWorkspaceId } = useWorkflowRegistry() + const { createFolder, updateFolder, deleteFolder } = useFolderStore() + + const handleCreateWorkflow = () => { + onCreateWorkflow(folderId) + } + + const handleCreateSubfolder = () => { + setShowSubfolderDialog(true) + } + + const handleRename = () => { + setRenameName(folderName) + setShowRenameDialog(true) + } + + const handleDelete = () => { + if (onDelete) { + onDelete(folderId) + } else { + // Default delete behavior + deleteFolder(folderId) + } + } + + const handleSubfolderSubmit = async (e: React.FormEvent) => { + e.preventDefault() + if (!subfolderName.trim() || !activeWorkspaceId) return + + setIsCreating(true) + try { + await createFolder({ + name: subfolderName.trim(), + workspaceId: activeWorkspaceId, + parentId: folderId, + }) + setSubfolderName('') + setShowSubfolderDialog(false) + } catch (error) { + console.error('Failed to create subfolder:', error) + } finally { + setIsCreating(false) + } + } + + const handleRenameSubmit = async (e: React.FormEvent) => { + e.preventDefault() + if (!renameName.trim()) return + + setIsRenaming(true) + try { + if (onRename) { + onRename(folderId, renameName.trim()) + } else { + // Default rename behavior + await updateFolder(folderId, { name: renameName.trim() }) + } + setShowRenameDialog(false) + } catch (error) { + console.error('Failed to rename folder:', error) + } finally { + setIsRenaming(false) + } + } + + const handleCancel = () => { + setSubfolderName('') + setShowSubfolderDialog(false) + setRenameName(folderName) + setShowRenameDialog(false) + } + + return ( + <> + + + + + e.stopPropagation()}> + + + New Workflow + + + + New Subfolder + + + + + Rename + + + + Delete + + + + + {/* Subfolder creation dialog */} + + e.stopPropagation()}> + + Create New Subfolder + +
+
+ + setSubfolderName(e.target.value)} + placeholder='Enter folder name...' + autoFocus + required + /> +
+
+ + +
+
+
+
+ + {/* Rename dialog */} + + e.stopPropagation()}> + + Rename Folder + +
+
+ + setRenameName(e.target.value)} + placeholder='Enter folder name...' + autoFocus + required + /> +
+
+ + +
+
+
+
+ + ) +} diff --git a/apps/sim/app/w/components/sidebar/components/folder-tree/folder-tree.tsx b/apps/sim/app/w/components/sidebar/components/folder-tree/folder-tree.tsx new file mode 100644 index 00000000000..92a906c3260 --- /dev/null +++ b/apps/sim/app/w/components/sidebar/components/folder-tree/folder-tree.tsx @@ -0,0 +1,383 @@ +'use client' + +import { useEffect, useState } from 'react' +import clsx from 'clsx' +import { ChevronDown, ChevronRight, Folder, FolderOpen } from 'lucide-react' +import Link from 'next/link' +import { usePathname } from 'next/navigation' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { type FolderTreeNode, useFolderStore } from '@/stores/folders/store' +import { useWorkflowRegistry } from '@/stores/workflows/registry/store' +import type { WorkflowMetadata } from '@/stores/workflows/registry/types' +import { FolderContextMenu } from '../folder-context-menu/folder-context-menu' + +interface FolderItemProps { + folder: FolderTreeNode + isCollapsed?: boolean + onCreateWorkflow: (folderId?: string) => void +} + +function FolderItem({ folder, isCollapsed, onCreateWorkflow }: FolderItemProps) { + const [dragOver, setDragOver] = useState(false) + const { expandedFolders, toggleExpanded, updateFolderAPI, deleteFolder } = useFolderStore() + const { updateWorkflow } = useWorkflowRegistry() + + const isExpanded = expandedFolders.has(folder.id) + + const handleToggleExpanded = () => { + toggleExpanded(folder.id) + // Persist to server + updateFolderAPI(folder.id, { isExpanded: !isExpanded }).catch(console.error) + } + + const handleRename = async (folderId: string, newName: string) => { + try { + await updateFolderAPI(folderId, { name: newName }) + } catch (error) { + console.error('Failed to rename folder:', error) + } + } + + const handleDelete = async (folderId: string) => { + if ( + confirm( + `Are you sure you want to delete "${folder.name}"? Child folders and workflows will be moved to the parent folder.` + ) + ) { + try { + await deleteFolder(folderId) + } catch (error) { + console.error('Failed to delete folder:', error) + } + } + } + + // Drag and drop handlers + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + setDragOver(true) + } + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + setDragOver(false) + } + + const handleDrop = async (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + setDragOver(false) + + const workflowId = e.dataTransfer.getData('workflow-id') + if (workflowId && workflowId !== folder.id) { + try { + // Update workflow to be in this folder + await updateWorkflow(workflowId, { folderId: folder.id }) + console.log(`Moved workflow ${workflowId} to folder ${folder.id}`) + } catch (error) { + console.error('Failed to move workflow to folder:', error) + } + } + } + + if (isCollapsed) { + return ( + + +
+
+ {isExpanded ? ( + + ) : ( + + )} +
+
+
+ +

{folder.name}

+
+
+ ) + } + + return ( +
+
+
+ {isExpanded ? : } +
+ +
+ {isExpanded ? ( + + ) : ( + + )} +
+ + + {folder.name} + + +
e.stopPropagation()}> + +
+
+
+ ) +} + +interface WorkflowItemProps { + workflow: WorkflowMetadata + active: boolean + isMarketplace?: boolean + isCollapsed?: boolean + level: number +} + +function WorkflowItem({ workflow, active, isMarketplace, isCollapsed, level }: WorkflowItemProps) { + const [isDragging, setIsDragging] = useState(false) + + const handleDragStart = (e: React.DragEvent) => { + if (isMarketplace) return // Don't allow dragging marketplace workflows + + e.dataTransfer.setData('workflow-id', workflow.id) + e.dataTransfer.effectAllowed = 'move' + setIsDragging(true) + } + + const handleDragEnd = () => { + setIsDragging(false) + } + + if (isCollapsed) { + return ( + + + +
+ + + +

+ {workflow.name} + {isMarketplace && ' (Preview)'} +

+
+ + ) + } + + return ( + +
+ + {workflow.name} + {isMarketplace && ' (Preview)'} + + + ) +} + +interface FolderTreeProps { + regularWorkflows: WorkflowMetadata[] + marketplaceWorkflows: WorkflowMetadata[] + isCollapsed?: boolean + isLoading?: boolean + onCreateWorkflow: (folderId?: string) => void +} + +export function FolderTree({ + regularWorkflows, + marketplaceWorkflows, + isCollapsed = false, + isLoading = false, + onCreateWorkflow, +}: FolderTreeProps) { + const pathname = usePathname() + const { activeWorkspaceId } = useWorkflowRegistry() + const { + getFolderTree, + expandedFolders, + fetchFolders, + isLoading: foldersLoading, + } = useFolderStore() + + // Fetch folders when workspace changes + useEffect(() => { + if (activeWorkspaceId) { + fetchFolders(activeWorkspaceId) + } + }, [activeWorkspaceId, fetchFolders]) + + const folderTree = activeWorkspaceId ? getFolderTree(activeWorkspaceId) : [] + + // Group workflows by folder + const workflowsByFolder = regularWorkflows.reduce( + (acc, workflow) => { + const folderId = workflow.folderId || 'root' + if (!acc[folderId]) acc[folderId] = [] + acc[folderId].push(workflow) + return acc + }, + {} as Record + ) + + const renderFolderTree = (nodes: FolderTreeNode[], level = 0): React.ReactNode[] => { + const result: React.ReactNode[] = [] + + nodes.forEach((folder) => { + // Render folder + result.push( +
+ +
+ ) + + // Render workflows in this folder + const workflowsInFolder = workflowsByFolder[folder.id] || [] + if (expandedFolders.has(folder.id) && workflowsInFolder.length > 0) { + workflowsInFolder.forEach((workflow) => { + result.push( + + ) + }) + } + + // Render child folders + if (expandedFolders.has(folder.id) && folder.children.length > 0) { + result.push(...renderFolderTree(folder.children, level + 1)) + } + }) + + return result + } + + const showLoading = isLoading || foldersLoading + + return ( +
+ {/* Folder tree */} + {renderFolderTree(folderTree)} + + {/* Root level workflows (no folder) */} + {(workflowsByFolder.root || []).map((workflow) => ( + + ))} + + {/* Marketplace workflows */} + {marketplaceWorkflows.length > 0 && ( +
+

+ {isCollapsed ? '' : 'Marketplace'} +

+ {marketplaceWorkflows.map((workflow) => ( + + ))} +
+ )} + + {/* Empty state */} + {!showLoading && + regularWorkflows.length === 0 && + marketplaceWorkflows.length === 0 && + folderTree.length === 0 && + !isCollapsed && ( +
+ No workflows or folders in {activeWorkspaceId ? 'this workspace' : 'your account'}. + Create one to get started. +
+ )} +
+ ) +} diff --git a/apps/sim/app/w/components/sidebar/components/workspace-header/workspace-header.tsx b/apps/sim/app/w/components/sidebar/components/workspace-header/workspace-header.tsx index 838b8d58f36..56a7d059eac 100644 --- a/apps/sim/app/w/components/sidebar/components/workspace-header/workspace-header.tsx +++ b/apps/sim/app/w/components/sidebar/components/workspace-header/workspace-header.tsx @@ -1,7 +1,7 @@ 'use client' import { useEffect, useState } from 'react' -import { ChevronDown, Pencil, Plus, Trash2, X } from 'lucide-react' +import { ChevronDown, Pencil, Trash2, X } from 'lucide-react' import Link from 'next/link' import { useRouter } from 'next/navigation' import { AgentIcon } from '@/components/icons' @@ -27,7 +27,6 @@ import { } from '@/components/ui/dropdown-menu' import { Input } from '@/components/ui/input' import { Skeleton } from '@/components/ui/skeleton' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { useSession } from '@/lib/auth-client' import { cn } from '@/lib/utils' import { useSidebarStore } from '@/stores/sidebar/store' @@ -535,34 +534,6 @@ export function WorkspaceHeader({
- - {/* Plus button positioned absolutely */} - {!isCollapsed && ( -
- - -
- {isClientLoading ? ( - - ) : ( - - )} -
-
- New Workflow -
-
- )} )} diff --git a/apps/sim/app/w/components/sidebar/sidebar.tsx b/apps/sim/app/w/components/sidebar/sidebar.tsx index 7f755b764b3..6d8ce037df8 100644 --- a/apps/sim/app/w/components/sidebar/sidebar.tsx +++ b/apps/sim/app/w/components/sidebar/sidebar.tsx @@ -12,12 +12,13 @@ import { useSidebarStore } from '@/stores/sidebar/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import type { WorkflowMetadata } from '@/stores/workflows/registry/types' import { useRegistryLoading } from '../../hooks/use-registry-loading' +import { CreateMenu } from './components/create-menu/create-menu' +import { FolderTree } from './components/folder-tree/folder-tree' import { HelpModal } from './components/help-modal/help-modal' import { InviteModal } from './components/invite-modal/invite-modal' import { NavSection } from './components/nav-section/nav-section' import { SettingsModal } from './components/settings-modal/settings-modal' import { SidebarControl } from './components/sidebar-control/sidebar-control' -import { WorkflowList } from './components/workflow-list/workflow-list' import { WorkspaceHeader } from './components/workspace-header/workspace-header' export function Sidebar() { @@ -126,7 +127,7 @@ export function Sidebar() { }, [workflows, isLoading, activeWorkspaceId]) // Create workflow - const handleCreateWorkflow = async () => { + const handleCreateWorkflow = async (folderId?: string) => { try { // Import the isActivelyLoadingFromDB function to check sync status const { isActivelyLoadingFromDB } = await import('@/stores/workflows/sync') @@ -137,9 +138,10 @@ export function Sidebar() { return } - // Create the workflow and ensure it's associated with the active workspace + // Create the workflow and ensure it's associated with the active workspace and folder const id = createWorkflow({ workspaceId: activeWorkspaceId || undefined, + folderId: folderId || undefined, // Associate with folder if provided }) router.push(`/w/${id}`) @@ -224,26 +226,25 @@ export function Sidebar() {
{/* Workflows Section */}
-

- {isLoading ? ( - isCollapsed ? ( - '' - ) : ( - - ) - ) : isCollapsed ? ( - '' - ) : ( - 'Workflows' +

+ {isLoading ? : 'Workflows'} +

+ {!isCollapsed && !isLoading && ( + )} -

- +
diff --git a/apps/sim/app/w/logs/components/filters/components/folder.tsx b/apps/sim/app/w/logs/components/filters/components/folder.tsx new file mode 100644 index 00000000000..025e20a7193 --- /dev/null +++ b/apps/sim/app/w/logs/components/filters/components/folder.tsx @@ -0,0 +1,168 @@ +import { useEffect, useState } from 'react' +import { Check, ChevronDown, Folder } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import { useFilterStore } from '@/app/w/logs/stores/store' +import { useFolderStore } from '@/stores/folders/store' +import { useWorkflowRegistry } from '@/stores/workflows/registry/store' + +interface FolderOption { + id: string + name: string + color: string + path: string // For nested folders, show full path +} + +export default function FolderFilter() { + const { folderIds, toggleFolderId, setFolderIds } = useFilterStore() + const { getFolderTree, getFolderPath, fetchFolders } = useFolderStore() + const { activeWorkspaceId } = useWorkflowRegistry() + const [folders, setFolders] = useState([]) + const [loading, setLoading] = useState(true) + + // Fetch all available folders from the API + useEffect(() => { + const fetchFoldersData = async () => { + try { + setLoading(true) + if (activeWorkspaceId) { + await fetchFolders(activeWorkspaceId) + const folderTree = getFolderTree(activeWorkspaceId) + + // Flatten the folder tree and create options with full paths + const flattenFolders = (nodes: any[], parentPath = ''): FolderOption[] => { + const result: FolderOption[] = [] + + for (const node of nodes) { + const currentPath = parentPath ? `${parentPath} / ${node.name}` : node.name + result.push({ + id: node.id, + name: node.name, + color: node.color || '#6B7280', + path: currentPath, + }) + + // Add children recursively + if (node.children && node.children.length > 0) { + result.push(...flattenFolders(node.children, currentPath)) + } + } + + return result + } + + const folderOptions = flattenFolders(folderTree) + setFolders(folderOptions) + } + } catch (error) { + console.error('Failed to fetch folders:', error) + } finally { + setLoading(false) + } + } + + fetchFoldersData() + }, [activeWorkspaceId, fetchFolders, getFolderTree]) + + // Get display text for the dropdown button + const getSelectedFoldersText = () => { + if (folderIds.length === 0) return 'All folders' + if (folderIds.length === 1) { + const selected = folders.find((f) => f.id === folderIds[0]) + return selected ? selected.name : 'All folders' + } + return `${folderIds.length} folders selected` + } + + // Check if a folder is selected + const isFolderSelected = (folderId: string) => { + return folderIds.includes(folderId) + } + + // Clear all selections + const clearSelections = () => { + setFolderIds([]) + } + + // Add special option for workflows without folders + const includeRootOption = true + + return ( + + + + + + { + e.preventDefault() + clearSelections() + }} + className='flex cursor-pointer items-center justify-between p-2 text-sm' + > + All folders + {folderIds.length === 0 && } + + + {/* Option for workflows without folders */} + {includeRootOption && ( + { + e.preventDefault() + toggleFolderId('root') + }} + className='flex cursor-pointer items-center justify-between p-2 text-sm' + > +
+ + No folder +
+ {isFolderSelected('root') && } +
+ )} + + {(!loading && folders.length > 0) || includeRootOption ? : null} + + {!loading && + folders.map((folder) => ( + { + e.preventDefault() + toggleFolderId(folder.id) + }} + className='flex cursor-pointer items-center justify-between p-2 text-sm' + > +
+
+ + {folder.path} + +
+ {isFolderSelected(folder.id) && } + + ))} + + {loading && ( + + Loading folders... + + )} + + + ) +} diff --git a/apps/sim/app/w/logs/components/filters/filters.tsx b/apps/sim/app/w/logs/components/filters/filters.tsx index 04be4538c45..853ce8bd599 100644 --- a/apps/sim/app/w/logs/components/filters/filters.tsx +++ b/apps/sim/app/w/logs/components/filters/filters.tsx @@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button' import { isProd } from '@/lib/environment' import { useUserSubscription } from '@/hooks/use-user-subscription' import FilterSection from './components/filter-section' +import FolderFilter from './components/folder' import Level from './components/level' import Timeline from './components/timeline' import Trigger from './components/trigger' @@ -62,6 +63,9 @@ export function Filters() { {/* Trigger Filter */} } /> + {/* Folder Filter */} + } /> + {/* Workflow Filter */} } />
diff --git a/apps/sim/app/w/logs/logs.tsx b/apps/sim/app/w/logs/logs.tsx index b4dc0e1d3d4..a19d0ed0784 100644 --- a/apps/sim/app/w/logs/logs.tsx +++ b/apps/sim/app/w/logs/logs.tsx @@ -72,6 +72,7 @@ export default function Logs() { timeRange, level, workflowIds, + folderIds, searchQuery, triggers, } = useFilterStore() @@ -225,6 +226,7 @@ export default function Logs() { timeRange, level, workflowIds, + folderIds, searchQuery, triggers, setPage, diff --git a/apps/sim/app/w/logs/stores/store.ts b/apps/sim/app/w/logs/stores/store.ts index fe717abe284..95347860682 100644 --- a/apps/sim/app/w/logs/stores/store.ts +++ b/apps/sim/app/w/logs/stores/store.ts @@ -6,6 +6,7 @@ export const useFilterStore = create((set, get) => ({ timeRange: 'All time', level: 'all', workflowIds: [], + folderIds: [], searchQuery: '', triggers: [], loading: true, @@ -53,6 +54,25 @@ export const useFilterStore = create((set, get) => ({ get().resetPagination() }, + setFolderIds: (folderIds) => { + set({ folderIds }) + get().resetPagination() + }, + + toggleFolderId: (folderId) => { + const currentFolderIds = [...get().folderIds] + const index = currentFolderIds.indexOf(folderId) + + if (index === -1) { + currentFolderIds.push(folderId) + } else { + currentFolderIds.splice(index, 1) + } + + set({ folderIds: currentFolderIds }) + get().resetPagination() + }, + setSearchQuery: (searchQuery) => { set({ searchQuery }) get().resetPagination() @@ -91,7 +111,7 @@ export const useFilterStore = create((set, get) => ({ // Build query parameters for server-side filtering buildQueryParams: (page: number, limit: number) => { - const { timeRange, level, workflowIds, searchQuery, triggers } = get() + const { timeRange, level, workflowIds, folderIds, searchQuery, triggers } = get() const params = new URLSearchParams() params.set('includeWorkflow', 'true') @@ -113,6 +133,11 @@ export const useFilterStore = create((set, get) => ({ params.set('workflowIds', workflowIds.join(',')) } + // Add folder filter + if (folderIds.length > 0) { + params.set('folderIds', folderIds.join(',')) + } + // Add time range filter if (timeRange !== 'All time') { const now = new Date() diff --git a/apps/sim/app/w/logs/stores/types.ts b/apps/sim/app/w/logs/stores/types.ts index 86b9ce43f3c..c6266fd3967 100644 --- a/apps/sim/app/w/logs/stores/types.ts +++ b/apps/sim/app/w/logs/stores/types.ts @@ -93,6 +93,7 @@ export interface FilterState { timeRange: TimeRange level: LogLevel workflowIds: string[] + folderIds: string[] searchQuery: string triggers: TriggerType[] @@ -111,6 +112,8 @@ export interface FilterState { setLevel: (level: LogLevel) => void setWorkflowIds: (workflowIds: string[]) => void toggleWorkflowId: (workflowId: string) => void + setFolderIds: (folderIds: string[]) => void + toggleFolderId: (folderId: string) => void setSearchQuery: (query: string) => void setTriggers: (triggers: TriggerType[]) => void toggleTrigger: (trigger: TriggerType) => void diff --git a/apps/sim/db/migrations/0042_breezy_miracleman.sql b/apps/sim/db/migrations/0042_breezy_miracleman.sql new file mode 100644 index 00000000000..67d58a2e9e0 --- /dev/null +++ b/apps/sim/db/migrations/0042_breezy_miracleman.sql @@ -0,0 +1,21 @@ +CREATE TABLE "workflow_folder" ( + "id" text PRIMARY KEY NOT NULL, + "name" text NOT NULL, + "user_id" text NOT NULL, + "workspace_id" text NOT NULL, + "parent_id" text, + "color" text DEFAULT '#6B7280', + "is_expanded" boolean DEFAULT true NOT NULL, + "sort_order" integer DEFAULT 0 NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "workflow" ADD COLUMN "folder_id" text;--> statement-breakpoint +ALTER TABLE "workflow_folder" ADD CONSTRAINT "workflow_folder_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "workflow_folder" ADD CONSTRAINT "workflow_folder_workspace_id_workspace_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspace"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "workflow_folder" ADD CONSTRAINT "workflow_folder_parent_id_workflow_folder_id_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."workflow_folder"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "workflow_folder_workspace_parent_idx" ON "workflow_folder" USING btree ("workspace_id","parent_id");--> statement-breakpoint +CREATE INDEX "workflow_folder_user_idx" ON "workflow_folder" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "workflow_folder_parent_sort_idx" ON "workflow_folder" USING btree ("parent_id","sort_order");--> statement-breakpoint +ALTER TABLE "workflow" ADD CONSTRAINT "workflow_folder_id_workflow_folder_id_fk" FOREIGN KEY ("folder_id") REFERENCES "public"."workflow_folder"("id") ON DELETE set null ON UPDATE no action; \ No newline at end of file diff --git a/apps/sim/db/migrations/meta/0042_snapshot.json b/apps/sim/db/migrations/meta/0042_snapshot.json new file mode 100644 index 00000000000..40abb4d8424 --- /dev/null +++ b/apps/sim/db/migrations/meta/0042_snapshot.json @@ -0,0 +1,3082 @@ +{ + "id": "5a104de1-5afa-46be-bbe8-5a8759024b15", + "prevId": "01a747d8-d7e0-4f49-af52-b45e0f4343a9", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_key": { + "name": "api_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "api_key_user_id_user_id_fk": { + "name": "api_key_user_id_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_key_key_unique": { + "name": "api_key_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat": { + "name": "chat", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subdomain": { + "name": "subdomain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "customizations": { + "name": "customizations", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "output_configs": { + "name": "output_configs", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "subdomain_idx": { + "name": "subdomain_idx", + "columns": [ + { + "expression": "subdomain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_workflow_id_workflow_id_fk": { + "name": "chat_workflow_id_workflow_id_fk", + "tableFrom": "chat", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_user_id_user_id_fk": { + "name": "chat_user_id_user_id_fk", + "tableFrom": "chat", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_tools": { + "name": "custom_tools", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "custom_tools_user_id_user_id_fk": { + "name": "custom_tools_user_id_user_id_fk", + "tableFrom": "custom_tools", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.document": { + "name": "document", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_url": { + "name": "file_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_hash": { + "name": "file_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "chunk_count": { + "name": "chunk_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "character_count": { + "name": "character_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "processing_status": { + "name": "processing_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_completed_at": { + "name": "processing_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_error": { + "name": "processing_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "doc_kb_id_idx": { + "name": "doc_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_file_hash_idx": { + "name": "doc_file_hash_idx", + "columns": [ + { + "expression": "file_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_filename_idx": { + "name": "doc_filename_idx", + "columns": [ + { + "expression": "filename", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_kb_uploaded_at_idx": { + "name": "doc_kb_uploaded_at_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "uploaded_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_processing_status_idx": { + "name": "doc_processing_status_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "processing_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_knowledge_base_id_knowledge_base_id_fk": { + "name": "document_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.embedding": { + "name": "embedding", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_index": { + "name": "chunk_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chunk_hash": { + "name": "chunk_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_length": { + "name": "content_length", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "start_offset": { + "name": "start_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "end_offset": { + "name": "end_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "overlap_tokens": { + "name": "overlap_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "search_rank": { + "name": "search_rank", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'1.0'" + }, + "access_count": { + "name": "access_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_accessed_at": { + "name": "last_accessed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "quality_score": { + "name": "quality_score", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "content_tsv": { + "name": "content_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"embedding\".\"content\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "emb_kb_id_idx": { + "name": "emb_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_id_idx": { + "name": "emb_doc_id_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_chunk_idx": { + "name": "emb_doc_chunk_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chunk_index", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_model_idx": { + "name": "emb_kb_model_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_chunk_hash_idx": { + "name": "emb_chunk_hash_idx", + "columns": [ + { + "expression": "chunk_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_access_idx": { + "name": "emb_kb_access_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_accessed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_rank_idx": { + "name": "emb_kb_rank_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "search_rank", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_enabled_idx": { + "name": "emb_kb_enabled_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_enabled_idx": { + "name": "emb_doc_enabled_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "embedding_vector_hnsw_idx": { + "name": "embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "emb_metadata_gin_idx": { + "name": "emb_metadata_gin_idx", + "columns": [ + { + "expression": "metadata", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "emb_content_fts_idx": { + "name": "emb_content_fts_idx", + "columns": [ + { + "expression": "content_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "embedding_knowledge_base_id_knowledge_base_id_fk": { + "name": "embedding_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "embedding", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "embedding_document_id_document_id_fk": { + "name": "embedding_document_id_document_id_fk", + "tableFrom": "embedding", + "tableTo": "document", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "embedding_not_null_check": { + "name": "embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.environment": { + "name": "environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "environment_user_id_user_id_fk": { + "name": "environment_user_id_user_id_fk", + "tableFrom": "environment", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "environment_user_id_unique": { + "name": "environment_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base": { + "name": "knowledge_base", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "embedding_dimension": { + "name": "embedding_dimension", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1536 + }, + "chunking_config": { + "name": "chunking_config", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{\"maxSize\": 1024, \"minSize\": 100, \"overlap\": 200}'" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_user_id_idx": { + "name": "kb_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_id_idx": { + "name": "kb_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_user_workspace_idx": { + "name": "kb_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_deleted_at_idx": { + "name": "kb_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_user_id_user_id_fk": { + "name": "knowledge_base_user_id_user_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_workspace_id_workspace_id_fk": { + "name": "knowledge_base_workspace_id_workspace_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.marketplace": { + "name": "marketplace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_name": { + "name": "author_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "views": { + "name": "views", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "marketplace_workflow_id_workflow_id_fk": { + "name": "marketplace_workflow_id_workflow_id_fk", + "tableFrom": "marketplace", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "marketplace_author_id_user_id_fk": { + "name": "marketplace_author_id_user_id_fk", + "tableFrom": "marketplace", + "tableTo": "user", + "columnsFrom": ["author_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memory": { + "name": "memory", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "memory_key_idx": { + "name": "memory_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workflow_idx": { + "name": "memory_workflow_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workflow_key_idx": { + "name": "memory_workflow_key_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memory_workflow_id_workflow_id_fk": { + "name": "memory_workflow_id_workflow_id_fk", + "tableFrom": "memory", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_active_organization_id_organization_id_fk": { + "name": "session_active_organization_id_organization_id_fk", + "tableFrom": "session", + "tableTo": "organization", + "columnsFrom": ["active_organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "debug_mode": { + "name": "debug_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "auto_connect": { + "name": "auto_connect", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "auto_fill_env_vars": { + "name": "auto_fill_env_vars", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "telemetry_enabled": { + "name": "telemetry_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "telemetry_notified_user": { + "name": "telemetry_notified_user", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "email_preferences": { + "name": "email_preferences", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "general": { + "name": "general", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "settings_user_id_user_id_fk": { + "name": "settings_user_id_user_id_fk", + "tableFrom": "settings", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "settings_user_id_unique": { + "name": "settings_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscription": { + "name": "subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "period_start": { + "name": "period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "seats": { + "name": "seats", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "trial_start": { + "name": "trial_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_end": { + "name": "trial_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_stats": { + "name": "user_stats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "total_manual_executions": { + "name": "total_manual_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_api_calls": { + "name": "total_api_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_webhook_triggers": { + "name": "total_webhook_triggers", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_scheduled_executions": { + "name": "total_scheduled_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_chat_executions": { + "name": "total_chat_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_tokens_used": { + "name": "total_tokens_used", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cost": { + "name": "total_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_active": { + "name": "last_active", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_stats_user_id_user_id_fk": { + "name": "user_stats_user_id_user_id_fk", + "tableFrom": "user_stats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_stats_user_id_unique": { + "name": "user_stats_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist": { + "name": "waitlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "waitlist_email_unique": { + "name": "waitlist_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook": { + "name": "webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_config": { + "name": "provider_config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "path_idx": { + "name": "path_idx", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_workflow_id_workflow_id_fk": { + "name": "webhook_workflow_id_workflow_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow": { + "name": "workflow", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#3972F6'" + }, + "last_synced": { + "name": "last_synced", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "is_deployed": { + "name": "is_deployed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deployed_state": { + "name": "deployed_state", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "deployed_at": { + "name": "deployed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "collaborators": { + "name": "collaborators", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "marketplace_data": { + "name": "marketplace_data", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "is_published": { + "name": "is_published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "workflow_user_id_user_id_fk": { + "name": "workflow_user_id_user_id_fk", + "tableFrom": "workflow", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_workspace_id_workspace_id_fk": { + "name": "workflow_workspace_id_workspace_id_fk", + "tableFrom": "workflow", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_folder_id_workflow_folder_id_fk": { + "name": "workflow_folder_id_workflow_folder_id_fk", + "tableFrom": "workflow", + "tableTo": "workflow_folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_folder": { + "name": "workflow_folder", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'#6B7280'" + }, + "is_expanded": { + "name": "is_expanded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_folder_workspace_parent_idx": { + "name": "workflow_folder_workspace_parent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_user_idx": { + "name": "workflow_folder_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_parent_sort_idx": { + "name": "workflow_folder_parent_sort_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_folder_user_id_user_id_fk": { + "name": "workflow_folder_user_id_user_id_fk", + "tableFrom": "workflow_folder", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_folder_workspace_id_workspace_id_fk": { + "name": "workflow_folder_workspace_id_workspace_id_fk", + "tableFrom": "workflow_folder", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_logs": { + "name": "workflow_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "duration": { + "name": "duration", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "workflow_logs_workflow_id_workflow_id_fk": { + "name": "workflow_logs_workflow_id_workflow_id_fk", + "tableFrom": "workflow_logs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_schedule": { + "name": "workflow_schedule", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_ran_at": { + "name": "last_ran_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trigger_type": { + "name": "trigger_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workflow_schedule_workflow_id_workflow_id_fk": { + "name": "workflow_schedule_workflow_id_workflow_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workflow_schedule_workflow_id_unique": { + "name": "workflow_schedule_workflow_id_unique", + "nullsNotDistinct": false, + "columns": ["workflow_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace": { + "name": "workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_owner_id_user_id_fk": { + "name": "workspace_owner_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["owner_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_invitation": { + "name": "workspace_invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_invitation_workspace_id_workspace_id_fk": { + "name": "workspace_invitation_workspace_id_workspace_id_fk", + "tableFrom": "workspace_invitation", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_invitation_inviter_id_user_id_fk": { + "name": "workspace_invitation_inviter_id_user_id_fk", + "tableFrom": "workspace_invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_invitation_token_unique": { + "name": "workspace_invitation_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_member": { + "name": "workspace_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_workspace_idx": { + "name": "user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_member_workspace_id_workspace_id_fk": { + "name": "workspace_member_workspace_id_workspace_id_fk", + "tableFrom": "workspace_member", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_member_user_id_user_id_fk": { + "name": "workspace_member_user_id_user_id_fk", + "tableFrom": "workspace_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/apps/sim/db/migrations/meta/_journal.json b/apps/sim/db/migrations/meta/_journal.json index 9afe5b351d0..ff79c9ee703 100644 --- a/apps/sim/db/migrations/meta/_journal.json +++ b/apps/sim/db/migrations/meta/_journal.json @@ -288,6 +288,13 @@ "when": 1749514555378, "tag": "0041_sparkling_ma_gnuci", "breakpoints": true + }, + { + "idx": 42, + "version": "7", + "when": 1749784177503, + "tag": "0042_breezy_miracleman", + "breakpoints": true } ] } diff --git a/apps/sim/db/migrations/relations.ts b/apps/sim/db/migrations/relations.ts deleted file mode 100644 index d043340e263..00000000000 --- a/apps/sim/db/migrations/relations.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { relations } from 'drizzle-orm/relations' -import { account, session, user } from './schema' - -export const accountRelations = relations(account, ({ one }) => ({ - user: one(user, { - fields: [account.userId], - references: [user.id], - }), -})) - -export const userRelations = relations(user, ({ many }) => ({ - accounts: many(account), - sessions: many(session), -})) - -export const sessionRelations = relations(session, ({ one }) => ({ - user: one(user, { - fields: [session.userId], - references: [user.id], - }), -})) diff --git a/apps/sim/db/migrations/schema.ts b/apps/sim/db/migrations/schema.ts deleted file mode 100644 index 6454c56d725..00000000000 --- a/apps/sim/db/migrations/schema.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { boolean, foreignKey, pgTable, text, timestamp, unique } from 'drizzle-orm/pg-core' - -export const verification = pgTable('verification', { - id: text().primaryKey().notNull(), - identifier: text().notNull(), - value: text().notNull(), - expiresAt: timestamp('expires_at', { mode: 'string' }).notNull(), - createdAt: timestamp('created_at', { mode: 'string' }), - updatedAt: timestamp('updated_at', { mode: 'string' }), -}) - -export const user = pgTable( - 'user', - { - id: text().primaryKey().notNull(), - name: text().notNull(), - email: text().notNull(), - emailVerified: boolean('email_verified').notNull(), - image: text(), - createdAt: timestamp('created_at', { mode: 'string' }).notNull(), - updatedAt: timestamp('updated_at', { mode: 'string' }).notNull(), - }, - (table) => [unique('user_email_unique').on(table.email)] -) - -export const account = pgTable( - 'account', - { - id: text().primaryKey().notNull(), - accountId: text('account_id').notNull(), - providerId: text('provider_id').notNull(), - userId: text('user_id').notNull(), - accessToken: text('access_token'), - refreshToken: text('refresh_token'), - idToken: text('id_token'), - accessTokenExpiresAt: timestamp('access_token_expires_at', { - mode: 'string', - }), - refreshTokenExpiresAt: timestamp('refresh_token_expires_at', { - mode: 'string', - }), - scope: text(), - password: text(), - createdAt: timestamp('created_at', { mode: 'string' }).notNull(), - updatedAt: timestamp('updated_at', { mode: 'string' }).notNull(), - }, - (table) => [ - foreignKey({ - columns: [table.userId], - foreignColumns: [user.id], - name: 'account_user_id_user_id_fk', - }).onDelete('cascade'), - ] -) - -export const session = pgTable( - 'session', - { - id: text().primaryKey().notNull(), - expiresAt: timestamp('expires_at', { mode: 'string' }).notNull(), - token: text().notNull(), - createdAt: timestamp('created_at', { mode: 'string' }).notNull(), - updatedAt: timestamp('updated_at', { mode: 'string' }).notNull(), - ipAddress: text('ip_address'), - userAgent: text('user_agent'), - userId: text('user_id').notNull(), - }, - (table) => [ - foreignKey({ - columns: [table.userId], - foreignColumns: [user.id], - name: 'session_user_id_user_id_fk', - }).onDelete('cascade'), - unique('session_token_unique').on(table.token), - ] -) diff --git a/apps/sim/db/schema.ts b/apps/sim/db/schema.ts index a8c74a8f6eb..c0094992c6b 100644 --- a/apps/sim/db/schema.ts +++ b/apps/sim/db/schema.ts @@ -78,12 +78,41 @@ export const verification = pgTable('verification', { updatedAt: timestamp('updated_at'), }) +export const workflowFolder = pgTable( + 'workflow_folder', + { + id: text('id').primaryKey(), + name: text('name').notNull(), + userId: text('user_id') + .notNull() + .references(() => user.id, { onDelete: 'cascade' }), + workspaceId: text('workspace_id') + .notNull() + .references(() => workspace.id, { onDelete: 'cascade' }), + parentId: text('parent_id'), // Self-reference will be handled by foreign key constraint + color: text('color').default('#6B7280'), + isExpanded: boolean('is_expanded').notNull().default(true), + sortOrder: integer('sort_order').notNull().default(0), + createdAt: timestamp('created_at').notNull().defaultNow(), + updatedAt: timestamp('updated_at').notNull().defaultNow(), + }, + (table) => ({ + userIdx: index('workflow_folder_user_idx').on(table.userId), + workspaceParentIdx: index('workflow_folder_workspace_parent_idx').on( + table.workspaceId, + table.parentId + ), + parentSortIdx: index('workflow_folder_parent_sort_idx').on(table.parentId, table.sortOrder), + }) +) + export const workflow = pgTable('workflow', { id: text('id').primaryKey(), userId: text('user_id') .notNull() .references(() => user.id, { onDelete: 'cascade' }), workspaceId: text('workspace_id').references(() => workspace.id, { onDelete: 'cascade' }), + folderId: text('folder_id').references(() => workflowFolder.id, { onDelete: 'set null' }), name: text('name').notNull(), description: text('description'), state: json('state').notNull(), @@ -98,11 +127,8 @@ export const workflow = pgTable('workflow', { runCount: integer('run_count').notNull().default(0), lastRunAt: timestamp('last_run_at'), variables: json('variables').default('{}'), - marketplaceData: json('marketplace_data'), // Format: { id: string, status: 'owner' | 'temp' } - - // These columns are kept for backward compatibility during migration - // @deprecated - Use marketplaceData instead isPublished: boolean('is_published').notNull().default(false), + marketplaceData: json('marketplace_data'), }) export const waitlist = pgTable('waitlist', { diff --git a/apps/sim/stores/folders/store.ts b/apps/sim/stores/folders/store.ts new file mode 100644 index 00000000000..de063d4ddc1 --- /dev/null +++ b/apps/sim/stores/folders/store.ts @@ -0,0 +1,270 @@ +import { create } from 'zustand' +import { devtools } from 'zustand/middleware' + +export interface WorkflowFolder { + id: string + name: string + userId: string + workspaceId: string + parentId: string | null + color: string + isExpanded: boolean + sortOrder: number + createdAt: Date + updatedAt: Date +} + +export interface FolderTreeNode extends WorkflowFolder { + children: FolderTreeNode[] + level: number +} + +interface FolderState { + folders: Record + isLoading: boolean + expandedFolders: Set + + // Actions + setFolders: (folders: WorkflowFolder[]) => void + addFolder: (folder: WorkflowFolder) => void + updateFolder: (id: string, updates: Partial) => void + removeFolder: (id: string) => void + setLoading: (loading: boolean) => void + toggleExpanded: (folderId: string) => void + setExpanded: (folderId: string, expanded: boolean) => void + + // Computed values + getFolderTree: (workspaceId: string) => FolderTreeNode[] + getFolderById: (id: string) => WorkflowFolder | undefined + getChildFolders: (parentId: string | null) => WorkflowFolder[] + getFolderPath: (folderId: string) => WorkflowFolder[] + + // API actions + fetchFolders: (workspaceId: string) => Promise + createFolder: (data: { + name: string + workspaceId: string + parentId?: string + color?: string + }) => Promise + updateFolderAPI: (id: string, updates: Partial) => Promise + deleteFolder: (id: string, moveWorkflowsTo?: string) => Promise +} + +export const useFolderStore = create()( + devtools( + (set, get) => ({ + folders: {}, + isLoading: false, + expandedFolders: new Set(), + + setFolders: (folders) => + set(() => ({ + folders: folders.reduce( + (acc, folder) => { + acc[folder.id] = folder + return acc + }, + {} as Record + ), + })), + + addFolder: (folder) => + set((state) => ({ + folders: { ...state.folders, [folder.id]: folder }, + })), + + updateFolder: (id, updates) => + set((state) => ({ + folders: { + ...state.folders, + [id]: state.folders[id] ? { ...state.folders[id], ...updates } : state.folders[id], + }, + })), + + removeFolder: (id) => + set((state) => { + const newFolders = { ...state.folders } + delete newFolders[id] + return { folders: newFolders } + }), + + setLoading: (loading) => set({ isLoading: loading }), + + toggleExpanded: (folderId) => + set((state) => { + const newExpanded = new Set(state.expandedFolders) + if (newExpanded.has(folderId)) { + newExpanded.delete(folderId) + } else { + newExpanded.add(folderId) + } + return { expandedFolders: newExpanded } + }), + + setExpanded: (folderId, expanded) => + set((state) => { + const newExpanded = new Set(state.expandedFolders) + if (expanded) { + newExpanded.add(folderId) + } else { + newExpanded.delete(folderId) + } + return { expandedFolders: newExpanded } + }), + + getFolderTree: (workspaceId) => { + const folders = Object.values(get().folders).filter((f) => f.workspaceId === workspaceId) + + const buildTree = (parentId: string | null, level = 0): FolderTreeNode[] => { + return folders + .filter((folder) => folder.parentId === parentId) + .sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name)) + .map((folder) => ({ + ...folder, + children: buildTree(folder.id, level + 1), + level, + })) + } + + return buildTree(null) + }, + + getFolderById: (id) => get().folders[id], + + getChildFolders: (parentId) => + Object.values(get().folders) + .filter((folder) => folder.parentId === parentId) + .sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name)), + + getFolderPath: (folderId) => { + const folders = get().folders + const path: WorkflowFolder[] = [] + let currentId: string | null = folderId + + while (currentId && folders[currentId]) { + const folder: WorkflowFolder = folders[currentId] + path.unshift(folder) + currentId = folder.parentId + } + + return path + }, + + fetchFolders: async (workspaceId) => { + set({ isLoading: true }) + try { + const response = await fetch(`/api/folders?workspaceId=${workspaceId}`) + if (!response.ok) { + throw new Error('Failed to fetch folders') + } + const { folders }: { folders: any[] } = await response.json() + + // Convert date strings to Date objects + const processedFolders: WorkflowFolder[] = folders.map((folder: any) => ({ + id: folder.id, + name: folder.name, + userId: folder.userId, + workspaceId: folder.workspaceId, + parentId: folder.parentId, + color: folder.color, + isExpanded: folder.isExpanded, + sortOrder: folder.sortOrder, + createdAt: new Date(folder.createdAt), + updatedAt: new Date(folder.updatedAt), + })) + + get().setFolders(processedFolders) + + // Initialize expanded state from folder data + const expandedSet = new Set() + processedFolders.forEach((folder: WorkflowFolder) => { + if (folder.isExpanded) { + expandedSet.add(folder.id) + } + }) + set({ expandedFolders: expandedSet }) + } catch (error) { + console.error('Error fetching folders:', error) + } finally { + set({ isLoading: false }) + } + }, + + createFolder: async (data) => { + const response = await fetch('/api/folders', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }) + + if (!response.ok) { + const error = await response.json() + throw new Error(error.error || 'Failed to create folder') + } + + const { folder } = await response.json() + const processedFolder = { + ...folder, + createdAt: new Date(folder.createdAt), + updatedAt: new Date(folder.updatedAt), + } + + get().addFolder(processedFolder) + return processedFolder + }, + + updateFolderAPI: async (id, updates) => { + const response = await fetch(`/api/folders/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(updates), + }) + + if (!response.ok) { + const error = await response.json() + throw new Error(error.error || 'Failed to update folder') + } + + const { folder } = await response.json() + const processedFolder = { + ...folder, + createdAt: new Date(folder.createdAt), + updatedAt: new Date(folder.updatedAt), + } + + get().updateFolder(id, processedFolder) + + // Update expanded state if isExpanded was changed + if (updates.isExpanded !== undefined) { + get().setExpanded(id, updates.isExpanded) + } + + return processedFolder + }, + + deleteFolder: async (id, moveWorkflowsTo) => { + const url = moveWorkflowsTo + ? `/api/folders/${id}?moveWorkflowsTo=${moveWorkflowsTo}` + : `/api/folders/${id}` + + const response = await fetch(url, { method: 'DELETE' }) + + if (!response.ok) { + const error = await response.json() + throw new Error(error.error || 'Failed to delete folder') + } + + get().removeFolder(id) + + // Remove from expanded state + set((state) => { + const newExpanded = new Set(state.expandedFolders) + newExpanded.delete(id) + return { expandedFolders: newExpanded } + }) + }, + }), + { name: 'folder-store' } + ) +) diff --git a/apps/sim/stores/workflows/index.ts b/apps/sim/stores/workflows/index.ts index 5586b69df82..f00b26f4499 100644 --- a/apps/sim/stores/workflows/index.ts +++ b/apps/sim/stores/workflows/index.ts @@ -67,6 +67,8 @@ export function getWorkflowWithValues(workflowId: string) { description: metadata.description, color: metadata.color || '#3972F6', marketplaceData: metadata.marketplaceData || null, + workspaceId: metadata.workspaceId, + folderId: metadata.folderId, state: { blocks: mergedBlocks, edges: workflowState.edges, @@ -161,6 +163,7 @@ export function getAllWorkflowsWithValues() { color: metadata.color || '#3972F6', marketplaceData: metadata.marketplaceData || null, workspaceId: metadata.workspaceId, // Include workspaceId in the result + folderId: metadata.folderId, // Include folderId in the result state: { blocks: mergedBlocks, edges: workflowState.edges, diff --git a/apps/sim/stores/workflows/registry/store.ts b/apps/sim/stores/workflows/registry/store.ts index 157fca4c7f6..70d7cf13291 100644 --- a/apps/sim/stores/workflows/registry/store.ts +++ b/apps/sim/stores/workflows/registry/store.ts @@ -669,6 +669,7 @@ export const useWorkflowRegistry = create()( ? { id: options.marketplaceId, status: 'temp' as const } : undefined, workspaceId, // Associate with workspace + folderId: options.folderId || null, // Associate with folder if provided } let initialState: any diff --git a/apps/sim/stores/workflows/registry/types.ts b/apps/sim/stores/workflows/registry/types.ts index 2027a0cacc2..facb08ce540 100644 --- a/apps/sim/stores/workflows/registry/types.ts +++ b/apps/sim/stores/workflows/registry/types.ts @@ -18,6 +18,7 @@ export interface WorkflowMetadata { color: string marketplaceData?: MarketplaceData | null workspaceId?: string + folderId?: string | null } export interface WorkflowRegistryState { @@ -43,6 +44,7 @@ export interface WorkflowRegistryActions { name?: string description?: string workspaceId?: string + folderId?: string | null }) => string duplicateWorkflow: (sourceId: string) => string | null getWorkflowDeploymentStatus: (workflowId: string | null) => DeploymentStatus | null diff --git a/apps/sim/stores/workflows/sync.ts b/apps/sim/stores/workflows/sync.ts index c2fbeb412b0..9eea7f8b58f 100644 --- a/apps/sim/stores/workflows/sync.ts +++ b/apps/sim/stores/workflows/sync.ts @@ -237,6 +237,7 @@ export async function fetchWorkflowsFromDB(): Promise { createdAt, marketplaceData, workspaceId, // Extract workspaceId + folderId, // Extract folderId } = workflow // Ensure this workflow belongs to the current workspace @@ -257,6 +258,7 @@ export async function fetchWorkflowsFromDB(): Promise { lastModified: createdAt ? new Date(createdAt) : new Date(lastSynced), marketplaceData: marketplaceData || null, workspaceId, // Include workspaceId in metadata + folderId: folderId || null, // Include folderId in metadata } // 2. Prepare workflow state data From 535a9d382d30753e77357e7df063c513d2319d61 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Fri, 13 Jun 2025 21:23:00 -0700 Subject: [PATCH 16/67] revert tab sync --- apps/sim/app/w/[id]/workflow.tsx | 6 - apps/sim/hooks/use-tab-sync.ts | 308 ------------------------------- 2 files changed, 314 deletions(-) delete mode 100644 apps/sim/hooks/use-tab-sync.ts diff --git a/apps/sim/app/w/[id]/workflow.tsx b/apps/sim/app/w/[id]/workflow.tsx index fc703edb7fb..ff10a7b9d6f 100644 --- a/apps/sim/app/w/[id]/workflow.tsx +++ b/apps/sim/app/w/[id]/workflow.tsx @@ -18,7 +18,6 @@ import { LoopNodeComponent } from '@/app/w/[id]/components/loop-node/loop-node' import { NotificationList } from '@/app/w/[id]/components/notifications/notifications' import { ParallelNodeComponent } from '@/app/w/[id]/components/parallel-node/parallel-node' import { getBlock } from '@/blocks' -import { useTabSync } from '@/hooks/use-tab-sync' import { useExecutionStore } from '@/stores/execution/store' import { useNotificationStore } from '@/stores/notifications/store' import { useVariablesStore } from '@/stores/panel/variables/store' @@ -98,11 +97,6 @@ function WorkflowContent() { const { isDebugModeEnabled } = useGeneralStore() const [dragStartParentId, setDragStartParentId] = useState(null) - // Tab synchronization hook - automatically syncs workflow when tab becomes visible - useTabSync({ - enabled: true, - }) - // Helper function to update a node's parent with proper position calculation const updateNodeParent = useCallback( (nodeId: string, newParentId: string | null) => { diff --git a/apps/sim/hooks/use-tab-sync.ts b/apps/sim/hooks/use-tab-sync.ts deleted file mode 100644 index 561479f25f5..00000000000 --- a/apps/sim/hooks/use-tab-sync.ts +++ /dev/null @@ -1,308 +0,0 @@ -'use client' - -import { useCallback, useEffect, useRef } from 'react' -import { createLogger } from '@/lib/logs/console-logger' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' -import { useSubBlockStore } from '@/stores/workflows/subblock/store' -import { fetchWorkflowsFromDB } from '@/stores/workflows/sync' -import { useWorkflowStore } from '@/stores/workflows/workflow/store' - -const logger = createLogger('TabSync') - -export interface TabSyncOptions { - /** Whether tab sync is enabled. Default: true */ - enabled?: boolean - /** Minimum time in ms between syncs. Default: 2000 */ - minSyncInterval?: number -} - -/** - * Helper function to normalize blocks for comparison, excluding position data - * This focuses on structural changes rather than movement - */ -function normalizeBlocksForComparison(blocks: Record) { - const normalized: Record = {} - - for (const [id, block] of Object.entries(blocks)) { - normalized[id] = { - ...block, - // Exclude position from comparison to avoid movement sync issues - position: undefined, - } - } - - return normalized -} - -/** - * Hook that automatically syncs the workflow editor when the user switches back to the tab. - * This prevents the "newest write wins" issue by ensuring users always see the latest version. - * Note: This excludes position changes to avoid inconsistent movement syncing. - */ -export function useTabSync(options: TabSyncOptions = {}) { - const { - enabled = true, - minSyncInterval = 2000, // Increased to reduce conflicts - } = options - - const lastSyncRef = useRef(0) - const isSyncingRef = useRef(false) - const timeoutRefs = useRef([]) - const { activeWorkflowId } = useWorkflowRegistry() - const workflowStore = useWorkflowStore() - - const syncWorkflowEditor = useCallback(async () => { - if (!enabled || !activeWorkflowId || isSyncingRef.current) { - return - } - - // Rate limiting - prevent too frequent syncs - const now = Date.now() - if (now - lastSyncRef.current < minSyncInterval) { - logger.debug('Sync skipped due to rate limiting') - return - } - - // Prevent concurrent syncs - isSyncingRef.current = true - lastSyncRef.current = now - - try { - logger.info('Tab became visible - checking for workflow updates') - - // Store current complete workflow state for comparison (excluding positions) - const currentState = { - blocks: { ...workflowStore.blocks }, - edges: [...workflowStore.edges], - loops: { ...workflowStore.loops }, - parallels: { ...workflowStore.parallels }, - lastSaved: workflowStore.lastSaved || 0, - isDeployed: workflowStore.isDeployed, - deployedAt: workflowStore.deployedAt, - needsRedeployment: workflowStore.needsRedeployment, - hasActiveSchedule: workflowStore.hasActiveSchedule, - hasActiveWebhook: workflowStore.hasActiveWebhook, - } - - // Wait for any pending writes to complete before fetching - await new Promise((resolve) => setTimeout(resolve, 200)) - - // Force a fresh fetch from database to ensure we get the absolute latest state - await fetchWorkflowsFromDB() - - // Wait a bit more to ensure the fetch has fully completed and localStorage is updated - await new Promise((resolve) => setTimeout(resolve, 100)) - - // Get the updated workflow from the registry - const updatedWorkflow = useWorkflowRegistry.getState().workflows[activeWorkflowId] - - if (!updatedWorkflow) { - logger.warn('Active workflow not found after sync') - return - } - - // Load the updated workflow state from localStorage (populated by fetchWorkflowsFromDB) - const workflowStateKey = `workflow-${activeWorkflowId}` - const subBlockValuesKey = `subblock-values-${activeWorkflowId}` - - const updatedWorkflowState = localStorage.getItem(workflowStateKey) - const updatedSubBlockValues = localStorage.getItem(subBlockValuesKey) - - if (!updatedWorkflowState) { - logger.warn('No updated workflow state found in localStorage') - return - } - - const newWorkflowState = JSON.parse(updatedWorkflowState) - const newSubBlockValues = updatedSubBlockValues ? JSON.parse(updatedSubBlockValues) : {} - const newLastSaved = newWorkflowState.lastSaved || 0 - - // **CRITICAL: Only update if the database version is actually newer** - // This prevents overriding newer local changes with older database state - if (newLastSaved <= currentState.lastSaved) { - logger.debug('Database state is not newer than current state, skipping update', { - currentLastSaved: new Date(currentState.lastSaved).toISOString(), - newLastSaved: new Date(newLastSaved).toISOString(), - }) - return - } - - // Normalize and stringify once to avoid redundant processing - const currentNormalized = { - blocks: normalizeBlocksForComparison(currentState.blocks), - edges: currentState.edges, - loops: currentState.loops, - parallels: currentState.parallels, - } - - const newNormalized = { - blocks: normalizeBlocksForComparison(newWorkflowState.blocks || {}), - edges: newWorkflowState.edges || [], - loops: newWorkflowState.loops || {}, - parallels: newWorkflowState.parallels || {}, - } - - // Cache stringified versions for comparison - const currentStringified = { - full: JSON.stringify(currentNormalized), - blocks: JSON.stringify(currentNormalized.blocks), - edges: JSON.stringify(currentNormalized.edges), - loops: JSON.stringify(currentNormalized.loops), - parallels: JSON.stringify(currentNormalized.parallels), - } - - const newStringified = { - full: JSON.stringify(newNormalized), - blocks: JSON.stringify(newNormalized.blocks), - edges: JSON.stringify(newNormalized.edges), - loops: JSON.stringify(newNormalized.loops), - parallels: JSON.stringify(newNormalized.parallels), - } - - const hasStructuralChanges = currentStringified.full !== newStringified.full - - // Detailed change detection using cached strings - const hasBlockChanges = currentStringified.blocks !== newStringified.blocks - const hasEdgeChanges = currentStringified.edges !== newStringified.edges - const hasLoopChanges = currentStringified.loops !== newStringified.loops - const hasParallelChanges = currentStringified.parallels !== newStringified.parallels - - if (hasStructuralChanges) { - logger.info('Newer structural changes detected - updating editor', { - activeWorkflowId, - blocksChanged: hasBlockChanges, - edgesChanged: hasEdgeChanges, - loopsChanged: hasLoopChanges, - parallelsChanged: hasParallelChanges, - currentBlockCount: Object.keys(currentState.blocks).length, - newBlockCount: Object.keys(newWorkflowState.blocks || {}).length, - currentEdgeCount: currentState.edges.length, - newEdgeCount: (newWorkflowState.edges || []).length, - timeDiff: newLastSaved - currentState.lastSaved, - note: 'Positions preserved to avoid movement conflicts', - }) - - // Merge new structural changes while preserving current positions - const mergedBlocks = { ...(newWorkflowState.blocks || {}) } - - // Preserve current positions to avoid movement conflicts - for (const [blockId, currentBlock] of Object.entries(currentState.blocks)) { - if (mergedBlocks[blockId] && currentBlock.position) { - mergedBlocks[blockId] = { - ...mergedBlocks[blockId], - position: currentBlock.position, // Keep current position - } - } - } - - // Update the workflow store with structural changes but preserved positions - const completeStateUpdate = { - blocks: mergedBlocks, - edges: newWorkflowState.edges || [], - loops: newWorkflowState.loops || {}, - parallels: newWorkflowState.parallels || {}, - lastSaved: newLastSaved, - isDeployed: - newWorkflowState.isDeployed !== undefined - ? newWorkflowState.isDeployed - : currentState.isDeployed, - deployedAt: - newWorkflowState.deployedAt !== undefined - ? newWorkflowState.deployedAt - : currentState.deployedAt, - needsRedeployment: - newWorkflowState.needsRedeployment !== undefined - ? newWorkflowState.needsRedeployment - : currentState.needsRedeployment, - hasActiveSchedule: - newWorkflowState.hasActiveSchedule !== undefined - ? newWorkflowState.hasActiveSchedule - : currentState.hasActiveSchedule, - hasActiveWebhook: - newWorkflowState.hasActiveWebhook !== undefined - ? newWorkflowState.hasActiveWebhook - : currentState.hasActiveWebhook, - } - - useWorkflowStore.setState(completeStateUpdate) - - // Update subblock values - useSubBlockStore.setState((state) => ({ - workflowValues: { - ...state.workflowValues, - [activeWorkflowId]: newSubBlockValues, - }, - })) - - logger.info('Workflow editor successfully synced structural changes (positions preserved)') - } else { - logger.debug('No structural changes detected, positions preserved') - } - } catch (error) { - logger.error('Failed to sync workflow editor:', error) - } finally { - // Always release the sync lock - isSyncingRef.current = false - } - }, [ - enabled, - activeWorkflowId, - minSyncInterval, - workflowStore.blocks, - workflowStore.edges, - workflowStore.loops, - workflowStore.parallels, - workflowStore.lastSaved, - workflowStore.isDeployed, - workflowStore.deployedAt, - workflowStore.needsRedeployment, - workflowStore.hasActiveSchedule, - workflowStore.hasActiveWebhook, - ]) - - // Handle tab visibility changes - useEffect(() => { - if (!enabled) { - return - } - - const handleVisibilityChange = () => { - // Only sync when tab becomes visible (not when it becomes hidden) - if (document.visibilityState === 'visible') { - logger.debug('Tab became visible - triggering structural sync check') - // Use a longer delay to allow any ongoing operations to complete - const timeoutId = setTimeout(() => { - syncWorkflowEditor() - }, 300) - timeoutRefs.current.push(timeoutId) - } - } - - // Also handle window focus as a fallback for older browsers - const handleWindowFocus = () => { - logger.debug('Window focused - triggering structural sync check') - // Use a longer delay to allow any ongoing operations to complete - const timeoutId = setTimeout(() => { - syncWorkflowEditor() - }, 300) - timeoutRefs.current.push(timeoutId) - } - - document.addEventListener('visibilitychange', handleVisibilityChange) - window.addEventListener('focus', handleWindowFocus) - - return () => { - // Clear any pending timeouts to prevent memory leaks - timeoutRefs.current.forEach(clearTimeout) - timeoutRefs.current = [] - - document.removeEventListener('visibilitychange', handleVisibilityChange) - window.removeEventListener('focus', handleWindowFocus) - } - }, [enabled, syncWorkflowEditor]) - - // Return the sync function for manual triggering if needed - return { - syncWorkflowEditor, - } -} From e5edb98102042ceb21009a6341c5755cd5696a53 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 14 Jun 2025 13:48:11 -0700 Subject: [PATCH 17/67] improvement(folders): added multi-select for moving folders (#493) * added multi-select for folders * allow drag into root * remove extraneous comments * instantly create worfklow on plus * styling improvements, fixed flicker * small improvement to dragover container * ack PR comments --- .../components/create-menu/create-menu.tsx | 130 +++--- .../folder-tree/components/folder-item.tsx | 151 +++++++ .../folder-tree/components/workflow-item.tsx | 137 ++++++ .../components/folder-tree/folder-tree.tsx | 423 +++++++----------- apps/sim/stores/folders/store.ts | 57 ++- 5 files changed, 565 insertions(+), 333 deletions(-) create mode 100644 apps/sim/app/w/components/sidebar/components/folder-tree/components/folder-item.tsx create mode 100644 apps/sim/app/w/components/sidebar/components/folder-tree/components/workflow-item.tsx diff --git a/apps/sim/app/w/components/sidebar/components/create-menu/create-menu.tsx b/apps/sim/app/w/components/sidebar/components/create-menu/create-menu.tsx index d13e7723a5b..5ff60db8657 100644 --- a/apps/sim/app/w/components/sidebar/components/create-menu/create-menu.tsx +++ b/apps/sim/app/w/components/sidebar/components/create-menu/create-menu.tsx @@ -4,14 +4,10 @@ import { useState } from 'react' import { File, Folder, Plus } from 'lucide-react' import { Button } from '@/components/ui/button' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { cn } from '@/lib/utils' import { useFolderStore } from '@/stores/folders/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' @@ -24,15 +20,18 @@ export function CreateMenu({ onCreateWorkflow, isCollapsed }: CreateMenuProps) { const [showFolderDialog, setShowFolderDialog] = useState(false) const [folderName, setFolderName] = useState('') const [isCreating, setIsCreating] = useState(false) + const [isHoverOpen, setIsHoverOpen] = useState(false) const { activeWorkspaceId } = useWorkflowRegistry() const { createFolder } = useFolderStore() const handleCreateWorkflow = () => { + setIsHoverOpen(false) onCreateWorkflow() } const handleCreateFolder = () => { + setIsHoverOpen(false) setShowFolderDialog(true) } @@ -50,7 +49,6 @@ export function CreateMenu({ onCreateWorkflow, isCollapsed }: CreateMenuProps) { setShowFolderDialog(false) } catch (error) { console.error('Failed to create folder:', error) - // You could add toast notification here } finally { setIsCreating(false) } @@ -61,81 +59,61 @@ export function CreateMenu({ onCreateWorkflow, isCollapsed }: CreateMenuProps) { setShowFolderDialog(false) } - if (isCollapsed) { - return ( - <> - - - - - - - - New Workflow - - - - New Folder - - - - - {/* Folder creation dialog */} - - - - Create New Folder - -
-
- - setFolderName(e.target.value)} - placeholder='Enter folder name...' - autoFocus - required - /> -
-
- - -
-
-
-
- - ) - } - return ( <> - - - - - - - + + setIsHoverOpen(true)} + onMouseLeave={() => setIsHoverOpen(false)} + onOpenAutoFocus={(e) => e.preventDefault()} + onCloseAutoFocus={(e) => e.preventDefault()} + > + + + + {/* Folder creation dialog */} diff --git a/apps/sim/app/w/components/sidebar/components/folder-tree/components/folder-item.tsx b/apps/sim/app/w/components/sidebar/components/folder-tree/components/folder-item.tsx new file mode 100644 index 00000000000..2c39f63bc50 --- /dev/null +++ b/apps/sim/app/w/components/sidebar/components/folder-tree/components/folder-item.tsx @@ -0,0 +1,151 @@ +'use client' + +import { useCallback, useEffect, useRef } from 'react' +import clsx from 'clsx' +import { ChevronDown, ChevronRight, Folder, FolderOpen } from 'lucide-react' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { type FolderTreeNode, useFolderStore } from '@/stores/folders/store' +import { FolderContextMenu } from '../../folder-context-menu/folder-context-menu' + +interface FolderItemProps { + folder: FolderTreeNode + isCollapsed?: boolean + onCreateWorkflow: (folderId?: string) => void + dragOver?: boolean + onDragOver?: (e: React.DragEvent) => void + onDragLeave?: (e: React.DragEvent) => void + onDrop?: (e: React.DragEvent) => void +} + +export function FolderItem({ + folder, + isCollapsed, + onCreateWorkflow, + dragOver = false, + onDragOver, + onDragLeave, + onDrop, +}: FolderItemProps) { + const { expandedFolders, toggleExpanded, updateFolderAPI, deleteFolder } = useFolderStore() + + const isExpanded = expandedFolders.has(folder.id) + const updateTimeoutRef = useRef | undefined>(undefined) + const pendingStateRef = useRef(null) + + const handleToggleExpanded = useCallback(() => { + const newExpandedState = !isExpanded + toggleExpanded(folder.id) + pendingStateRef.current = newExpandedState + + if (updateTimeoutRef.current) { + clearTimeout(updateTimeoutRef.current) + } + + updateTimeoutRef.current = setTimeout(() => { + if (pendingStateRef.current === newExpandedState) { + updateFolderAPI(folder.id, { isExpanded: newExpandedState }) + .catch(console.error) + .finally(() => { + pendingStateRef.current = null + }) + } + }, 300) + }, [folder.id, isExpanded, toggleExpanded, updateFolderAPI]) + + useEffect(() => { + return () => { + if (updateTimeoutRef.current) { + clearTimeout(updateTimeoutRef.current) + } + } + }, []) + + const handleRename = async (folderId: string, newName: string) => { + try { + await updateFolderAPI(folderId, { name: newName }) + } catch (error) { + console.error('Failed to rename folder:', error) + } + } + + const handleDelete = async (folderId: string) => { + if ( + confirm( + `Are you sure you want to delete "${folder.name}"? Child folders and workflows will be moved to the parent folder.` + ) + ) { + try { + await deleteFolder(folderId) + } catch (error) { + console.error('Failed to delete folder:', error) + } + } + } + + if (isCollapsed) { + return ( + + +
+
+ {isExpanded ? ( + + ) : ( + + )} +
+
+
+ +

{folder.name}

+
+
+ ) + } + + return ( +
+
+
+ {isExpanded ? : } +
+ +
+ {isExpanded ? ( + + ) : ( + + )} +
+ + + {folder.name} + + +
e.stopPropagation()}> + +
+
+
+ ) +} diff --git a/apps/sim/app/w/components/sidebar/components/folder-tree/components/workflow-item.tsx b/apps/sim/app/w/components/sidebar/components/folder-tree/components/workflow-item.tsx new file mode 100644 index 00000000000..07bf393d9bb --- /dev/null +++ b/apps/sim/app/w/components/sidebar/components/folder-tree/components/workflow-item.tsx @@ -0,0 +1,137 @@ +'use client' + +import { useRef, useState } from 'react' +import clsx from 'clsx' +import Link from 'next/link' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { useFolderStore, useIsWorkflowSelected } from '@/stores/folders/store' +import type { WorkflowMetadata } from '@/stores/workflows/registry/types' + +interface WorkflowItemProps { + workflow: WorkflowMetadata + active: boolean + isMarketplace?: boolean + isCollapsed?: boolean + level: number + isDragOver?: boolean +} + +export function WorkflowItem({ + workflow, + active, + isMarketplace, + isCollapsed, + level, + isDragOver = false, +}: WorkflowItemProps) { + const [isDragging, setIsDragging] = useState(false) + const dragStartedRef = useRef(false) + const { selectedWorkflows, selectOnly, toggleWorkflowSelection } = useFolderStore() + const isSelected = useIsWorkflowSelected(workflow.id) + + const handleClick = (e: React.MouseEvent) => { + if (dragStartedRef.current) { + e.preventDefault() + return + } + + if (e.shiftKey) { + e.preventDefault() + toggleWorkflowSelection(workflow.id) + } else { + if (!isSelected || selectedWorkflows.size > 1) { + selectOnly(workflow.id) + } + } + } + + const handleDragStart = (e: React.DragEvent) => { + if (isMarketplace) return + + dragStartedRef.current = true + setIsDragging(true) + + let workflowIds: string[] + if (isSelected && selectedWorkflows.size > 1) { + workflowIds = Array.from(selectedWorkflows) + } else { + workflowIds = [workflow.id] + } + + e.dataTransfer.setData('workflow-ids', JSON.stringify(workflowIds)) + e.dataTransfer.effectAllowed = 'move' + } + + const handleDragEnd = () => { + setIsDragging(false) + requestAnimationFrame(() => { + dragStartedRef.current = false + }) + } + + if (isCollapsed) { + return ( + + + 1 && !active && !isDragOver + ? 'bg-accent/70' + : '', + isDragging ? 'opacity-50' : '' + )} + draggable={!isMarketplace} + onDragStart={handleDragStart} + onDragEnd={handleDragEnd} + onClick={handleClick} + > +
+ + + +

+ {workflow.name} + {isMarketplace && ' (Preview)'} +

+
+ + ) + } + + return ( + 1 && !active && !isDragOver ? 'bg-accent/70' : '', + isDragging ? 'opacity-50' : '', + !isMarketplace ? 'cursor-move' : '' + )} + style={{ paddingLeft: isCollapsed ? '0px' : `${(level + 1) * 20 + 8}px` }} + draggable={!isMarketplace} + onDragStart={handleDragStart} + onDragEnd={handleDragEnd} + onClick={handleClick} + > +
+ + {workflow.name} + {isMarketplace && ' (Preview)'} + + + ) +} diff --git a/apps/sim/app/w/components/sidebar/components/folder-tree/folder-tree.tsx b/apps/sim/app/w/components/sidebar/components/folder-tree/folder-tree.tsx index 92a906c3260..818dd1d3b3c 100644 --- a/apps/sim/app/w/components/sidebar/components/folder-tree/folder-tree.tsx +++ b/apps/sim/app/w/components/sidebar/components/folder-tree/folder-tree.tsx @@ -2,244 +2,145 @@ import { useEffect, useState } from 'react' import clsx from 'clsx' -import { ChevronDown, ChevronRight, Folder, FolderOpen } from 'lucide-react' -import Link from 'next/link' import { usePathname } from 'next/navigation' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { type FolderTreeNode, useFolderStore } from '@/stores/folders/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import type { WorkflowMetadata } from '@/stores/workflows/registry/types' -import { FolderContextMenu } from '../folder-context-menu/folder-context-menu' +import { FolderItem } from './components/folder-item' +import { WorkflowItem } from './components/workflow-item' -interface FolderItemProps { +interface FolderSectionProps { folder: FolderTreeNode - isCollapsed?: boolean + level: number + isCollapsed: boolean onCreateWorkflow: (folderId?: string) => void + workflowsByFolder: Record + expandedFolders: Set + pathname: string + updateWorkflow: (id: string, updates: Partial) => void + renderFolderTree: ( + nodes: FolderTreeNode[], + level: number, + parentDragOver?: boolean + ) => React.ReactNode[] + parentDragOver?: boolean } -function FolderItem({ folder, isCollapsed, onCreateWorkflow }: FolderItemProps) { - const [dragOver, setDragOver] = useState(false) - const { expandedFolders, toggleExpanded, updateFolderAPI, deleteFolder } = useFolderStore() - const { updateWorkflow } = useWorkflowRegistry() +function FolderSection({ + folder, + level, + isCollapsed, + onCreateWorkflow, + workflowsByFolder, + expandedFolders, + pathname, + updateWorkflow, + renderFolderTree, + parentDragOver = false, +}: FolderSectionProps) { + const { isDragOver, handleDragOver, handleDragLeave, handleDrop } = useDragHandlers( + updateWorkflow, + folder.id, + `Moved workflow(s) to folder ${folder.id}` + ) - const isExpanded = expandedFolders.has(folder.id) + const workflowsInFolder = workflowsByFolder[folder.id] || [] + const isAnyDragOver = isDragOver || parentDragOver - const handleToggleExpanded = () => { - toggleExpanded(folder.id) - // Persist to server - updateFolderAPI(folder.id, { isExpanded: !isExpanded }).catch(console.error) - } + return ( +
+ {/* Render folder */} +
+ +
- const handleRename = async (folderId: string, newName: string) => { - try { - await updateFolderAPI(folderId, { name: newName }) - } catch (error) { - console.error('Failed to rename folder:', error) - } - } + {/* Render workflows in this folder */} + {expandedFolders.has(folder.id) && workflowsInFolder.length > 0 && ( +
+ {workflowsInFolder.map((workflow) => ( + + ))} +
+ )} - const handleDelete = async (folderId: string) => { - if ( - confirm( - `Are you sure you want to delete "${folder.name}"? Child folders and workflows will be moved to the parent folder.` - ) - ) { - try { - await deleteFolder(folderId) - } catch (error) { - console.error('Failed to delete folder:', error) - } - } - } + {/* Render child folders */} + {expandedFolders.has(folder.id) && folder.children.length > 0 && ( +
{renderFolderTree(folder.children, level + 1, isAnyDragOver)}
+ )} +
+ ) +} + +// Custom hook for drag and drop handling +function useDragHandlers( + updateWorkflow: (id: string, updates: Partial) => void, + targetFolderId: string | null, // null for root + logMessage?: string +) { + const [isDragOver, setIsDragOver] = useState(false) - // Drag and drop handlers const handleDragOver = (e: React.DragEvent) => { e.preventDefault() e.stopPropagation() - setDragOver(true) + setIsDragOver(true) } const handleDragLeave = (e: React.DragEvent) => { e.preventDefault() e.stopPropagation() - setDragOver(false) + setIsDragOver(false) } - const handleDrop = async (e: React.DragEvent) => { + const handleDrop = (e: React.DragEvent) => { e.preventDefault() e.stopPropagation() - setDragOver(false) + setIsDragOver(false) + + const workflowIdsData = e.dataTransfer.getData('workflow-ids') + if (workflowIdsData) { + const workflowIds = JSON.parse(workflowIdsData) as string[] - const workflowId = e.dataTransfer.getData('workflow-id') - if (workflowId && workflowId !== folder.id) { try { - // Update workflow to be in this folder - await updateWorkflow(workflowId, { folderId: folder.id }) - console.log(`Moved workflow ${workflowId} to folder ${folder.id}`) + workflowIds.forEach((workflowId) => + updateWorkflow(workflowId, { folderId: targetFolderId }) + ) + console.log(logMessage || `Moved ${workflowIds.length} workflow(s)`) } catch (error) { - console.error('Failed to move workflow to folder:', error) + console.error('Failed to move workflows:', error) } } } - if (isCollapsed) { - return ( - - -
-
- {isExpanded ? ( - - ) : ( - - )} -
-
-
- -

{folder.name}

-
-
- ) - } - - return ( -
-
-
- {isExpanded ? : } -
- -
- {isExpanded ? ( - - ) : ( - - )} -
- - - {folder.name} - - -
e.stopPropagation()}> - -
-
-
- ) -} - -interface WorkflowItemProps { - workflow: WorkflowMetadata - active: boolean - isMarketplace?: boolean - isCollapsed?: boolean - level: number -} - -function WorkflowItem({ workflow, active, isMarketplace, isCollapsed, level }: WorkflowItemProps) { - const [isDragging, setIsDragging] = useState(false) - - const handleDragStart = (e: React.DragEvent) => { - if (isMarketplace) return // Don't allow dragging marketplace workflows - - e.dataTransfer.setData('workflow-id', workflow.id) - e.dataTransfer.effectAllowed = 'move' - setIsDragging(true) - } - - const handleDragEnd = () => { - setIsDragging(false) + return { + isDragOver, + handleDragOver, + handleDragLeave, + handleDrop, } - - if (isCollapsed) { - return ( - - - -
- - - -

- {workflow.name} - {isMarketplace && ' (Preview)'} -

-
- - ) - } - - return ( - -
- - {workflow.name} - {isMarketplace && ' (Preview)'} - - - ) } interface FolderTreeProps { @@ -264,7 +165,9 @@ export function FolderTree({ expandedFolders, fetchFolders, isLoading: foldersLoading, + clearSelection, } = useFolderStore() + const { updateWorkflow } = useWorkflowRegistry() // Fetch folders when workspace changes useEffect(() => { @@ -273,6 +176,10 @@ export function FolderTree({ } }, [activeWorkspaceId, fetchFolders]) + useEffect(() => { + clearSelection() + }, [activeWorkspaceId, clearSelection]) + const folderTree = activeWorkspaceId ? getFolderTree(activeWorkspaceId) : [] // Group workflows by folder @@ -286,63 +193,74 @@ export function FolderTree({ {} as Record ) - const renderFolderTree = (nodes: FolderTreeNode[], level = 0): React.ReactNode[] => { - const result: React.ReactNode[] = [] - - nodes.forEach((folder) => { - // Render folder - result.push( -
- -
- ) - - // Render workflows in this folder - const workflowsInFolder = workflowsByFolder[folder.id] || [] - if (expandedFolders.has(folder.id) && workflowsInFolder.length > 0) { - workflowsInFolder.forEach((workflow) => { - result.push( - - ) - }) - } - - // Render child folders - if (expandedFolders.has(folder.id) && folder.children.length > 0) { - result.push(...renderFolderTree(folder.children, level + 1)) - } - }) - - return result + const { + isDragOver: rootDragOver, + handleDragOver: handleRootDragOver, + handleDragLeave: handleRootDragLeave, + handleDrop: handleRootDrop, + } = useDragHandlers(updateWorkflow, null, 'Moved workflow(s) to root') + + const renderFolderTree = ( + nodes: FolderTreeNode[], + level = 0, + parentDragOver = false + ): React.ReactNode[] => { + return nodes.map((folder) => ( + + )) } const showLoading = isLoading || foldersLoading return ( -
+
{/* Folder tree */} {renderFolderTree(folderTree)} {/* Root level workflows (no folder) */} - {(workflowsByFolder.root || []).map((workflow) => ( - - ))} +
+ {(workflowsByFolder.root || []).map((workflow) => ( + + ))} +
{/* Marketplace workflows */} {marketplaceWorkflows.length > 0 && ( @@ -362,6 +280,7 @@ export function FolderTree({ isMarketplace isCollapsed={isCollapsed} level={-1} + isDragOver={false} /> ))}
diff --git a/apps/sim/stores/folders/store.ts b/apps/sim/stores/folders/store.ts index de063d4ddc1..1c2cedbf236 100644 --- a/apps/sim/stores/folders/store.ts +++ b/apps/sim/stores/folders/store.ts @@ -23,6 +23,7 @@ interface FolderState { folders: Record isLoading: boolean expandedFolders: Set + selectedWorkflows: Set // Actions setFolders: (folders: WorkflowFolder[]) => void @@ -33,6 +34,14 @@ interface FolderState { toggleExpanded: (folderId: string) => void setExpanded: (folderId: string, expanded: boolean) => void + // Selection actions + selectWorkflow: (workflowId: string) => void + deselectWorkflow: (workflowId: string) => void + toggleWorkflowSelection: (workflowId: string) => void + clearSelection: () => void + selectOnly: (workflowId: string) => void + isWorkflowSelected: (workflowId: string) => boolean + // Computed values getFolderTree: (workspaceId: string) => FolderTreeNode[] getFolderById: (id: string) => WorkflowFolder | undefined @@ -57,6 +66,7 @@ export const useFolderStore = create()( folders: {}, isLoading: false, expandedFolders: new Set(), + selectedWorkflows: new Set(), setFolders: (folders) => set(() => ({ @@ -113,6 +123,44 @@ export const useFolderStore = create()( return { expandedFolders: newExpanded } }), + // Selection actions + selectWorkflow: (workflowId) => + set((state) => { + const newSelected = new Set(state.selectedWorkflows) + newSelected.add(workflowId) + return { selectedWorkflows: newSelected } + }), + + deselectWorkflow: (workflowId) => + set((state) => { + const newSelected = new Set(state.selectedWorkflows) + newSelected.delete(workflowId) + return { selectedWorkflows: newSelected } + }), + + toggleWorkflowSelection: (workflowId) => + set((state) => { + const newSelected = new Set(state.selectedWorkflows) + if (newSelected.has(workflowId)) { + newSelected.delete(workflowId) + } else { + newSelected.add(workflowId) + } + return { selectedWorkflows: newSelected } + }), + + clearSelection: () => + set(() => ({ + selectedWorkflows: new Set(), + })), + + selectOnly: (workflowId) => + set(() => ({ + selectedWorkflows: new Set([workflowId]), + })), + + isWorkflowSelected: (workflowId) => get().selectedWorkflows.has(workflowId), + getFolderTree: (workspaceId) => { const folders = Object.values(get().folders).filter((f) => f.workspaceId === workspaceId) @@ -235,11 +283,6 @@ export const useFolderStore = create()( get().updateFolder(id, processedFolder) - // Update expanded state if isExpanded was changed - if (updates.isExpanded !== undefined) { - get().setExpanded(id, updates.isExpanded) - } - return processedFolder }, @@ -268,3 +311,7 @@ export const useFolderStore = create()( { name: 'folder-store' } ) ) + +// Selector hook for checking if a workflow is selected (avoids get() calls) +export const useIsWorkflowSelected = (workflowId: string) => + useFolderStore((state) => state.selectedWorkflows.has(workflowId)) From 1c7aa5cacb46ef7b4eeade44c527c422e521bb73 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 14 Jun 2025 14:22:19 -0700 Subject: [PATCH 18/67] fix(deployed-chat): made the chat mobile friendly (#494) --- .../[subdomain]/components/input/input.tsx | 70 ++++++++++++------- 1 file changed, 45 insertions(+), 25 deletions(-) diff --git a/apps/sim/app/chat/[subdomain]/components/input/input.tsx b/apps/sim/app/chat/[subdomain]/components/input/input.tsx index 126d2de9963..c513965e079 100644 --- a/apps/sim/app/chat/[subdomain]/components/input/input.tsx +++ b/apps/sim/app/chat/[subdomain]/components/input/input.tsx @@ -7,7 +7,8 @@ import { Send, Square } from 'lucide-react' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' import { VoiceInput } from './voice-input' -const PLACEHOLDER = 'Enter a message or click the mic to speak' +const PLACEHOLDER_MOBILE = 'Enter a message' +const PLACEHOLDER_DESKTOP = 'Enter a message or click the mic to speak' const MAX_TEXTAREA_HEIGHT = 160 // Max height in pixels (e.g., for about 4-5 lines) const containerVariants = { @@ -195,31 +196,50 @@ export const ChatInput: React.FC<{
{!isActive && !inputValue && ( -
- {PLACEHOLDER} - -
+ `} +
+ )}
From 8b18a53b75212583121e108c5fca6b1524807813 Mon Sep 17 00:00:00 2001 From: Emir Karabeg <78010029+emir-karabeg@users.noreply.github.com> Date: Sat, 14 Jun 2025 16:16:27 -0700 Subject: [PATCH 19/67] improvement(ui/ux): chat deploy (#496) * improvement(ui/ux): chat deploy experience * improvement(ui/ux): chat fontweight --- apps/sim/app/chat/[subdomain]/chat-client.tsx | 4 +- .../[subdomain]/components/header/header.tsx | 15 +- .../[subdomain]/components/input/input.tsx | 216 ++++++++---------- .../components/input/voice-input.tsx | 29 ++- .../voice-interface/components/particles.tsx | 48 ++-- 5 files changed, 154 insertions(+), 158 deletions(-) diff --git a/apps/sim/app/chat/[subdomain]/chat-client.tsx b/apps/sim/app/chat/[subdomain]/chat-client.tsx index b28182c4dd6..1f5c087fcc0 100644 --- a/apps/sim/app/chat/[subdomain]/chat-client.tsx +++ b/apps/sim/app/chat/[subdomain]/chat-client.tsx @@ -570,8 +570,8 @@ export default function ChatClient({ subdomain }: { subdomain: string }) { /> {/* Input area (free-standing at the bottom) */} -
-
+
+
{ void handleSendMessage(value, isVoiceInput) diff --git a/apps/sim/app/chat/[subdomain]/components/header/header.tsx b/apps/sim/app/chat/[subdomain]/components/header/header.tsx index 1680f50f0aa..d620104ebf9 100644 --- a/apps/sim/app/chat/[subdomain]/components/header/header.tsx +++ b/apps/sim/app/chat/[subdomain]/components/header/header.tsx @@ -1,6 +1,5 @@ 'use client' -import { motion } from 'framer-motion' import { GithubIcon } from '@/components/icons' interface ChatHeaderProps { @@ -19,7 +18,7 @@ export function ChatHeader({ chatConfig, starCount }: ChatHeaderProps) { const primaryColor = chatConfig?.customizations?.primaryColor || '#701FFC' return ( -
+
{chatConfig?.customizations?.logoUrl && (
-
- + {starCount} - + void @@ -45,8 +35,12 @@ export const ChatInput: React.FC<{ el.style.height = 'auto' // Reset height to correctly calculate scrollHeight const scrollHeight = el.scrollHeight - if (scrollHeight > MAX_TEXTAREA_HEIGHT) { - el.style.height = `${MAX_TEXTAREA_HEIGHT}px` + // Use mobile height on mobile devices, desktop height on desktop + const isMobile = window.innerWidth < 768 + const maxHeight = isMobile ? MAX_TEXTAREA_HEIGHT_MOBILE : MAX_TEXTAREA_HEIGHT + + if (scrollHeight > maxHeight) { + el.style.height = `${maxHeight}px` el.style.overflowY = 'auto' } else { el.style.height = `${scrollHeight}px` @@ -136,32 +130,28 @@ export const ChatInput: React.FC<{ return ( <> -
- -
- {/* Voice Input with Tooltip */} - {isSttAvailable && ( -
+
+
+ {/* Text Input Area with Controls */} + +
+ {/* Voice Input */} + {isSttAvailable && (
- +
@@ -170,97 +160,87 @@ export const ChatInput: React.FC<{
+ )} + + {/* Text Input Container */} +
+