mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-19 20:08:04 -05:00
Compare commits
9 Commits
fix/copilo
...
improvemen
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
65f4d3da72 | ||
|
|
c8280cdfff | ||
|
|
f336395f98 | ||
|
|
81cbfe7af4 | ||
|
|
739341b08e | ||
|
|
3c43779ba3 | ||
|
|
1861f77283 | ||
|
|
72c2ba7443 | ||
|
|
037dad6975 |
@@ -1,35 +0,0 @@
|
||||
---
|
||||
paths:
|
||||
- "apps/sim/components/emcn/**"
|
||||
---
|
||||
|
||||
# EMCN Components
|
||||
|
||||
Import from `@/components/emcn`, never from subpaths (except CSS files).
|
||||
|
||||
## CVA vs Direct Styles
|
||||
|
||||
**Use CVA when:** 2+ variants (primary/secondary, sm/md/lg)
|
||||
|
||||
```tsx
|
||||
const buttonVariants = cva('base-classes', {
|
||||
variants: { variant: { default: '...', primary: '...' } }
|
||||
})
|
||||
export { Button, buttonVariants }
|
||||
```
|
||||
|
||||
**Use direct className when:** Single consistent style, no variations
|
||||
|
||||
```tsx
|
||||
function Label({ className, ...props }) {
|
||||
return <Primitive className={cn('style-classes', className)} {...props} />
|
||||
}
|
||||
```
|
||||
|
||||
## Rules
|
||||
|
||||
- Use Radix UI primitives for accessibility
|
||||
- Export component and variants (if using CVA)
|
||||
- TSDoc with usage examples
|
||||
- Consistent tokens: `font-medium`, `text-[12px]`, `rounded-[4px]`
|
||||
- `transition-colors` for hover states
|
||||
@@ -1,13 +0,0 @@
|
||||
# Global Standards
|
||||
|
||||
## Logging
|
||||
Import `createLogger` from `sim/logger`. Use `logger.info`, `logger.warn`, `logger.error` instead of `console.log`.
|
||||
|
||||
## Comments
|
||||
Use TSDoc for documentation. No `====` separators. No non-TSDoc comments.
|
||||
|
||||
## Styling
|
||||
Never update global styles. Keep all styling local to components.
|
||||
|
||||
## Package Manager
|
||||
Use `bun` and `bunx`, not `npm` and `npx`.
|
||||
@@ -1,56 +0,0 @@
|
||||
---
|
||||
paths:
|
||||
- "apps/sim/**"
|
||||
---
|
||||
|
||||
# Sim App Architecture
|
||||
|
||||
## Core Principles
|
||||
1. **Single Responsibility**: Each component, hook, store has one clear purpose
|
||||
2. **Composition Over Complexity**: Break down complex logic into smaller pieces
|
||||
3. **Type Safety First**: TypeScript interfaces for all props, state, return types
|
||||
4. **Predictable State**: Zustand for global state, useState for UI-only concerns
|
||||
|
||||
## Root-Level Structure
|
||||
|
||||
```
|
||||
apps/sim/
|
||||
├── app/ # Next.js app router (pages, API routes)
|
||||
├── blocks/ # Block definitions and registry
|
||||
├── components/ # Shared UI (emcn/, ui/)
|
||||
├── executor/ # Workflow execution engine
|
||||
├── hooks/ # Shared hooks (queries/, selectors/)
|
||||
├── lib/ # App-wide utilities
|
||||
├── providers/ # LLM provider integrations
|
||||
├── stores/ # Zustand stores
|
||||
├── tools/ # Tool definitions
|
||||
└── triggers/ # Trigger definitions
|
||||
```
|
||||
|
||||
## Feature Organization
|
||||
|
||||
Features live under `app/workspace/[workspaceId]/`:
|
||||
|
||||
```
|
||||
feature/
|
||||
├── components/ # Feature components
|
||||
├── hooks/ # Feature-scoped hooks
|
||||
├── utils/ # Feature-scoped utilities (2+ consumers)
|
||||
├── feature.tsx # Main component
|
||||
└── page.tsx # Next.js page entry
|
||||
```
|
||||
|
||||
## Naming Conventions
|
||||
- **Components**: PascalCase (`WorkflowList`)
|
||||
- **Hooks**: `use` prefix (`useWorkflowOperations`)
|
||||
- **Files**: kebab-case (`workflow-list.tsx`)
|
||||
- **Stores**: `stores/feature/store.ts`
|
||||
- **Constants**: SCREAMING_SNAKE_CASE
|
||||
- **Interfaces**: PascalCase with suffix (`WorkflowListProps`)
|
||||
|
||||
## Utils Rules
|
||||
|
||||
- **Never create `utils.ts` for single consumer** - inline it
|
||||
- **Create `utils.ts` when** 2+ files need the same helper
|
||||
- **Check existing sources** before duplicating (`lib/` has many utilities)
|
||||
- **Location**: `lib/` (app-wide) → `feature/utils/` (feature-scoped) → inline (single-use)
|
||||
@@ -1,48 +0,0 @@
|
||||
---
|
||||
paths:
|
||||
- "apps/sim/**/*.tsx"
|
||||
---
|
||||
|
||||
# Component Patterns
|
||||
|
||||
## Structure Order
|
||||
|
||||
```typescript
|
||||
'use client' // Only if using hooks
|
||||
|
||||
// Imports (external → internal)
|
||||
// Constants at module level
|
||||
const CONFIG = { SPACING: 8 } as const
|
||||
|
||||
// Props interface
|
||||
interface ComponentProps {
|
||||
requiredProp: string
|
||||
optionalProp?: boolean
|
||||
}
|
||||
|
||||
export function Component({ requiredProp, optionalProp = false }: ComponentProps) {
|
||||
// a. Refs
|
||||
// b. External hooks (useParams, useRouter)
|
||||
// c. Store hooks
|
||||
// d. Custom hooks
|
||||
// e. Local state
|
||||
// f. useMemo
|
||||
// g. useCallback
|
||||
// h. useEffect
|
||||
// i. Return JSX
|
||||
}
|
||||
```
|
||||
|
||||
## Rules
|
||||
|
||||
1. `'use client'` only when using React hooks
|
||||
2. Always define props interface
|
||||
3. Extract constants with `as const`
|
||||
4. Semantic HTML (`aside`, `nav`, `article`)
|
||||
5. Optional chain callbacks: `onAction?.(id)`
|
||||
|
||||
## Component Extraction
|
||||
|
||||
**Extract when:** 50+ lines, used in 2+ files, or has own state/logic
|
||||
|
||||
**Keep inline when:** < 10 lines, single use, purely presentational
|
||||
@@ -1,55 +0,0 @@
|
||||
---
|
||||
paths:
|
||||
- "apps/sim/**/use-*.ts"
|
||||
- "apps/sim/**/hooks/**/*.ts"
|
||||
---
|
||||
|
||||
# Hook Patterns
|
||||
|
||||
## Structure
|
||||
|
||||
```typescript
|
||||
interface UseFeatureProps {
|
||||
id: string
|
||||
onSuccess?: (result: Result) => void
|
||||
}
|
||||
|
||||
export function useFeature({ id, onSuccess }: UseFeatureProps) {
|
||||
// 1. Refs for stable dependencies
|
||||
const idRef = useRef(id)
|
||||
const onSuccessRef = useRef(onSuccess)
|
||||
|
||||
// 2. State
|
||||
const [data, setData] = useState<Data | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
// 3. Sync refs
|
||||
useEffect(() => {
|
||||
idRef.current = id
|
||||
onSuccessRef.current = onSuccess
|
||||
}, [id, onSuccess])
|
||||
|
||||
// 4. Operations (useCallback with empty deps when using refs)
|
||||
const fetchData = useCallback(async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const result = await fetch(`/api/${idRef.current}`).then(r => r.json())
|
||||
setData(result)
|
||||
onSuccessRef.current?.(result)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return { data, isLoading, fetchData }
|
||||
}
|
||||
```
|
||||
|
||||
## Rules
|
||||
|
||||
1. Single responsibility per hook
|
||||
2. Props interface required
|
||||
3. Refs for stable callback dependencies
|
||||
4. Wrap returned functions in useCallback
|
||||
5. Always try/catch async operations
|
||||
6. Track loading/error states
|
||||
@@ -1,62 +0,0 @@
|
||||
---
|
||||
paths:
|
||||
- "apps/sim/**/*.ts"
|
||||
- "apps/sim/**/*.tsx"
|
||||
---
|
||||
|
||||
# Import Patterns
|
||||
|
||||
## Absolute Imports
|
||||
|
||||
**Always use absolute imports.** Never use relative imports.
|
||||
|
||||
```typescript
|
||||
// ✓ Good
|
||||
import { useWorkflowStore } from '@/stores/workflows/store'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
// ✗ Bad
|
||||
import { useWorkflowStore } from '../../../stores/workflows/store'
|
||||
```
|
||||
|
||||
## Barrel Exports
|
||||
|
||||
Use barrel exports (`index.ts`) when a folder has 3+ exports. Import from barrel, not individual files.
|
||||
|
||||
```typescript
|
||||
// ✓ Good
|
||||
import { Dashboard, Sidebar } from '@/app/workspace/[workspaceId]/logs/components'
|
||||
|
||||
// ✗ Bad
|
||||
import { Dashboard } from '@/app/workspace/[workspaceId]/logs/components/dashboard/dashboard'
|
||||
```
|
||||
|
||||
## No Re-exports
|
||||
|
||||
Do not re-export from non-barrel files. Import directly from the source.
|
||||
|
||||
```typescript
|
||||
// ✓ Good - import from where it's declared
|
||||
import { CORE_TRIGGER_TYPES } from '@/stores/logs/filters/types'
|
||||
|
||||
// ✗ Bad - re-exporting in utils.ts then importing from there
|
||||
import { CORE_TRIGGER_TYPES } from '@/app/workspace/.../utils'
|
||||
```
|
||||
|
||||
## Import Order
|
||||
|
||||
1. React/core libraries
|
||||
2. External libraries
|
||||
3. UI components (`@/components/emcn`, `@/components/ui`)
|
||||
4. Utilities (`@/lib/...`)
|
||||
5. Stores (`@/stores/...`)
|
||||
6. Feature imports
|
||||
7. CSS imports
|
||||
|
||||
## Type Imports
|
||||
|
||||
Use `type` keyword for type-only imports:
|
||||
|
||||
```typescript
|
||||
import type { WorkflowLog } from '@/stores/logs/types'
|
||||
```
|
||||
@@ -1,209 +0,0 @@
|
||||
---
|
||||
paths:
|
||||
- "apps/sim/tools/**"
|
||||
- "apps/sim/blocks/**"
|
||||
- "apps/sim/triggers/**"
|
||||
---
|
||||
|
||||
# Adding Integrations
|
||||
|
||||
## Overview
|
||||
|
||||
Adding a new integration typically requires:
|
||||
1. **Tools** - API operations (`tools/{service}/`)
|
||||
2. **Block** - UI component (`blocks/blocks/{service}.ts`)
|
||||
3. **Icon** - SVG icon (`components/icons.tsx`)
|
||||
4. **Trigger** (optional) - Webhooks/polling (`triggers/{service}/`)
|
||||
|
||||
Always look up the service's API docs first.
|
||||
|
||||
## 1. Tools (`tools/{service}/`)
|
||||
|
||||
```
|
||||
tools/{service}/
|
||||
├── index.ts # Export all tools
|
||||
├── types.ts # Params/response types
|
||||
├── {action}.ts # Individual tool (e.g., send_message.ts)
|
||||
└── ...
|
||||
```
|
||||
|
||||
**Tool file structure:**
|
||||
|
||||
```typescript
|
||||
// tools/{service}/{action}.ts
|
||||
import type { {Service}Params, {Service}Response } from '@/tools/{service}/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const {service}{Action}Tool: ToolConfig<{Service}Params, {Service}Response> = {
|
||||
id: '{service}_{action}',
|
||||
name: '{Service} {Action}',
|
||||
description: 'What this tool does',
|
||||
version: '1.0.0',
|
||||
oauth: { required: true, provider: '{service}' }, // if OAuth
|
||||
params: { /* param definitions */ },
|
||||
request: {
|
||||
url: '/api/tools/{service}/{action}',
|
||||
method: 'POST',
|
||||
headers: () => ({ 'Content-Type': 'application/json' }),
|
||||
body: (params) => ({ ...params }),
|
||||
},
|
||||
transformResponse: async (response) => {
|
||||
const data = await response.json()
|
||||
if (!data.success) throw new Error(data.error)
|
||||
return { success: true, output: data.output }
|
||||
},
|
||||
outputs: { /* output definitions */ },
|
||||
}
|
||||
```
|
||||
|
||||
**Register in `tools/registry.ts`:**
|
||||
|
||||
```typescript
|
||||
import { {service}{Action}Tool } from '@/tools/{service}'
|
||||
// Add to registry object
|
||||
{service}_{action}: {service}{Action}Tool,
|
||||
```
|
||||
|
||||
## 2. Block (`blocks/blocks/{service}.ts`)
|
||||
|
||||
```typescript
|
||||
import { {Service}Icon } from '@/components/icons'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import type { {Service}Response } from '@/tools/{service}/types'
|
||||
|
||||
export const {Service}Block: BlockConfig<{Service}Response> = {
|
||||
type: '{service}',
|
||||
name: '{Service}',
|
||||
description: 'Short description',
|
||||
longDescription: 'Detailed description',
|
||||
category: 'tools',
|
||||
bgColor: '#hexcolor',
|
||||
icon: {Service}Icon,
|
||||
subBlocks: [ /* see SubBlock Properties below */ ],
|
||||
tools: {
|
||||
access: ['{service}_{action}', ...],
|
||||
config: {
|
||||
tool: (params) => `{service}_${params.operation}`,
|
||||
params: (params) => ({ ...params }),
|
||||
},
|
||||
},
|
||||
inputs: { /* input definitions */ },
|
||||
outputs: { /* output definitions */ },
|
||||
}
|
||||
```
|
||||
|
||||
### SubBlock Properties
|
||||
|
||||
```typescript
|
||||
{
|
||||
id: 'fieldName', // Unique identifier
|
||||
title: 'Field Label', // UI label
|
||||
type: 'short-input', // See SubBlock Types below
|
||||
placeholder: 'Hint text',
|
||||
required: true, // See Required below
|
||||
condition: { ... }, // See Condition below
|
||||
dependsOn: ['otherField'], // See DependsOn below
|
||||
mode: 'basic', // 'basic' | 'advanced' | 'both' | 'trigger'
|
||||
}
|
||||
```
|
||||
|
||||
**SubBlock Types:** `short-input`, `long-input`, `dropdown`, `code`, `switch`, `slider`, `oauth-input`, `channel-selector`, `user-selector`, `file-upload`, etc.
|
||||
|
||||
### `condition` - Show/hide based on another field
|
||||
|
||||
```typescript
|
||||
// Show when operation === 'send'
|
||||
condition: { field: 'operation', value: 'send' }
|
||||
|
||||
// Show when operation is 'send' OR 'read'
|
||||
condition: { field: 'operation', value: ['send', 'read'] }
|
||||
|
||||
// Show when operation !== 'send'
|
||||
condition: { field: 'operation', value: 'send', not: true }
|
||||
|
||||
// Complex: NOT in list AND another condition
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['list_channels', 'list_users'],
|
||||
not: true,
|
||||
and: { field: 'destinationType', value: 'dm', not: true }
|
||||
}
|
||||
```
|
||||
|
||||
### `required` - Field validation
|
||||
|
||||
```typescript
|
||||
// Always required
|
||||
required: true
|
||||
|
||||
// Conditionally required (same syntax as condition)
|
||||
required: { field: 'operation', value: 'send' }
|
||||
```
|
||||
|
||||
### `dependsOn` - Clear field when dependencies change
|
||||
|
||||
```typescript
|
||||
// Clear when credential changes
|
||||
dependsOn: ['credential']
|
||||
|
||||
// Clear when authMethod changes AND (credential OR botToken) changes
|
||||
dependsOn: { all: ['authMethod'], any: ['credential', 'botToken'] }
|
||||
```
|
||||
|
||||
### `mode` - When to show field
|
||||
|
||||
- `'basic'` - Only in basic mode (default UI)
|
||||
- `'advanced'` - Only in advanced mode (manual input)
|
||||
- `'both'` - Show in both modes (default)
|
||||
- `'trigger'` - Only when block is used as trigger
|
||||
|
||||
**Register in `blocks/registry.ts`:**
|
||||
|
||||
```typescript
|
||||
import { {Service}Block } from '@/blocks/blocks/{service}'
|
||||
// Add to registry object (alphabetically)
|
||||
{service}: {Service}Block,
|
||||
```
|
||||
|
||||
## 3. Icon (`components/icons.tsx`)
|
||||
|
||||
```typescript
|
||||
export function {Service}Icon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...props} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
{/* SVG path from service's brand assets */}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## 4. Trigger (`triggers/{service}/`) - Optional
|
||||
|
||||
```
|
||||
triggers/{service}/
|
||||
├── index.ts # Export all triggers
|
||||
├── webhook.ts # Webhook handler
|
||||
├── utils.ts # Shared utilities
|
||||
└── {event}.ts # Specific event handlers
|
||||
```
|
||||
|
||||
**Register in `triggers/registry.ts`:**
|
||||
|
||||
```typescript
|
||||
import { {service}WebhookTrigger } from '@/triggers/{service}'
|
||||
// Add to TRIGGER_REGISTRY
|
||||
{service}_webhook: {service}WebhookTrigger,
|
||||
```
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] Look up API docs for the service
|
||||
- [ ] Create `tools/{service}/types.ts` with proper types
|
||||
- [ ] Create tool files for each operation
|
||||
- [ ] Create `tools/{service}/index.ts` barrel export
|
||||
- [ ] Register tools in `tools/registry.ts`
|
||||
- [ ] Add icon to `components/icons.tsx`
|
||||
- [ ] Create block in `blocks/blocks/{service}.ts`
|
||||
- [ ] Register block in `blocks/registry.ts`
|
||||
- [ ] (Optional) Create triggers in `triggers/{service}/`
|
||||
- [ ] (Optional) Register triggers in `triggers/registry.ts`
|
||||
@@ -1,66 +0,0 @@
|
||||
---
|
||||
paths:
|
||||
- "apps/sim/hooks/queries/**/*.ts"
|
||||
---
|
||||
|
||||
# React Query Patterns
|
||||
|
||||
All React Query hooks live in `hooks/queries/`.
|
||||
|
||||
## Query Key Factory
|
||||
|
||||
Every query file defines a keys factory:
|
||||
|
||||
```typescript
|
||||
export const entityKeys = {
|
||||
all: ['entity'] as const,
|
||||
list: (workspaceId?: string) => [...entityKeys.all, 'list', workspaceId ?? ''] as const,
|
||||
detail: (id?: string) => [...entityKeys.all, 'detail', id ?? ''] as const,
|
||||
}
|
||||
```
|
||||
|
||||
## File Structure
|
||||
|
||||
```typescript
|
||||
// 1. Query keys factory
|
||||
// 2. Types (if needed)
|
||||
// 3. Private fetch functions
|
||||
// 4. Exported hooks
|
||||
```
|
||||
|
||||
## Query Hook
|
||||
|
||||
```typescript
|
||||
export function useEntityList(workspaceId?: string, options?: { enabled?: boolean }) {
|
||||
return useQuery({
|
||||
queryKey: entityKeys.list(workspaceId),
|
||||
queryFn: () => fetchEntities(workspaceId as string),
|
||||
enabled: Boolean(workspaceId) && (options?.enabled ?? true),
|
||||
staleTime: 60 * 1000,
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## Mutation Hook
|
||||
|
||||
```typescript
|
||||
export function useCreateEntity() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async (variables) => { /* fetch POST */ },
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: entityKeys.all }),
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## Optimistic Updates
|
||||
|
||||
For optimistic mutations syncing with Zustand, use `createOptimisticMutationHandlers` from `@/hooks/queries/utils/optimistic-mutation`.
|
||||
|
||||
## Naming
|
||||
|
||||
- **Keys**: `entityKeys`
|
||||
- **Query hooks**: `useEntity`, `useEntityList`
|
||||
- **Mutation hooks**: `useCreateEntity`, `useUpdateEntity`
|
||||
- **Fetch functions**: `fetchEntity` (private)
|
||||
@@ -1,71 +0,0 @@
|
||||
---
|
||||
paths:
|
||||
- "apps/sim/**/store.ts"
|
||||
- "apps/sim/**/stores/**/*.ts"
|
||||
---
|
||||
|
||||
# Zustand Store Patterns
|
||||
|
||||
Stores live in `stores/`. Complex stores split into `store.ts` + `types.ts`.
|
||||
|
||||
## Basic Store
|
||||
|
||||
```typescript
|
||||
import { create } from 'zustand'
|
||||
import { devtools } from 'zustand/middleware'
|
||||
import type { FeatureState } from '@/stores/feature/types'
|
||||
|
||||
const initialState = { items: [] as Item[], activeId: null as string | null }
|
||||
|
||||
export const useFeatureStore = create<FeatureState>()(
|
||||
devtools(
|
||||
(set, get) => ({
|
||||
...initialState,
|
||||
setItems: (items) => set({ items }),
|
||||
addItem: (item) => set((state) => ({ items: [...state.items, item] })),
|
||||
reset: () => set(initialState),
|
||||
}),
|
||||
{ name: 'feature-store' }
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
## Persisted Store
|
||||
|
||||
```typescript
|
||||
import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
|
||||
export const useFeatureStore = create<FeatureState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
width: 300,
|
||||
setWidth: (width) => set({ width }),
|
||||
_hasHydrated: false,
|
||||
setHasHydrated: (v) => set({ _hasHydrated: v }),
|
||||
}),
|
||||
{
|
||||
name: 'feature-state',
|
||||
partialize: (state) => ({ width: state.width }),
|
||||
onRehydrateStorage: () => (state) => state?.setHasHydrated(true),
|
||||
}
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
## Rules
|
||||
|
||||
1. Use `devtools` middleware (named stores)
|
||||
2. Use `persist` only when data should survive reload
|
||||
3. `partialize` to persist only necessary state
|
||||
4. `_hasHydrated` pattern for persisted stores needing hydration tracking
|
||||
5. Immutable updates only
|
||||
6. `set((state) => ...)` when depending on previous state
|
||||
7. Provide `reset()` action
|
||||
|
||||
## Outside React
|
||||
|
||||
```typescript
|
||||
const items = useFeatureStore.getState().items
|
||||
useFeatureStore.setState({ items: newItems })
|
||||
```
|
||||
@@ -1,41 +0,0 @@
|
||||
---
|
||||
paths:
|
||||
- "apps/sim/**/*.tsx"
|
||||
- "apps/sim/**/*.css"
|
||||
---
|
||||
|
||||
# Styling Rules
|
||||
|
||||
## Tailwind
|
||||
|
||||
1. **No inline styles** - Use Tailwind classes
|
||||
2. **No duplicate dark classes** - Skip `dark:` when value matches light mode
|
||||
3. **Exact values** - `text-[14px]`, `h-[26px]`
|
||||
4. **Transitions** - `transition-colors` for interactive states
|
||||
|
||||
## Conditional Classes
|
||||
|
||||
```typescript
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
<div className={cn(
|
||||
'base-classes',
|
||||
isActive && 'active-classes',
|
||||
disabled ? 'opacity-60' : 'hover:bg-accent'
|
||||
)} />
|
||||
```
|
||||
|
||||
## CSS Variables
|
||||
|
||||
For dynamic values (widths, heights) synced with stores:
|
||||
|
||||
```typescript
|
||||
// In store
|
||||
setWidth: (width) => {
|
||||
set({ width })
|
||||
document.documentElement.style.setProperty('--sidebar-width', `${width}px`)
|
||||
}
|
||||
|
||||
// In component
|
||||
<aside style={{ width: 'var(--sidebar-width)' }} />
|
||||
```
|
||||
@@ -1,58 +0,0 @@
|
||||
---
|
||||
paths:
|
||||
- "apps/sim/**/*.test.ts"
|
||||
- "apps/sim/**/*.test.tsx"
|
||||
---
|
||||
|
||||
# Testing Patterns
|
||||
|
||||
Use Vitest. Test files: `feature.ts` → `feature.test.ts`
|
||||
|
||||
## Structure
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { databaseMock, loggerMock } from '@sim/testing'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@sim/db', () => databaseMock)
|
||||
vi.mock('@sim/logger', () => loggerMock)
|
||||
|
||||
import { myFunction } from '@/lib/feature'
|
||||
|
||||
describe('myFunction', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
it.concurrent('isolated tests run in parallel', () => { ... })
|
||||
})
|
||||
```
|
||||
|
||||
## @sim/testing Package
|
||||
|
||||
Always prefer over local mocks.
|
||||
|
||||
| Category | Utilities |
|
||||
|----------|-----------|
|
||||
| **Mocks** | `loggerMock`, `databaseMock`, `setupGlobalFetchMock()` |
|
||||
| **Factories** | `createSession()`, `createWorkflowRecord()`, `createBlock()`, `createExecutorContext()` |
|
||||
| **Builders** | `WorkflowBuilder`, `ExecutionContextBuilder` |
|
||||
| **Assertions** | `expectWorkflowAccessGranted()`, `expectBlockExecuted()` |
|
||||
|
||||
## Rules
|
||||
|
||||
1. `@vitest-environment node` directive at file top
|
||||
2. `vi.mock()` calls before importing mocked modules
|
||||
3. `@sim/testing` utilities over local mocks
|
||||
4. `it.concurrent` for isolated tests (no shared mutable state)
|
||||
5. `beforeEach(() => vi.clearAllMocks())` to reset state
|
||||
|
||||
## Hoisted Mocks
|
||||
|
||||
For mutable mock references:
|
||||
|
||||
```typescript
|
||||
const mockFn = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/lib/module', () => ({ myFunction: mockFn }))
|
||||
mockFn.mockResolvedValue({ data: 'test' })
|
||||
```
|
||||
@@ -1,21 +0,0 @@
|
||||
---
|
||||
paths:
|
||||
- "apps/sim/**/*.ts"
|
||||
- "apps/sim/**/*.tsx"
|
||||
---
|
||||
|
||||
# TypeScript Rules
|
||||
|
||||
1. **No `any`** - Use proper types or `unknown` with type guards
|
||||
2. **Props interface** - Always define for components
|
||||
3. **Const assertions** - `as const` for constant objects/arrays
|
||||
4. **Ref types** - Explicit: `useRef<HTMLDivElement>(null)`
|
||||
5. **Type imports** - `import type { X }` for type-only imports
|
||||
|
||||
```typescript
|
||||
// ✗ Bad
|
||||
const handleClick = (e: any) => {}
|
||||
|
||||
// ✓ Good
|
||||
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {}
|
||||
```
|
||||
@@ -86,27 +86,112 @@ export async function GET(request: NextRequest) {
|
||||
)
|
||||
.limit(candidateLimit)
|
||||
|
||||
const seenIds = new Set<string>()
|
||||
const mergedResults = []
|
||||
const knownLocales = ['en', 'es', 'fr', 'de', 'ja', 'zh']
|
||||
|
||||
for (let i = 0; i < Math.max(vectorResults.length, keywordResults.length); i++) {
|
||||
if (i < vectorResults.length && !seenIds.has(vectorResults[i].chunkId)) {
|
||||
mergedResults.push(vectorResults[i])
|
||||
seenIds.add(vectorResults[i].chunkId)
|
||||
}
|
||||
if (i < keywordResults.length && !seenIds.has(keywordResults[i].chunkId)) {
|
||||
mergedResults.push(keywordResults[i])
|
||||
seenIds.add(keywordResults[i].chunkId)
|
||||
const vectorRankMap = new Map<string, number>()
|
||||
vectorResults.forEach((r, idx) => vectorRankMap.set(r.chunkId, idx + 1))
|
||||
|
||||
const keywordRankMap = new Map<string, number>()
|
||||
keywordResults.forEach((r, idx) => keywordRankMap.set(r.chunkId, idx + 1))
|
||||
|
||||
const allChunkIds = new Set([
|
||||
...vectorResults.map((r) => r.chunkId),
|
||||
...keywordResults.map((r) => r.chunkId),
|
||||
])
|
||||
|
||||
const k = 60
|
||||
type ResultWithRRF = (typeof vectorResults)[0] & { rrfScore: number }
|
||||
const scoredResults: ResultWithRRF[] = []
|
||||
|
||||
for (const chunkId of allChunkIds) {
|
||||
const vectorRank = vectorRankMap.get(chunkId) ?? Number.POSITIVE_INFINITY
|
||||
const keywordRank = keywordRankMap.get(chunkId) ?? Number.POSITIVE_INFINITY
|
||||
|
||||
const rrfScore = 1 / (k + vectorRank) + 1 / (k + keywordRank)
|
||||
|
||||
const result =
|
||||
vectorResults.find((r) => r.chunkId === chunkId) ||
|
||||
keywordResults.find((r) => r.chunkId === chunkId)
|
||||
|
||||
if (result) {
|
||||
scoredResults.push({ ...result, rrfScore })
|
||||
}
|
||||
}
|
||||
|
||||
const filteredResults = mergedResults.slice(0, limit)
|
||||
const searchResults = filteredResults.map((result) => {
|
||||
scoredResults.sort((a, b) => b.rrfScore - a.rrfScore)
|
||||
|
||||
const localeFilteredResults = scoredResults.filter((result) => {
|
||||
const firstPart = result.sourceDocument.split('/')[0]
|
||||
if (knownLocales.includes(firstPart)) {
|
||||
return firstPart === locale
|
||||
}
|
||||
return locale === 'en'
|
||||
})
|
||||
|
||||
const queryLower = query.toLowerCase()
|
||||
const getTitleBoost = (result: ResultWithRRF): number => {
|
||||
const fileName = result.sourceDocument
|
||||
.replace('.mdx', '')
|
||||
.split('/')
|
||||
.pop()
|
||||
?.toLowerCase()
|
||||
?.replace(/_/g, ' ')
|
||||
|
||||
if (fileName === queryLower) return 0.01
|
||||
if (fileName?.includes(queryLower)) return 0.005
|
||||
return 0
|
||||
}
|
||||
|
||||
localeFilteredResults.sort((a, b) => {
|
||||
return b.rrfScore + getTitleBoost(b) - (a.rrfScore + getTitleBoost(a))
|
||||
})
|
||||
|
||||
const pageMap = new Map<string, ResultWithRRF>()
|
||||
|
||||
for (const result of localeFilteredResults) {
|
||||
const pageKey = result.sourceDocument
|
||||
const existing = pageMap.get(pageKey)
|
||||
|
||||
if (!existing || result.rrfScore > existing.rrfScore) {
|
||||
pageMap.set(pageKey, result)
|
||||
}
|
||||
}
|
||||
|
||||
const deduplicatedResults = Array.from(pageMap.values())
|
||||
.sort((a, b) => b.rrfScore + getTitleBoost(b) - (a.rrfScore + getTitleBoost(a)))
|
||||
.slice(0, limit)
|
||||
|
||||
const searchResults = deduplicatedResults.map((result) => {
|
||||
const title = result.headerText || result.sourceDocument.replace('.mdx', '')
|
||||
|
||||
const pathParts = result.sourceDocument
|
||||
.replace('.mdx', '')
|
||||
.split('/')
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.filter((part) => part !== 'index' && !knownLocales.includes(part))
|
||||
.map((part) => {
|
||||
return part
|
||||
.replace(/_/g, ' ')
|
||||
.split(' ')
|
||||
.map((word) => {
|
||||
const acronyms = [
|
||||
'api',
|
||||
'mcp',
|
||||
'sdk',
|
||||
'url',
|
||||
'http',
|
||||
'json',
|
||||
'xml',
|
||||
'html',
|
||||
'css',
|
||||
'ai',
|
||||
]
|
||||
if (acronyms.includes(word.toLowerCase())) {
|
||||
return word.toUpperCase()
|
||||
}
|
||||
return word.charAt(0).toUpperCase() + word.slice(1)
|
||||
})
|
||||
.join(' ')
|
||||
})
|
||||
|
||||
return {
|
||||
id: result.chunkId,
|
||||
|
||||
@@ -1739,12 +1739,12 @@ export function BrowserUseIcon(props: SVGProps<SVGSVGElement>) {
|
||||
{...props}
|
||||
version='1.0'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='150pt'
|
||||
height='150pt'
|
||||
width='28'
|
||||
height='28'
|
||||
viewBox='0 0 150 150'
|
||||
preserveAspectRatio='xMidYMid meet'
|
||||
>
|
||||
<g transform='translate(0,150) scale(0.05,-0.05)' fill='#000000' stroke='none'>
|
||||
<g transform='translate(0,150) scale(0.05,-0.05)' fill='currentColor' stroke='none'>
|
||||
<path
|
||||
d='M786 2713 c-184 -61 -353 -217 -439 -405 -76 -165 -65 -539 19 -666
|
||||
l57 -85 -48 -124 c-203 -517 -79 -930 346 -1155 159 -85 441 -71 585 28 l111
|
||||
|
||||
@@ -7,7 +7,7 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="browser_use"
|
||||
color="#E0E0E0"
|
||||
color="#181C1E"
|
||||
/>
|
||||
|
||||
{/* MANUAL-CONTENT-START:intro */}
|
||||
|
||||
@@ -52,6 +52,15 @@ Read content from a Google Slides presentation
|
||||
| --------- | ---- | ----------- |
|
||||
| `slides` | json | Array of slides with their content |
|
||||
| `metadata` | json | Presentation metadata including ID, title, and URL |
|
||||
| ↳ `presentationId` | string | The presentation ID |
|
||||
| ↳ `title` | string | The presentation title |
|
||||
| ↳ `pageSize` | object | Presentation page size |
|
||||
| ↳ `width` | json | Page width as a Dimension object |
|
||||
| ↳ `height` | json | Page height as a Dimension object |
|
||||
| ↳ `width` | json | Page width as a Dimension object |
|
||||
| ↳ `height` | json | Page height as a Dimension object |
|
||||
| ↳ `mimeType` | string | The mime type of the presentation |
|
||||
| ↳ `url` | string | URL to open the presentation |
|
||||
|
||||
### `google_slides_write`
|
||||
|
||||
@@ -71,6 +80,10 @@ Write or update content in a Google Slides presentation
|
||||
| --------- | ---- | ----------- |
|
||||
| `updatedContent` | boolean | Indicates if presentation content was updated successfully |
|
||||
| `metadata` | json | Updated presentation metadata including ID, title, and URL |
|
||||
| ↳ `presentationId` | string | The presentation ID |
|
||||
| ↳ `title` | string | The presentation title |
|
||||
| ↳ `mimeType` | string | The mime type of the presentation |
|
||||
| ↳ `url` | string | URL to open the presentation |
|
||||
|
||||
### `google_slides_create`
|
||||
|
||||
@@ -90,6 +103,10 @@ Create a new Google Slides presentation
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `metadata` | json | Created presentation metadata including ID, title, and URL |
|
||||
| ↳ `presentationId` | string | The presentation ID |
|
||||
| ↳ `title` | string | The presentation title |
|
||||
| ↳ `mimeType` | string | The mime type of the presentation |
|
||||
| ↳ `url` | string | URL to open the presentation |
|
||||
|
||||
### `google_slides_replace_all_text`
|
||||
|
||||
@@ -111,6 +128,10 @@ Find and replace all occurrences of text throughout a Google Slides presentation
|
||||
| --------- | ---- | ----------- |
|
||||
| `occurrencesChanged` | number | Number of text occurrences that were replaced |
|
||||
| `metadata` | json | Operation metadata including presentation ID and URL |
|
||||
| ↳ `presentationId` | string | The presentation ID |
|
||||
| ↳ `findText` | string | The text that was searched for |
|
||||
| ↳ `replaceText` | string | The text that replaced the matches |
|
||||
| ↳ `url` | string | URL to open the presentation |
|
||||
|
||||
### `google_slides_add_slide`
|
||||
|
||||
@@ -131,6 +152,10 @@ Add a new slide to a Google Slides presentation with a specified layout
|
||||
| --------- | ---- | ----------- |
|
||||
| `slideId` | string | The object ID of the newly created slide |
|
||||
| `metadata` | json | Operation metadata including presentation ID, layout, and URL |
|
||||
| ↳ `presentationId` | string | The presentation ID |
|
||||
| ↳ `layout` | string | The layout used for the new slide |
|
||||
| ↳ `insertionIndex` | number | The zero-based index where the slide was inserted |
|
||||
| ↳ `url` | string | URL to open the presentation |
|
||||
|
||||
### `google_slides_add_image`
|
||||
|
||||
@@ -154,6 +179,10 @@ Insert an image into a specific slide in a Google Slides presentation
|
||||
| --------- | ---- | ----------- |
|
||||
| `imageId` | string | The object ID of the newly created image |
|
||||
| `metadata` | json | Operation metadata including presentation ID and image URL |
|
||||
| ↳ `presentationId` | string | The presentation ID |
|
||||
| ↳ `pageObjectId` | string | The page object ID where the image was inserted |
|
||||
| ↳ `imageUrl` | string | The source image URL |
|
||||
| ↳ `url` | string | URL to open the presentation |
|
||||
|
||||
### `google_slides_get_thumbnail`
|
||||
|
||||
@@ -176,6 +205,10 @@ Generate a thumbnail image of a specific slide in a Google Slides presentation
|
||||
| `width` | number | Width of the thumbnail in pixels |
|
||||
| `height` | number | Height of the thumbnail in pixels |
|
||||
| `metadata` | json | Operation metadata including presentation ID and page object ID |
|
||||
| ↳ `presentationId` | string | The presentation ID |
|
||||
| ↳ `pageObjectId` | string | The page object ID for the thumbnail |
|
||||
| ↳ `thumbnailSize` | string | The requested thumbnail size |
|
||||
| ↳ `mimeType` | string | The thumbnail MIME type |
|
||||
|
||||
### `google_slides_get_page`
|
||||
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import { PopoverSection } from '@/components/emcn'
|
||||
|
||||
/**
|
||||
* Skeleton loading component for chat history dropdown
|
||||
* Displays placeholder content while chats are being loaded
|
||||
*/
|
||||
export function ChatHistorySkeleton() {
|
||||
return (
|
||||
<>
|
||||
<PopoverSection>
|
||||
<div className='h-3 w-12 animate-pulse rounded bg-muted/40' />
|
||||
</PopoverSection>
|
||||
<div className='flex flex-col gap-0.5'>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className='flex h-[25px] items-center px-[6px]'>
|
||||
<div className='h-3 w-full animate-pulse rounded bg-muted/40' />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export * from './chat-history-skeleton'
|
||||
@@ -1,56 +0,0 @@
|
||||
import { Button } from '@/components/emcn'
|
||||
|
||||
interface CheckpointDiscardModalProps {
|
||||
isProcessingDiscard: boolean
|
||||
onCancel: () => void
|
||||
onRevert: () => void
|
||||
onContinue: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline confirmation modal for discarding checkpoints during message editing
|
||||
* Shows options to cancel, revert to checkpoint, or continue without reverting
|
||||
*/
|
||||
export function CheckpointDiscardModal({
|
||||
isProcessingDiscard,
|
||||
onCancel,
|
||||
onRevert,
|
||||
onContinue,
|
||||
}: CheckpointDiscardModalProps) {
|
||||
return (
|
||||
<div className='mt-[8px] rounded-[4px] border border-[var(--border)] bg-[var(--surface-4)] p-[10px]'>
|
||||
<p className='mb-[8px] text-[12px] text-[var(--text-primary)]'>
|
||||
Continue from a previous message?
|
||||
</p>
|
||||
<div className='flex gap-[8px]'>
|
||||
<Button
|
||||
onClick={onCancel}
|
||||
variant='active'
|
||||
size='sm'
|
||||
className='flex-1'
|
||||
disabled={isProcessingDiscard}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onRevert}
|
||||
variant='destructive'
|
||||
size='sm'
|
||||
className='flex-1'
|
||||
disabled={isProcessingDiscard}
|
||||
>
|
||||
{isProcessingDiscard ? 'Reverting...' : 'Revert'}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onContinue}
|
||||
variant='tertiary'
|
||||
size='sm'
|
||||
className='flex-1'
|
||||
disabled={isProcessingDiscard}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export * from './checkpoint-discard-modal'
|
||||
@@ -1 +0,0 @@
|
||||
export * from './file-display'
|
||||
@@ -1,7 +1,5 @@
|
||||
export * from './checkpoint-discard-modal'
|
||||
export * from './file-display'
|
||||
export { CopilotMarkdownRenderer } from './markdown-renderer'
|
||||
export * from './restore-checkpoint-modal'
|
||||
export { default as CopilotMarkdownRenderer } from './markdown-renderer'
|
||||
export * from './smooth-streaming'
|
||||
export * from './thinking-block'
|
||||
export * from './usage-limit-actions'
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { default as CopilotMarkdownRenderer } from './markdown-renderer'
|
||||
@@ -1 +0,0 @@
|
||||
export * from './restore-checkpoint-modal'
|
||||
@@ -1,46 +0,0 @@
|
||||
import { Button } from '@/components/emcn'
|
||||
|
||||
interface RestoreCheckpointModalProps {
|
||||
isReverting: boolean
|
||||
onCancel: () => void
|
||||
onConfirm: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline confirmation modal for restoring a checkpoint
|
||||
* Warns user that the action cannot be undone
|
||||
*/
|
||||
export function RestoreCheckpointModal({
|
||||
isReverting,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: RestoreCheckpointModalProps) {
|
||||
return (
|
||||
<div className='mt-[8px] rounded-[4px] border border-[var(--border)] bg-[var(--surface-4)] p-[10px]'>
|
||||
<p className='mb-[8px] text-[12px] text-[var(--text-primary)]'>
|
||||
Revert to checkpoint? This will restore your workflow to the state saved at this checkpoint.{' '}
|
||||
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
|
||||
</p>
|
||||
<div className='flex gap-[8px]'>
|
||||
<Button
|
||||
onClick={onCancel}
|
||||
variant='active'
|
||||
size='sm'
|
||||
className='flex-1'
|
||||
disabled={isReverting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onConfirm}
|
||||
variant='destructive'
|
||||
size='sm'
|
||||
className='flex-1'
|
||||
disabled={isReverting}
|
||||
>
|
||||
{isReverting ? 'Reverting...' : 'Revert'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { memo, useEffect, useRef, useState } from 'react'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { CopilotMarkdownRenderer } from '../markdown-renderer'
|
||||
import CopilotMarkdownRenderer from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/markdown-renderer'
|
||||
|
||||
/**
|
||||
* Character animation delay in milliseconds
|
||||
@@ -1 +0,0 @@
|
||||
export * from './smooth-streaming'
|
||||
@@ -3,20 +3,15 @@
|
||||
import { memo, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { ChevronUp } from 'lucide-react'
|
||||
import { CopilotMarkdownRenderer } from '../markdown-renderer'
|
||||
import CopilotMarkdownRenderer from './markdown-renderer'
|
||||
|
||||
/**
|
||||
* Removes thinking tags (raw or escaped) from streamed content.
|
||||
* Also strips special tags (options, plan) that may have been accidentally included.
|
||||
*/
|
||||
function stripThinkingTags(text: string): string {
|
||||
return text
|
||||
.replace(/<\/?thinking[^>]*>/gi, '')
|
||||
.replace(/<\/?thinking[^&]*>/gi, '')
|
||||
.replace(/<options>[\s\S]*?<\/options>/gi, '') // Strip complete options tags
|
||||
.replace(/<options>[\s\S]*$/gi, '') // Strip incomplete/streaming options tags
|
||||
.replace(/<plan>[\s\S]*?<\/plan>/gi, '') // Strip complete plan tags
|
||||
.replace(/<plan>[\s\S]*$/gi, '') // Strip incomplete/streaming plan tags
|
||||
.trim()
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export * from './thinking-block'
|
||||
@@ -1 +0,0 @@
|
||||
export * from './usage-limit-actions'
|
||||
@@ -9,22 +9,18 @@ import {
|
||||
ToolCall,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components'
|
||||
import {
|
||||
CheckpointDiscardModal,
|
||||
FileAttachmentDisplay,
|
||||
RestoreCheckpointModal,
|
||||
SmoothStreamingText,
|
||||
StreamingIndicator,
|
||||
ThinkingBlock,
|
||||
UsageLimitActions,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components'
|
||||
import { CopilotMarkdownRenderer } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/markdown-renderer'
|
||||
import CopilotMarkdownRenderer from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/markdown-renderer'
|
||||
import {
|
||||
useCheckpointManagement,
|
||||
useMessageContentAnalysis,
|
||||
useMessageEditing,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/hooks'
|
||||
import { UserInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/user-input'
|
||||
import { buildMentionHighlightNodes } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils'
|
||||
import type { CopilotMessage as CopilotMessageType } from '@/stores/panel'
|
||||
import { useCopilotStore } from '@/stores/panel'
|
||||
|
||||
@@ -183,32 +179,6 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
||||
return message.content ? parseSpecialTags(message.content) : null
|
||||
}, [message.content, message.contentBlocks, isUser, isStreaming])
|
||||
|
||||
// Detect previously selected option by checking if the next user message matches an option
|
||||
const selectedOptionKey = useMemo(() => {
|
||||
if (!parsedTags?.options || isStreaming) return null
|
||||
|
||||
// Find the index of this message in the messages array
|
||||
const currentIndex = messages.findIndex((m) => m.id === message.id)
|
||||
if (currentIndex === -1 || currentIndex >= messages.length - 1) return null
|
||||
|
||||
// Get the next message
|
||||
const nextMessage = messages[currentIndex + 1]
|
||||
if (!nextMessage || nextMessage.role !== 'user') return null
|
||||
|
||||
const nextContent = nextMessage.content?.trim()
|
||||
if (!nextContent) return null
|
||||
|
||||
// Check if the next user message content matches any option title
|
||||
for (const [key, option] of Object.entries(parsedTags.options)) {
|
||||
const optionTitle = typeof option === 'string' ? option : option.title
|
||||
if (nextContent === optionTitle) {
|
||||
return key
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}, [parsedTags?.options, messages, message.id, isStreaming])
|
||||
|
||||
// Get sendMessage from store for continuation actions
|
||||
const sendMessage = useCopilotStore((s) => s.sendMessage)
|
||||
|
||||
@@ -221,9 +191,6 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
||||
[sendMessage]
|
||||
)
|
||||
|
||||
// Analyze message content for visibility (used for assistant messages)
|
||||
const { hasVisibleContent } = useMessageContentAnalysis({ message })
|
||||
|
||||
// Memoize content blocks to avoid re-rendering unchanged blocks
|
||||
// No entrance animations to prevent layout shift
|
||||
const memoizedContentBlocks = useMemo(() => {
|
||||
@@ -323,12 +290,40 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
||||
|
||||
{/* Inline Checkpoint Discard Confirmation - shown below input in edit mode */}
|
||||
{showCheckpointDiscardModal && (
|
||||
<CheckpointDiscardModal
|
||||
isProcessingDiscard={isProcessingDiscard}
|
||||
onCancel={handleCancelCheckpointDiscard}
|
||||
onRevert={handleContinueAndRevert}
|
||||
onContinue={handleContinueWithoutRevert}
|
||||
/>
|
||||
<div className='mt-[8px] rounded-[4px] border border-[var(--border)] bg-[var(--surface-4)] p-[10px]'>
|
||||
<p className='mb-[8px] text-[12px] text-[var(--text-primary)]'>
|
||||
Continue from a previous message?
|
||||
</p>
|
||||
<div className='flex gap-[8px]'>
|
||||
<Button
|
||||
onClick={handleCancelCheckpointDiscard}
|
||||
variant='active'
|
||||
size='sm'
|
||||
className='flex-1'
|
||||
disabled={isProcessingDiscard}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleContinueAndRevert}
|
||||
variant='destructive'
|
||||
size='sm'
|
||||
className='flex-1'
|
||||
disabled={isProcessingDiscard}
|
||||
>
|
||||
{isProcessingDiscard ? 'Reverting...' : 'Revert'}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleContinueWithoutRevert}
|
||||
variant='tertiary'
|
||||
size='sm'
|
||||
className='flex-1'
|
||||
disabled={isProcessingDiscard}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
@@ -353,15 +348,46 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
||||
ref={messageContentRef}
|
||||
className={`relative whitespace-pre-wrap break-words px-[2px] py-1 font-medium font-sans text-[var(--text-primary)] text-sm leading-[1.25rem] ${isSendingMessage && isLastUserMessage && isHoveringMessage ? 'pr-7' : ''} ${!isExpanded && needsExpansion ? 'max-h-[60px] overflow-hidden' : 'overflow-visible'}`}
|
||||
>
|
||||
{buildMentionHighlightNodes(
|
||||
message.content || '',
|
||||
message.contexts || [],
|
||||
(token, key) => (
|
||||
<span key={key} className='rounded-[4px] bg-[rgba(50,189,126,0.65)] py-[1px]'>
|
||||
{token}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
{(() => {
|
||||
const text = message.content || ''
|
||||
const contexts: any[] = Array.isArray((message as any).contexts)
|
||||
? ((message as any).contexts as any[])
|
||||
: []
|
||||
|
||||
// Build tokens with their prefixes (@ for mentions, / for commands)
|
||||
const tokens = contexts
|
||||
.filter((c) => c?.kind !== 'current_workflow' && c?.label)
|
||||
.map((c) => {
|
||||
const prefix = c?.kind === 'slash_command' ? '/' : '@'
|
||||
return `${prefix}${c.label}`
|
||||
})
|
||||
if (!tokens.length) return text
|
||||
|
||||
const escapeRegex = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
const pattern = new RegExp(`(${tokens.map(escapeRegex).join('|')})`, 'g')
|
||||
|
||||
const nodes: React.ReactNode[] = []
|
||||
let lastIndex = 0
|
||||
let match: RegExpExecArray | null
|
||||
while ((match = pattern.exec(text)) !== null) {
|
||||
const i = match.index
|
||||
const before = text.slice(lastIndex, i)
|
||||
if (before) nodes.push(before)
|
||||
const mention = match[0]
|
||||
nodes.push(
|
||||
<span
|
||||
key={`mention-${i}-${lastIndex}`}
|
||||
className='rounded-[4px] bg-[rgba(50,189,126,0.65)] py-[1px]'
|
||||
>
|
||||
{mention}
|
||||
</span>
|
||||
)
|
||||
lastIndex = i + mention.length
|
||||
}
|
||||
const tail = text.slice(lastIndex)
|
||||
if (tail) nodes.push(tail)
|
||||
return nodes
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* Gradient fade when truncated - applies to entire message box */}
|
||||
@@ -413,16 +439,50 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
||||
|
||||
{/* Inline Restore Checkpoint Confirmation */}
|
||||
{showRestoreConfirmation && (
|
||||
<RestoreCheckpointModal
|
||||
isReverting={isReverting}
|
||||
onCancel={handleCancelRevert}
|
||||
onConfirm={handleConfirmRevert}
|
||||
/>
|
||||
<div className='mt-[8px] rounded-[4px] border border-[var(--border)] bg-[var(--surface-4)] p-[10px]'>
|
||||
<p className='mb-[8px] text-[12px] text-[var(--text-primary)]'>
|
||||
Revert to checkpoint? This will restore your workflow to the state saved at this
|
||||
checkpoint.{' '}
|
||||
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
|
||||
</p>
|
||||
<div className='flex gap-[8px]'>
|
||||
<Button
|
||||
onClick={handleCancelRevert}
|
||||
variant='active'
|
||||
size='sm'
|
||||
className='flex-1'
|
||||
disabled={isReverting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirmRevert}
|
||||
variant='destructive'
|
||||
size='sm'
|
||||
className='flex-1'
|
||||
disabled={isReverting}
|
||||
>
|
||||
{isReverting ? 'Reverting...' : 'Revert'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Check if there's any visible content in the blocks
|
||||
const hasVisibleContent = useMemo(() => {
|
||||
if (!message.contentBlocks || message.contentBlocks.length === 0) return false
|
||||
return message.contentBlocks.some((block) => {
|
||||
if (block.type === 'text') {
|
||||
const parsed = parseSpecialTags(block.content)
|
||||
return parsed.cleanContent.trim().length > 0
|
||||
}
|
||||
return block.type === 'thinking' || block.type === 'tool_call'
|
||||
})
|
||||
}, [message.contentBlocks])
|
||||
|
||||
if (isAssistant) {
|
||||
return (
|
||||
<div
|
||||
@@ -474,7 +534,6 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
||||
isLastMessage && !isStreaming && parsedTags.optionsComplete === true
|
||||
}
|
||||
streaming={isStreaming || !parsedTags.optionsComplete}
|
||||
selectedOptionKey={selectedOptionKey}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
export { useCheckpointManagement } from './use-checkpoint-management'
|
||||
export { useMessageContentAnalysis } from './use-message-content-analysis'
|
||||
export { useMessageEditing } from './use-message-editing'
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import { useMemo } from 'react'
|
||||
import { parseSpecialTags } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components'
|
||||
import type { CopilotMessage } from '@/stores/panel'
|
||||
|
||||
interface UseMessageContentAnalysisProps {
|
||||
message: CopilotMessage
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to analyze message content blocks for visibility and content
|
||||
* Determines if there's any visible content to display
|
||||
*
|
||||
* @param props - Configuration containing the message to analyze
|
||||
* @returns Object containing visibility analysis results
|
||||
*/
|
||||
export function useMessageContentAnalysis({ message }: UseMessageContentAnalysisProps) {
|
||||
const hasVisibleContent = useMemo(() => {
|
||||
if (!message.contentBlocks || message.contentBlocks.length === 0) return false
|
||||
return message.contentBlocks.some((block) => {
|
||||
if (block.type === 'text') {
|
||||
const parsed = parseSpecialTags(block.content)
|
||||
return parsed.cleanContent.trim().length > 0
|
||||
}
|
||||
return block.type === 'thinking' || block.type === 'tool_call'
|
||||
})
|
||||
}, [message.contentBlocks])
|
||||
|
||||
return {
|
||||
hasVisibleContent,
|
||||
}
|
||||
}
|
||||
@@ -2,18 +2,11 @@
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import type { ChatContext, CopilotMessage, MessageFileAttachment } from '@/stores/panel'
|
||||
import type { CopilotMessage } from '@/stores/panel'
|
||||
import { useCopilotStore } from '@/stores/panel'
|
||||
|
||||
const logger = createLogger('useMessageEditing')
|
||||
|
||||
/**
|
||||
* Ref interface for UserInput component
|
||||
*/
|
||||
interface UserInputRef {
|
||||
focus: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Message truncation height in pixels
|
||||
*/
|
||||
@@ -39,8 +32,8 @@ interface UseMessageEditingProps {
|
||||
setShowCheckpointDiscardModal: (show: boolean) => void
|
||||
pendingEditRef: React.MutableRefObject<{
|
||||
message: string
|
||||
fileAttachments?: MessageFileAttachment[]
|
||||
contexts?: ChatContext[]
|
||||
fileAttachments?: any[]
|
||||
contexts?: any[]
|
||||
} | null>
|
||||
/**
|
||||
* When true, disables the internal document click-outside handler.
|
||||
@@ -76,7 +69,7 @@ export function useMessageEditing(props: UseMessageEditingProps) {
|
||||
|
||||
const editContainerRef = useRef<HTMLDivElement>(null)
|
||||
const messageContentRef = useRef<HTMLDivElement>(null)
|
||||
const userInputRef = useRef<UserInputRef>(null)
|
||||
const userInputRef = useRef<any>(null)
|
||||
|
||||
const { sendMessage, isSendingMessage, abortMessage, currentChat } = useCopilotStore()
|
||||
|
||||
@@ -128,11 +121,7 @@ export function useMessageEditing(props: UseMessageEditingProps) {
|
||||
* Truncates messages after edited message and resends with same ID
|
||||
*/
|
||||
const performEdit = useCallback(
|
||||
async (
|
||||
editedMessage: string,
|
||||
fileAttachments?: MessageFileAttachment[],
|
||||
contexts?: ChatContext[]
|
||||
) => {
|
||||
async (editedMessage: string, fileAttachments?: any[], contexts?: any[]) => {
|
||||
const currentMessages = messages
|
||||
const editIndex = currentMessages.findIndex((m) => m.id === message.id)
|
||||
|
||||
@@ -145,7 +134,7 @@ export function useMessageEditing(props: UseMessageEditingProps) {
|
||||
...message,
|
||||
content: editedMessage,
|
||||
fileAttachments: fileAttachments || message.fileAttachments,
|
||||
contexts: contexts || message.contexts,
|
||||
contexts: contexts || (message as any).contexts,
|
||||
}
|
||||
|
||||
useCopilotStore.setState({ messages: [...truncatedMessages, updatedMessage] })
|
||||
@@ -164,7 +153,7 @@ export function useMessageEditing(props: UseMessageEditingProps) {
|
||||
timestamp: m.timestamp,
|
||||
...(m.contentBlocks && { contentBlocks: m.contentBlocks }),
|
||||
...(m.fileAttachments && { fileAttachments: m.fileAttachments }),
|
||||
...(m.contexts && { contexts: m.contexts }),
|
||||
...((m as any).contexts && { contexts: (m as any).contexts }),
|
||||
})),
|
||||
}),
|
||||
})
|
||||
@@ -175,7 +164,7 @@ export function useMessageEditing(props: UseMessageEditingProps) {
|
||||
|
||||
await sendMessage(editedMessage, {
|
||||
fileAttachments: fileAttachments || message.fileAttachments,
|
||||
contexts: contexts || message.contexts,
|
||||
contexts: contexts || (message as any).contexts,
|
||||
messageId: message.id,
|
||||
queueIfBusy: false,
|
||||
})
|
||||
@@ -189,11 +178,7 @@ export function useMessageEditing(props: UseMessageEditingProps) {
|
||||
* Checks for checkpoints and shows confirmation if needed
|
||||
*/
|
||||
const handleSubmitEdit = useCallback(
|
||||
async (
|
||||
editedMessage: string,
|
||||
fileAttachments?: MessageFileAttachment[],
|
||||
contexts?: ChatContext[]
|
||||
) => {
|
||||
async (editedMessage: string, fileAttachments?: any[], contexts?: any[]) => {
|
||||
if (!editedMessage.trim()) return
|
||||
|
||||
if (isSendingMessage) {
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export * from './copilot-message'
|
||||
@@ -1,8 +1,7 @@
|
||||
export * from './chat-history-skeleton'
|
||||
export * from './copilot-message'
|
||||
export * from './plan-mode-section'
|
||||
export * from './queued-messages'
|
||||
export * from './todo-list'
|
||||
export * from './tool-call'
|
||||
export * from './user-input'
|
||||
export * from './welcome'
|
||||
export * from './copilot-message/copilot-message'
|
||||
export * from './plan-mode-section/plan-mode-section'
|
||||
export * from './queued-messages/queued-messages'
|
||||
export * from './todo-list/todo-list'
|
||||
export * from './tool-call/tool-call'
|
||||
export * from './user-input/user-input'
|
||||
export * from './welcome/welcome'
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export * from './plan-mode-section'
|
||||
@@ -29,7 +29,7 @@ import { Check, GripHorizontal, Pencil, X } from 'lucide-react'
|
||||
import { Button, Textarea } from '@/components/emcn'
|
||||
import { Trash } from '@/components/emcn/icons/trash'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { CopilotMarkdownRenderer } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/markdown-renderer'
|
||||
import CopilotMarkdownRenderer from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/markdown-renderer'
|
||||
|
||||
/**
|
||||
* Shared border and background styles
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export * from './queued-messages'
|
||||
@@ -1 +0,0 @@
|
||||
export * from './todo-list'
|
||||
@@ -1 +0,0 @@
|
||||
export * from './tool-call'
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
hasInterrupt as hasInterruptFromConfig,
|
||||
isSpecialTool as isSpecialToolFromConfig,
|
||||
} from '@/lib/copilot/tools/client/ui-config'
|
||||
import { CopilotMarkdownRenderer } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/markdown-renderer'
|
||||
import CopilotMarkdownRenderer from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/markdown-renderer'
|
||||
import { SmoothStreamingText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/smooth-streaming'
|
||||
import { ThinkingBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/thinking-block'
|
||||
import { getDisplayValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block'
|
||||
@@ -26,9 +26,6 @@ import { CLASS_TOOL_METADATA } from '@/stores/panel/copilot/store'
|
||||
import type { SubAgentContentBlock } from '@/stores/panel/copilot/types'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
|
||||
/**
|
||||
* Parse special tags from content
|
||||
*/
|
||||
/**
|
||||
* Plan step can be either a string or an object with title and plan
|
||||
*/
|
||||
@@ -47,6 +44,56 @@ interface ParsedTags {
|
||||
cleanContent: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract plan steps from plan_respond tool calls in subagent blocks.
|
||||
* Returns { steps, isComplete } where steps is in the format expected by PlanSteps component.
|
||||
*/
|
||||
function extractPlanFromBlocks(blocks: SubAgentContentBlock[] | undefined): {
|
||||
steps: Record<string, PlanStep> | undefined
|
||||
isComplete: boolean
|
||||
} {
|
||||
if (!blocks) return { steps: undefined, isComplete: false }
|
||||
|
||||
// Find the plan_respond tool call
|
||||
const planRespondBlock = blocks.find(
|
||||
(b) => b.type === 'subagent_tool_call' && b.toolCall?.name === 'plan_respond'
|
||||
)
|
||||
|
||||
if (!planRespondBlock?.toolCall) {
|
||||
return { steps: undefined, isComplete: false }
|
||||
}
|
||||
|
||||
// Tool call arguments can be in different places depending on the source
|
||||
// Also handle nested data.arguments structure from the schema
|
||||
const tc = planRespondBlock.toolCall as any
|
||||
const args = tc.params || tc.parameters || tc.input || tc.arguments || tc.data?.arguments || {}
|
||||
const stepsArray = args.steps
|
||||
|
||||
if (!Array.isArray(stepsArray) || stepsArray.length === 0) {
|
||||
return { steps: undefined, isComplete: false }
|
||||
}
|
||||
|
||||
// Convert array format to Record<string, PlanStep> format
|
||||
// From: [{ number: 1, title: "..." }, { number: 2, title: "..." }]
|
||||
// To: { "1": "...", "2": "..." }
|
||||
const steps: Record<string, PlanStep> = {}
|
||||
for (const step of stepsArray) {
|
||||
if (step.number !== undefined && step.title) {
|
||||
steps[String(step.number)] = step.title
|
||||
}
|
||||
}
|
||||
|
||||
// Check if the tool call is complete (not pending/executing)
|
||||
const isComplete =
|
||||
planRespondBlock.toolCall.state === ClientToolCallState.success ||
|
||||
planRespondBlock.toolCall.state === ClientToolCallState.error
|
||||
|
||||
return {
|
||||
steps: Object.keys(steps).length > 0 ? steps : undefined,
|
||||
isComplete,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to parse partial JSON for streaming options.
|
||||
* Attempts to extract complete key-value pairs from incomplete JSON.
|
||||
@@ -244,7 +291,6 @@ export function OptionsSelector({
|
||||
disabled = false,
|
||||
enableKeyboardNav = false,
|
||||
streaming = false,
|
||||
selectedOptionKey = null,
|
||||
}: {
|
||||
options: Record<string, OptionItem>
|
||||
onSelect: (optionKey: string, optionText: string) => void
|
||||
@@ -253,8 +299,6 @@ export function OptionsSelector({
|
||||
enableKeyboardNav?: boolean
|
||||
/** When true, looks enabled but interaction is disabled (for streaming state) */
|
||||
streaming?: boolean
|
||||
/** Pre-selected option key (for restoring selection from history) */
|
||||
selectedOptionKey?: string | null
|
||||
}) {
|
||||
const isInteractionDisabled = disabled || streaming
|
||||
const sortedOptions = useMemo(() => {
|
||||
@@ -273,7 +317,7 @@ export function OptionsSelector({
|
||||
}, [options])
|
||||
|
||||
const [hoveredIndex, setHoveredIndex] = useState(0)
|
||||
const [chosenKey, setChosenKey] = useState<string | null>(selectedOptionKey)
|
||||
const [chosenKey, setChosenKey] = useState<string | null>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const isLocked = chosenKey !== null
|
||||
@@ -657,11 +701,20 @@ function SubAgentThinkingContent({
|
||||
}
|
||||
}
|
||||
|
||||
// Extract plan from plan_respond tool call (preferred) or fall back to <plan> tags
|
||||
const { steps: planSteps, isComplete: planComplete } = extractPlanFromBlocks(blocks)
|
||||
const allParsed = parseSpecialTags(allRawText)
|
||||
|
||||
if (!cleanText.trim() && !allParsed.plan) return null
|
||||
// Prefer plan_respond tool data over <plan> tags
|
||||
const hasPlan =
|
||||
!!(planSteps && Object.keys(planSteps).length > 0) ||
|
||||
!!(allParsed.plan && Object.keys(allParsed.plan).length > 0)
|
||||
const planToRender = planSteps || allParsed.plan
|
||||
const isPlanStreaming = planSteps ? !planComplete : isStreaming
|
||||
|
||||
const hasSpecialTags = !!(allParsed.plan && Object.keys(allParsed.plan).length > 0)
|
||||
if (!cleanText.trim() && !hasPlan) return null
|
||||
|
||||
const hasSpecialTags = hasPlan
|
||||
|
||||
return (
|
||||
<div className='space-y-1.5'>
|
||||
@@ -673,9 +726,7 @@ function SubAgentThinkingContent({
|
||||
hasSpecialTags={hasSpecialTags}
|
||||
/>
|
||||
)}
|
||||
{allParsed.plan && Object.keys(allParsed.plan).length > 0 && (
|
||||
<PlanSteps steps={allParsed.plan} streaming={isStreaming} />
|
||||
)}
|
||||
{hasPlan && planToRender && <PlanSteps steps={planToRender} streaming={isPlanStreaming} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -747,8 +798,19 @@ const SubagentContentRenderer = memo(function SubagentContentRenderer({
|
||||
}
|
||||
|
||||
const allParsed = parseSpecialTags(allRawText)
|
||||
|
||||
// Extract plan from plan_respond tool call (preferred) or fall back to <plan> tags
|
||||
const { steps: planSteps, isComplete: planComplete } = extractPlanFromBlocks(
|
||||
toolCall.subAgentBlocks
|
||||
)
|
||||
const hasPlan =
|
||||
!!(planSteps && Object.keys(planSteps).length > 0) ||
|
||||
!!(allParsed.plan && Object.keys(allParsed.plan).length > 0)
|
||||
const planToRender = planSteps || allParsed.plan
|
||||
const isPlanStreaming = planSteps ? !planComplete : isStreaming
|
||||
|
||||
const hasSpecialTags = !!(
|
||||
(allParsed.plan && Object.keys(allParsed.plan).length > 0) ||
|
||||
hasPlan ||
|
||||
(allParsed.options && Object.keys(allParsed.options).length > 0)
|
||||
)
|
||||
|
||||
@@ -760,8 +822,6 @@ const SubagentContentRenderer = memo(function SubagentContentRenderer({
|
||||
const outerLabel = getSubagentCompletionLabel(toolCall.name)
|
||||
const durationText = `${outerLabel} for ${formatDuration(duration)}`
|
||||
|
||||
const hasPlan = allParsed.plan && Object.keys(allParsed.plan).length > 0
|
||||
|
||||
const renderCollapsibleContent = () => (
|
||||
<>
|
||||
{segments.map((segment, index) => {
|
||||
@@ -803,7 +863,7 @@ const SubagentContentRenderer = memo(function SubagentContentRenderer({
|
||||
return (
|
||||
<div className='w-full space-y-1.5'>
|
||||
{renderCollapsibleContent()}
|
||||
{hasPlan && <PlanSteps steps={allParsed.plan!} streaming={isStreaming} />}
|
||||
{hasPlan && planToRender && <PlanSteps steps={planToRender} streaming={isPlanStreaming} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -835,7 +895,7 @@ const SubagentContentRenderer = memo(function SubagentContentRenderer({
|
||||
</div>
|
||||
|
||||
{/* Plan stays outside the collapsible */}
|
||||
{hasPlan && <PlanSteps steps={allParsed.plan!} />}
|
||||
{hasPlan && planToRender && <PlanSteps steps={planToRender} />}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -1415,7 +1475,11 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }:
|
||||
if (
|
||||
toolCall.name === 'checkoff_todo' ||
|
||||
toolCall.name === 'mark_todo_in_progress' ||
|
||||
toolCall.name === 'tool_search_tool_regex'
|
||||
toolCall.name === 'tool_search_tool_regex' ||
|
||||
toolCall.name === 'user_memory' ||
|
||||
toolCall.name === 'edit_responsd' ||
|
||||
toolCall.name === 'debug_respond' ||
|
||||
toolCall.name === 'plan_respond'
|
||||
)
|
||||
return null
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export * from './attached-files-display'
|
||||
@@ -1,127 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { ArrowUp, Image, Loader2 } from 'lucide-react'
|
||||
import { Badge, Button } from '@/components/emcn'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { ModeSelector } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/mode-selector/mode-selector'
|
||||
import { ModelSelector } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/model-selector/model-selector'
|
||||
|
||||
interface BottomControlsProps {
|
||||
mode: 'ask' | 'build' | 'plan'
|
||||
onModeChange?: (mode: 'ask' | 'build' | 'plan') => void
|
||||
selectedModel: string
|
||||
onModelSelect: (model: string) => void
|
||||
isNearTop: boolean
|
||||
disabled: boolean
|
||||
hideModeSelector: boolean
|
||||
canSubmit: boolean
|
||||
isLoading: boolean
|
||||
isAborting: boolean
|
||||
showAbortButton: boolean
|
||||
onSubmit: () => void
|
||||
onAbort: () => void
|
||||
onFileSelect: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Bottom controls section of the user input
|
||||
* Contains mode selector, model selector, file attachment button, and submit/abort buttons
|
||||
*/
|
||||
export function BottomControls({
|
||||
mode,
|
||||
onModeChange,
|
||||
selectedModel,
|
||||
onModelSelect,
|
||||
isNearTop,
|
||||
disabled,
|
||||
hideModeSelector,
|
||||
canSubmit,
|
||||
isLoading,
|
||||
isAborting,
|
||||
showAbortButton,
|
||||
onSubmit,
|
||||
onAbort,
|
||||
onFileSelect,
|
||||
}: BottomControlsProps) {
|
||||
return (
|
||||
<div className='flex items-center justify-between gap-2'>
|
||||
{/* Left side: Mode Selector + Model Selector */}
|
||||
<div className='flex min-w-0 flex-1 items-center gap-[8px]'>
|
||||
{!hideModeSelector && (
|
||||
<ModeSelector
|
||||
mode={mode}
|
||||
onModeChange={onModeChange}
|
||||
isNearTop={isNearTop}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ModelSelector
|
||||
selectedModel={selectedModel}
|
||||
isNearTop={isNearTop}
|
||||
onModelSelect={onModelSelect}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right side: Attach Button + Send Button */}
|
||||
<div className='flex flex-shrink-0 items-center gap-[10px]'>
|
||||
<Badge
|
||||
onClick={onFileSelect}
|
||||
title='Attach file'
|
||||
className={cn(
|
||||
'cursor-pointer rounded-[6px] border-0 bg-transparent p-[0px] dark:bg-transparent',
|
||||
disabled && 'cursor-not-allowed opacity-50'
|
||||
)}
|
||||
>
|
||||
<Image className='!h-3.5 !w-3.5 scale-x-110' />
|
||||
</Badge>
|
||||
|
||||
{showAbortButton ? (
|
||||
<Button
|
||||
onClick={onAbort}
|
||||
disabled={isAborting}
|
||||
className={cn(
|
||||
'h-[20px] w-[20px] rounded-full border-0 p-0 transition-colors',
|
||||
!isAborting
|
||||
? 'bg-[var(--c-383838)] hover:bg-[var(--c-575757)] dark:bg-[var(--c-E0E0E0)] dark:hover:bg-[var(--c-CFCFCF)]'
|
||||
: 'bg-[var(--c-383838)] dark:bg-[var(--c-E0E0E0)]'
|
||||
)}
|
||||
title='Stop generation'
|
||||
>
|
||||
{isAborting ? (
|
||||
<Loader2 className='block h-[13px] w-[13px] animate-spin text-white dark:text-black' />
|
||||
) : (
|
||||
<svg
|
||||
className='block h-[13px] w-[13px] fill-white dark:fill-black'
|
||||
viewBox='0 0 24 24'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<rect x='4' y='4' width='16' height='16' rx='3' ry='3' />
|
||||
</svg>
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={onSubmit}
|
||||
disabled={!canSubmit}
|
||||
className={cn(
|
||||
'h-[22px] w-[22px] rounded-full border-0 p-0 transition-colors',
|
||||
canSubmit
|
||||
? 'bg-[var(--c-383838)] hover:bg-[var(--c-575757)] dark:bg-[var(--c-E0E0E0)] dark:hover:bg-[var(--c-CFCFCF)]'
|
||||
: 'bg-[var(--c-808080)] dark:bg-[var(--c-808080)]'
|
||||
)}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className='block h-3.5 w-3.5 animate-spin text-white dark:text-black' />
|
||||
) : (
|
||||
<ArrowUp
|
||||
className='block h-3.5 w-3.5 text-white dark:text-black'
|
||||
strokeWidth={2.25}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export * from './bottom-controls'
|
||||
@@ -1 +0,0 @@
|
||||
export * from './context-pills'
|
||||
@@ -1,7 +1,6 @@
|
||||
export { AttachedFilesDisplay } from './attached-files-display'
|
||||
export { BottomControls } from './bottom-controls'
|
||||
export { ContextPills } from './context-pills'
|
||||
export { type MentionFolderNav, MentionMenu } from './mention-menu'
|
||||
export { ModeSelector } from './mode-selector'
|
||||
export { ModelSelector } from './model-selector'
|
||||
export { type SlashFolderNav, SlashMenu } from './slash-menu'
|
||||
export { AttachedFilesDisplay } from './attached-files-display/attached-files-display'
|
||||
export { ContextPills } from './context-pills/context-pills'
|
||||
export { type MentionFolderNav, MentionMenu } from './mention-menu/mention-menu'
|
||||
export { ModeSelector } from './mode-selector/mode-selector'
|
||||
export { ModelSelector } from './model-selector/model-selector'
|
||||
export { type SlashFolderNav, SlashMenu } from './slash-menu/slash-menu'
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export * from './mention-menu'
|
||||
@@ -1 +0,0 @@
|
||||
export * from './mode-selector'
|
||||
@@ -1 +0,0 @@
|
||||
export * from './model-selector'
|
||||
@@ -1 +0,0 @@
|
||||
export * from './slash-menu'
|
||||
@@ -5,6 +5,5 @@ export { useMentionData } from './use-mention-data'
|
||||
export { useMentionInsertHandlers } from './use-mention-insert-handlers'
|
||||
export { useMentionKeyboard } from './use-mention-keyboard'
|
||||
export { useMentionMenu } from './use-mention-menu'
|
||||
export { useMentionSystem } from './use-mention-system'
|
||||
export { useMentionTokens } from './use-mention-tokens'
|
||||
export { useTextareaAutoResize } from './use-textarea-auto-resize'
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
escapeRegex,
|
||||
filterOutContext,
|
||||
isContextAlreadySelected,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils'
|
||||
@@ -23,6 +22,9 @@ interface UseContextManagementProps {
|
||||
export function useContextManagement({ message, initialContexts }: UseContextManagementProps) {
|
||||
const [selectedContexts, setSelectedContexts] = useState<ChatContext[]>(initialContexts ?? [])
|
||||
const initializedRef = useRef(false)
|
||||
const escapeRegex = useCallback((value: string) => {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}, [])
|
||||
|
||||
// Initialize with initial contexts when they're first provided (for edit mode)
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
import type { MentionFolderNav } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components'
|
||||
import { useContextManagement } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-context-management'
|
||||
import { useFileAttachments } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments'
|
||||
import { useMentionData } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-data'
|
||||
import { useMentionInsertHandlers } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-insert-handlers'
|
||||
import { useMentionKeyboard } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-keyboard'
|
||||
import { useMentionMenu } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-menu'
|
||||
import { useMentionTokens } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-tokens'
|
||||
import { useTextareaAutoResize } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-textarea-auto-resize'
|
||||
import type { ChatContext } from '@/stores/panel'
|
||||
|
||||
interface UseMentionSystemProps {
|
||||
message: string
|
||||
setMessage: (message: string) => void
|
||||
workflowId: string | null
|
||||
workspaceId: string
|
||||
userId?: string
|
||||
panelWidth: number
|
||||
disabled: boolean
|
||||
isLoading: boolean
|
||||
inputContainerRef: HTMLDivElement | null
|
||||
initialContexts?: ChatContext[]
|
||||
mentionFolderNav: MentionFolderNav | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Composite hook that combines all mention-related hooks into a single interface.
|
||||
* Reduces import complexity in components that need full mention functionality.
|
||||
*
|
||||
* @param props - Configuration for all mention system hooks
|
||||
* @returns Combined interface for mention system functionality
|
||||
*/
|
||||
export function useMentionSystem({
|
||||
message,
|
||||
setMessage,
|
||||
workflowId,
|
||||
workspaceId,
|
||||
userId,
|
||||
panelWidth,
|
||||
disabled,
|
||||
isLoading,
|
||||
inputContainerRef,
|
||||
initialContexts,
|
||||
mentionFolderNav,
|
||||
}: UseMentionSystemProps) {
|
||||
const contextManagement = useContextManagement({ message, initialContexts })
|
||||
|
||||
const mentionMenu = useMentionMenu({
|
||||
message,
|
||||
selectedContexts: contextManagement.selectedContexts,
|
||||
onContextSelect: contextManagement.addContext,
|
||||
onMessageChange: setMessage,
|
||||
})
|
||||
|
||||
const mentionTokens = useMentionTokens({
|
||||
message,
|
||||
selectedContexts: contextManagement.selectedContexts,
|
||||
mentionMenu,
|
||||
setMessage,
|
||||
setSelectedContexts: contextManagement.setSelectedContexts,
|
||||
})
|
||||
|
||||
const { overlayRef } = useTextareaAutoResize({
|
||||
message,
|
||||
panelWidth,
|
||||
selectedContexts: contextManagement.selectedContexts,
|
||||
textareaRef: mentionMenu.textareaRef,
|
||||
containerRef: inputContainerRef,
|
||||
})
|
||||
|
||||
const mentionData = useMentionData({
|
||||
workflowId,
|
||||
workspaceId,
|
||||
})
|
||||
|
||||
const fileAttachments = useFileAttachments({
|
||||
userId,
|
||||
disabled,
|
||||
isLoading,
|
||||
})
|
||||
|
||||
const insertHandlers = useMentionInsertHandlers({
|
||||
mentionMenu,
|
||||
workflowId,
|
||||
selectedContexts: contextManagement.selectedContexts,
|
||||
onContextAdd: contextManagement.addContext,
|
||||
mentionFolderNav,
|
||||
})
|
||||
|
||||
const mentionKeyboard = useMentionKeyboard({
|
||||
mentionMenu,
|
||||
mentionData,
|
||||
insertHandlers,
|
||||
mentionFolderNav,
|
||||
})
|
||||
|
||||
return {
|
||||
contextManagement,
|
||||
mentionMenu,
|
||||
mentionTokens,
|
||||
overlayRef,
|
||||
mentionData,
|
||||
fileAttachments,
|
||||
insertHandlers,
|
||||
mentionKeyboard,
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export * from './user-input'
|
||||
@@ -9,19 +9,19 @@ import {
|
||||
useState,
|
||||
} from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { AtSign } from 'lucide-react'
|
||||
import { ArrowUp, AtSign, Image, Loader2 } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Badge, Button, Textarea } from '@/components/emcn'
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
import type { CopilotModelId } from '@/lib/copilot/models'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import {
|
||||
AttachedFilesDisplay,
|
||||
BottomControls,
|
||||
ContextPills,
|
||||
type MentionFolderNav,
|
||||
MentionMenu,
|
||||
ModelSelector,
|
||||
ModeSelector,
|
||||
type SlashFolderNav,
|
||||
SlashMenu,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components'
|
||||
@@ -44,10 +44,6 @@ import {
|
||||
useTextareaAutoResize,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks'
|
||||
import type { MessageFileAttachment } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments'
|
||||
import {
|
||||
computeMentionHighlightRanges,
|
||||
extractContextTokens,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils'
|
||||
import type { ChatContext } from '@/stores/panel'
|
||||
import { useCopilotStore } from '@/stores/panel'
|
||||
|
||||
@@ -310,7 +306,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
size: f.size,
|
||||
}))
|
||||
|
||||
onSubmit(trimmedMessage, fileAttachmentsForApi, contextManagement.selectedContexts)
|
||||
onSubmit(trimmedMessage, fileAttachmentsForApi, contextManagement.selectedContexts as any)
|
||||
|
||||
const shouldClearInput = clearOnSubmit && !options.preserveInput && !overrideMessage
|
||||
if (shouldClearInput) {
|
||||
@@ -661,7 +657,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
|
||||
const handleModelSelect = useCallback(
|
||||
(model: string) => {
|
||||
setSelectedModel(model as CopilotModelId)
|
||||
setSelectedModel(model as any)
|
||||
},
|
||||
[setSelectedModel]
|
||||
)
|
||||
@@ -681,17 +677,15 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
return <span>{displayText}</span>
|
||||
}
|
||||
|
||||
const tokens = extractContextTokens(contexts)
|
||||
const ranges = computeMentionHighlightRanges(message, tokens)
|
||||
const elements: React.ReactNode[] = []
|
||||
const ranges = mentionTokensWithContext.computeMentionRanges()
|
||||
|
||||
if (ranges.length === 0) {
|
||||
const displayText = message.endsWith('\n') ? `${message}\u200B` : message
|
||||
return <span>{displayText}</span>
|
||||
}
|
||||
|
||||
const elements: React.ReactNode[] = []
|
||||
let lastIndex = 0
|
||||
|
||||
for (let i = 0; i < ranges.length; i++) {
|
||||
const range = ranges[i]
|
||||
|
||||
@@ -700,12 +694,13 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
elements.push(<span key={`text-${i}-${lastIndex}-${range.start}`}>{before}</span>)
|
||||
}
|
||||
|
||||
const mentionText = message.slice(range.start, range.end)
|
||||
elements.push(
|
||||
<span
|
||||
key={`mention-${i}-${range.start}-${range.end}`}
|
||||
className='rounded-[4px] bg-[rgba(50,189,126,0.65)] py-[1px]'
|
||||
>
|
||||
{range.token}
|
||||
{mentionText}
|
||||
</span>
|
||||
)
|
||||
lastIndex = range.end
|
||||
@@ -718,7 +713,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
}
|
||||
|
||||
return elements.length > 0 ? elements : <span>{'\u00A0'}</span>
|
||||
}, [message, contextManagement.selectedContexts])
|
||||
}, [message, contextManagement.selectedContexts, mentionTokensWithContext])
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -860,22 +855,87 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
</div>
|
||||
|
||||
{/* Bottom Row: Mode Selector + Model Selector + Attach Button + Send Button */}
|
||||
<BottomControls
|
||||
mode={mode}
|
||||
onModeChange={onModeChange}
|
||||
selectedModel={selectedModel}
|
||||
onModelSelect={handleModelSelect}
|
||||
isNearTop={isNearTop}
|
||||
disabled={disabled}
|
||||
hideModeSelector={hideModeSelector}
|
||||
canSubmit={canSubmit}
|
||||
isLoading={isLoading}
|
||||
isAborting={isAborting}
|
||||
showAbortButton={Boolean(showAbortButton)}
|
||||
onSubmit={() => void handleSubmit()}
|
||||
onAbort={handleAbort}
|
||||
onFileSelect={fileAttachments.handleFileSelect}
|
||||
/>
|
||||
<div className='flex items-center justify-between gap-2'>
|
||||
{/* Left side: Mode Selector + Model Selector */}
|
||||
<div className='flex min-w-0 flex-1 items-center gap-[8px]'>
|
||||
{!hideModeSelector && (
|
||||
<ModeSelector
|
||||
mode={mode}
|
||||
onModeChange={onModeChange}
|
||||
isNearTop={isNearTop}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ModelSelector
|
||||
selectedModel={selectedModel}
|
||||
isNearTop={isNearTop}
|
||||
onModelSelect={handleModelSelect}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right side: Attach Button + Send Button */}
|
||||
<div className='flex flex-shrink-0 items-center gap-[10px]'>
|
||||
<Badge
|
||||
onClick={fileAttachments.handleFileSelect}
|
||||
title='Attach file'
|
||||
className={cn(
|
||||
'cursor-pointer rounded-[6px] border-0 bg-transparent p-[0px] dark:bg-transparent',
|
||||
disabled && 'cursor-not-allowed opacity-50'
|
||||
)}
|
||||
>
|
||||
<Image className='!h-3.5 !w-3.5 scale-x-110' />
|
||||
</Badge>
|
||||
|
||||
{showAbortButton ? (
|
||||
<Button
|
||||
onClick={handleAbort}
|
||||
disabled={isAborting}
|
||||
className={cn(
|
||||
'h-[20px] w-[20px] rounded-full border-0 p-0 transition-colors',
|
||||
!isAborting
|
||||
? 'bg-[var(--c-383838)] hover:bg-[var(--c-575757)] dark:bg-[var(--c-E0E0E0)] dark:hover:bg-[var(--c-CFCFCF)]'
|
||||
: 'bg-[var(--c-383838)] dark:bg-[var(--c-E0E0E0)]'
|
||||
)}
|
||||
title='Stop generation'
|
||||
>
|
||||
{isAborting ? (
|
||||
<Loader2 className='block h-[13px] w-[13px] animate-spin text-white dark:text-black' />
|
||||
) : (
|
||||
<svg
|
||||
className='block h-[13px] w-[13px] fill-white dark:fill-black'
|
||||
viewBox='0 0 24 24'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<rect x='4' y='4' width='16' height='16' rx='3' ry='3' />
|
||||
</svg>
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={() => {
|
||||
void handleSubmit()
|
||||
}}
|
||||
disabled={!canSubmit}
|
||||
className={cn(
|
||||
'h-[22px] w-[22px] rounded-full border-0 p-0 transition-colors',
|
||||
canSubmit
|
||||
? 'bg-[var(--c-383838)] hover:bg-[var(--c-575757)] dark:bg-[var(--c-E0E0E0)] dark:hover:bg-[var(--c-CFCFCF)]'
|
||||
: 'bg-[var(--c-808080)] dark:bg-[var(--c-808080)]'
|
||||
)}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className='block h-3.5 w-3.5 animate-spin text-white dark:text-black' />
|
||||
) : (
|
||||
<ArrowUp
|
||||
className='block h-3.5 w-3.5 text-white dark:text-black'
|
||||
strokeWidth={2.25}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hidden File Input - enabled during streaming so users can prepare images for the next message */}
|
||||
<input
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import {
|
||||
FOLDER_CONFIGS,
|
||||
type MentionFolderId,
|
||||
@@ -6,102 +5,6 @@ import {
|
||||
import type { MentionDataReturn } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-data'
|
||||
import type { ChatContext } from '@/stores/panel'
|
||||
|
||||
/**
|
||||
* Escapes special regex characters in a string
|
||||
* @param value - String to escape
|
||||
* @returns Escaped string safe for use in RegExp
|
||||
*/
|
||||
export function escapeRegex(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts mention tokens from contexts for display/matching
|
||||
* Filters out current_workflow contexts and builds prefixed labels
|
||||
* @param contexts - Array of chat contexts
|
||||
* @returns Array of prefixed token strings (e.g., "@workflow", "/web")
|
||||
*/
|
||||
export function extractContextTokens(contexts: ChatContext[]): string[] {
|
||||
return contexts
|
||||
.filter((c) => c.kind !== 'current_workflow' && c.label)
|
||||
.map((c) => {
|
||||
const prefix = c.kind === 'slash_command' ? '/' : '@'
|
||||
return `${prefix}${c.label}`
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Mention range for text highlighting
|
||||
*/
|
||||
export interface MentionHighlightRange {
|
||||
start: number
|
||||
end: number
|
||||
token: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes mention ranges in text for highlighting
|
||||
* @param text - Text to search
|
||||
* @param tokens - Prefixed tokens to find (e.g., "@workflow", "/web")
|
||||
* @returns Array of ranges with start, end, and matched token
|
||||
*/
|
||||
export function computeMentionHighlightRanges(
|
||||
text: string,
|
||||
tokens: string[]
|
||||
): MentionHighlightRange[] {
|
||||
if (!tokens.length || !text) return []
|
||||
|
||||
const pattern = new RegExp(`(${tokens.map(escapeRegex).join('|')})`, 'g')
|
||||
const ranges: MentionHighlightRange[] = []
|
||||
let match: RegExpExecArray | null
|
||||
|
||||
while ((match = pattern.exec(text)) !== null) {
|
||||
ranges.push({
|
||||
start: match.index,
|
||||
end: match.index + match[0].length,
|
||||
token: match[0],
|
||||
})
|
||||
}
|
||||
|
||||
return ranges
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds React nodes with highlighted mention tokens
|
||||
* @param text - Text to render
|
||||
* @param contexts - Chat contexts to highlight
|
||||
* @param createHighlightSpan - Function to create highlighted span element
|
||||
* @returns Array of React nodes with highlighted mentions
|
||||
*/
|
||||
export function buildMentionHighlightNodes(
|
||||
text: string,
|
||||
contexts: ChatContext[],
|
||||
createHighlightSpan: (token: string, key: string) => ReactNode
|
||||
): ReactNode[] {
|
||||
const tokens = extractContextTokens(contexts)
|
||||
if (!tokens.length) return [text]
|
||||
|
||||
const ranges = computeMentionHighlightRanges(text, tokens)
|
||||
if (!ranges.length) return [text]
|
||||
|
||||
const nodes: ReactNode[] = []
|
||||
let lastIndex = 0
|
||||
|
||||
for (const range of ranges) {
|
||||
if (range.start > lastIndex) {
|
||||
nodes.push(text.slice(lastIndex, range.start))
|
||||
}
|
||||
nodes.push(createHighlightSpan(range.token, `mention-${range.start}-${range.end}`))
|
||||
lastIndex = range.end
|
||||
}
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
nodes.push(text.slice(lastIndex))
|
||||
}
|
||||
|
||||
return nodes
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the data array for a folder ID from mentionData.
|
||||
* Uses FOLDER_CONFIGS as the source of truth for key mapping.
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export * from './welcome'
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
import { Trash } from '@/components/emcn/icons/trash'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import {
|
||||
ChatHistorySkeleton,
|
||||
CopilotMessage,
|
||||
PlanModeSection,
|
||||
QueuedMessages,
|
||||
@@ -41,7 +40,6 @@ import {
|
||||
useTodoManagement,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/hooks'
|
||||
import { useScrollManagement } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
|
||||
import type { ChatContext } from '@/stores/panel'
|
||||
import { useCopilotStore } from '@/stores/panel'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
|
||||
@@ -76,12 +74,10 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
||||
const copilotContainerRef = useRef<HTMLDivElement>(null)
|
||||
const cancelEditCallbackRef = useRef<(() => void) | null>(null)
|
||||
const [editingMessageId, setEditingMessageId] = useState<string | null>(null)
|
||||
const [isEditingMessage, setIsEditingMessage] = useState(false)
|
||||
const [revertingMessageId, setRevertingMessageId] = useState<string | null>(null)
|
||||
const [isHistoryDropdownOpen, setIsHistoryDropdownOpen] = useState(false)
|
||||
|
||||
// Derived state - editing when there's an editingMessageId
|
||||
const isEditingMessage = editingMessageId !== null
|
||||
|
||||
const { activeWorkflowId } = useWorkflowRegistry()
|
||||
|
||||
const {
|
||||
@@ -110,9 +106,9 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
||||
areChatsFresh,
|
||||
workflowId: copilotWorkflowId,
|
||||
setPlanTodos,
|
||||
closePlanTodos,
|
||||
clearPlanArtifact,
|
||||
savePlanArtifact,
|
||||
setSelectedModel,
|
||||
loadAutoAllowedTools,
|
||||
} = useCopilotStore()
|
||||
|
||||
@@ -296,15 +292,6 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
||||
}
|
||||
}, [abortMessage, showPlanTodos])
|
||||
|
||||
/**
|
||||
* Handles closing the plan todos section
|
||||
* Calls store action and clears the todos
|
||||
*/
|
||||
const handleClosePlanTodos = useCallback(() => {
|
||||
closePlanTodos()
|
||||
setPlanTodos([])
|
||||
}, [closePlanTodos, setPlanTodos])
|
||||
|
||||
/**
|
||||
* Handles message submission to the copilot
|
||||
* @param query - The message text to send
|
||||
@@ -312,12 +299,13 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
||||
* @param contexts - Optional context references
|
||||
*/
|
||||
const handleSubmit = useCallback(
|
||||
async (query: string, fileAttachments?: MessageFileAttachment[], contexts?: ChatContext[]) => {
|
||||
async (query: string, fileAttachments?: MessageFileAttachment[], contexts?: any[]) => {
|
||||
// Allow submission even when isSendingMessage - store will queue the message
|
||||
if (!query || !activeWorkflowId) return
|
||||
|
||||
if (showPlanTodos) {
|
||||
setPlanTodos([])
|
||||
const store = useCopilotStore.getState()
|
||||
store.setPlanTodos([])
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -331,7 +319,7 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
||||
logger.error('Failed to send message:', error)
|
||||
}
|
||||
},
|
||||
[activeWorkflowId, sendMessage, showPlanTodos, setPlanTodos]
|
||||
[activeWorkflowId, sendMessage, showPlanTodos]
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -342,6 +330,7 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
||||
const handleEditModeChange = useCallback(
|
||||
(messageId: string, isEditing: boolean, cancelCallback?: () => void) => {
|
||||
setEditingMessageId(isEditing ? messageId : null)
|
||||
setIsEditingMessage(isEditing)
|
||||
cancelEditCallbackRef.current = isEditing ? cancelCallback || null : null
|
||||
logger.info('Edit mode changed', { messageId, isEditing, willDimMessages: isEditing })
|
||||
},
|
||||
@@ -386,6 +375,24 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
||||
[handleHistoryDropdownOpenHook]
|
||||
)
|
||||
|
||||
/**
|
||||
* Skeleton loading component for chat history
|
||||
*/
|
||||
const ChatHistorySkeleton = () => (
|
||||
<>
|
||||
<PopoverSection>
|
||||
<div className='h-3 w-12 animate-pulse rounded bg-muted/40' />
|
||||
</PopoverSection>
|
||||
<div className='flex flex-col gap-0.5'>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className='flex h-[25px] items-center px-[6px]'>
|
||||
<div className='h-3 w-full animate-pulse rounded bg-muted/40' />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
@@ -581,7 +588,11 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
||||
<TodoList
|
||||
todos={planTodos}
|
||||
collapsed={todosCollapsed}
|
||||
onClose={handleClosePlanTodos}
|
||||
onClose={() => {
|
||||
const store = useCopilotStore.getState()
|
||||
store.closePlanTodos?.()
|
||||
useCopilotStore.setState({ planTodos: [] })
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -80,25 +80,6 @@ export function useCopilotInitialization(props: UseCopilotInitializationProps) {
|
||||
loadChats(false)
|
||||
}
|
||||
|
||||
// Handle race condition: chats loaded for wrong workflow during initial load
|
||||
// This happens when user navigates before initial loadChats completes
|
||||
if (
|
||||
activeWorkflowId &&
|
||||
!isLoadingChats &&
|
||||
chatsLoadedForWorkflow !== null &&
|
||||
chatsLoadedForWorkflow !== activeWorkflowId &&
|
||||
!isSendingMessage
|
||||
) {
|
||||
logger.info('Chats loaded for wrong workflow, reloading', {
|
||||
loaded: chatsLoadedForWorkflow,
|
||||
active: activeWorkflowId,
|
||||
})
|
||||
setIsInitialized(false)
|
||||
lastWorkflowIdRef.current = activeWorkflowId
|
||||
setCopilotWorkflowId(activeWorkflowId)
|
||||
loadChats(false)
|
||||
}
|
||||
|
||||
// Mark as initialized when chats are loaded for the active workflow
|
||||
if (
|
||||
activeWorkflowId &&
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ReactElement } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { ChevronDown, ChevronUp, Plus } from 'lucide-react'
|
||||
import { ChevronDown, ChevronsUpDown, ChevronUp, Plus } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import Editor from 'react-simple-code-editor'
|
||||
import { useUpdateNodeInternals } from 'reactflow'
|
||||
@@ -39,6 +39,16 @@ import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
|
||||
const logger = createLogger('ConditionInput')
|
||||
|
||||
/**
|
||||
* Default height for router textareas in pixels
|
||||
*/
|
||||
const ROUTER_DEFAULT_HEIGHT_PX = 100
|
||||
|
||||
/**
|
||||
* Minimum height for router textareas in pixels
|
||||
*/
|
||||
const ROUTER_MIN_HEIGHT_PX = 80
|
||||
|
||||
/**
|
||||
* Represents a single conditional block (if/else if/else).
|
||||
*/
|
||||
@@ -743,6 +753,61 @@ export function ConditionInput({
|
||||
}
|
||||
}, [conditionalBlocks, isRouterMode])
|
||||
|
||||
// State for tracking individual router textarea heights
|
||||
const [routerHeights, setRouterHeights] = useState<{ [key: string]: number }>({})
|
||||
const isResizing = useRef(false)
|
||||
|
||||
/**
|
||||
* Gets the height for a specific router block, returning default if not set.
|
||||
*
|
||||
* @param blockId - ID of the router block
|
||||
* @returns Height in pixels
|
||||
*/
|
||||
const getRouterHeight = (blockId: string): number => {
|
||||
return routerHeights[blockId] ?? ROUTER_DEFAULT_HEIGHT_PX
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles mouse-based resize for router textareas.
|
||||
*
|
||||
* @param e - Mouse event from the resize handle
|
||||
* @param blockId - ID of the block being resized
|
||||
*/
|
||||
const startRouterResize = (e: React.MouseEvent, blockId: string) => {
|
||||
if (isPreview || disabled) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
isResizing.current = true
|
||||
|
||||
const startY = e.clientY
|
||||
const startHeight = getRouterHeight(blockId)
|
||||
|
||||
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||||
if (!isResizing.current) return
|
||||
|
||||
const deltaY = moveEvent.clientY - startY
|
||||
const newHeight = Math.max(ROUTER_MIN_HEIGHT_PX, startHeight + deltaY)
|
||||
|
||||
// Update the textarea height directly for smooth resizing
|
||||
const textarea = inputRefs.current.get(blockId)
|
||||
if (textarea) {
|
||||
textarea.style.height = `${newHeight}px`
|
||||
}
|
||||
|
||||
// Update state to keep track
|
||||
setRouterHeights((prev) => ({ ...prev, [blockId]: newHeight }))
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
isResizing.current = false
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
document.removeEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
document.addEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
|
||||
// Show loading or empty state if not ready or no blocks
|
||||
if (!isReady || conditionalBlocks.length === 0) {
|
||||
return (
|
||||
@@ -907,10 +972,24 @@ export function ConditionInput({
|
||||
}}
|
||||
placeholder='Describe when this route should be taken...'
|
||||
disabled={disabled || isPreview}
|
||||
className='min-h-[60px] resize-none rounded-none border-0 px-3 py-2 text-sm placeholder:text-muted-foreground/50 focus-visible:ring-0 focus-visible:ring-offset-0'
|
||||
rows={2}
|
||||
className='min-h-[100px] resize-none rounded-none border-0 px-3 py-2 text-sm placeholder:text-muted-foreground/50 focus-visible:ring-0 focus-visible:ring-offset-0'
|
||||
rows={4}
|
||||
style={{ height: `${getRouterHeight(block.id)}px` }}
|
||||
/>
|
||||
|
||||
{/* Custom resize handle */}
|
||||
{!isPreview && !disabled && (
|
||||
<div
|
||||
className='absolute right-1 bottom-1 flex h-4 w-4 cursor-ns-resize items-center justify-center rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-5)] dark:bg-[var(--surface-5)]'
|
||||
onMouseDown={(e) => startRouterResize(e, block.id)}
|
||||
onDragStart={(e) => {
|
||||
e.preventDefault()
|
||||
}}
|
||||
>
|
||||
<ChevronsUpDown className='h-3 w-3 text-[var(--text-muted)]' />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{block.showEnvVars && (
|
||||
<EnvVarDropdown
|
||||
visible={block.showEnvVars}
|
||||
|
||||
@@ -234,48 +234,45 @@ export function LongInput({
|
||||
}, [value])
|
||||
|
||||
// Handle resize functionality
|
||||
const startResize = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
isResizing.current = true
|
||||
const startResize = (e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
isResizing.current = true
|
||||
|
||||
const startY = e.clientY
|
||||
const startHeight = height
|
||||
const startY = e.clientY
|
||||
const startHeight = height
|
||||
|
||||
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||||
if (!isResizing.current) return
|
||||
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||||
if (!isResizing.current) return
|
||||
|
||||
const deltaY = moveEvent.clientY - startY
|
||||
const newHeight = Math.max(MIN_HEIGHT_PX, startHeight + deltaY)
|
||||
const deltaY = moveEvent.clientY - startY
|
||||
const newHeight = Math.max(MIN_HEIGHT_PX, startHeight + deltaY)
|
||||
|
||||
if (textareaRef.current && overlayRef.current) {
|
||||
textareaRef.current.style.height = `${newHeight}px`
|
||||
overlayRef.current.style.height = `${newHeight}px`
|
||||
}
|
||||
if (containerRef.current) {
|
||||
containerRef.current.style.height = `${newHeight}px`
|
||||
}
|
||||
// Keep React state in sync so parent layouts (e.g., Editor) update during drag
|
||||
setHeight(newHeight)
|
||||
if (textareaRef.current && overlayRef.current) {
|
||||
textareaRef.current.style.height = `${newHeight}px`
|
||||
overlayRef.current.style.height = `${newHeight}px`
|
||||
}
|
||||
if (containerRef.current) {
|
||||
containerRef.current.style.height = `${newHeight}px`
|
||||
}
|
||||
// Keep React state in sync so parent layouts (e.g., Editor) update during drag
|
||||
setHeight(newHeight)
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
if (textareaRef.current) {
|
||||
const finalHeight = Number.parseInt(textareaRef.current.style.height, 10) || height
|
||||
setHeight(finalHeight)
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
if (textareaRef.current) {
|
||||
const finalHeight = Number.parseInt(textareaRef.current.style.height, 10) || height
|
||||
setHeight(finalHeight)
|
||||
}
|
||||
isResizing.current = false
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
document.removeEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
|
||||
isResizing.current = false
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
document.removeEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
document.addEventListener('mouseup', handleMouseUp)
|
||||
},
|
||||
[height]
|
||||
)
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
document.addEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
|
||||
// Expose wand control handlers to parent via ref
|
||||
useImperativeHandle(
|
||||
|
||||
@@ -2069,6 +2069,7 @@ export const ToolInput = memo(function ToolInput({
|
||||
placeholder: uiComponent.placeholder,
|
||||
requiredScopes: uiComponent.requiredScopes,
|
||||
dependsOn: uiComponent.dependsOn,
|
||||
canonicalParamId: uiComponent.canonicalParamId ?? param.id,
|
||||
}}
|
||||
onProjectSelect={onChange}
|
||||
disabled={disabled}
|
||||
|
||||
@@ -34,6 +34,7 @@ interface LogRowContextMenuProps {
|
||||
onCopyRunId: (runId: string) => void
|
||||
onClearFilters: () => void
|
||||
onClearConsole: () => void
|
||||
onFixInCopilot: (entry: ConsoleEntry) => void
|
||||
hasActiveFilters: boolean
|
||||
}
|
||||
|
||||
@@ -54,6 +55,7 @@ export function LogRowContextMenu({
|
||||
onCopyRunId,
|
||||
onClearFilters,
|
||||
onClearConsole,
|
||||
onFixInCopilot,
|
||||
hasActiveFilters,
|
||||
}: LogRowContextMenuProps) {
|
||||
const hasRunId = entry?.executionId != null
|
||||
@@ -96,6 +98,21 @@ export function LogRowContextMenu({
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Fix in Copilot - only for error rows */}
|
||||
{entry && !entry.success && (
|
||||
<>
|
||||
<PopoverItem
|
||||
onClick={() => {
|
||||
onFixInCopilot(entry)
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
Fix in Copilot
|
||||
</PopoverItem>
|
||||
<PopoverDivider />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Filter actions */}
|
||||
{entry && (
|
||||
<>
|
||||
|
||||
@@ -54,6 +54,7 @@ import { useShowTrainingControls } from '@/hooks/queries/general-settings'
|
||||
import { useCodeViewerFeatures } from '@/hooks/use-code-viewer'
|
||||
import { OUTPUT_PANEL_WIDTH, TERMINAL_HEIGHT } from '@/stores/constants'
|
||||
import { useCopilotTrainingStore } from '@/stores/copilot-training/store'
|
||||
import { openCopilotWithMessage } from '@/stores/notifications/utils'
|
||||
import type { ConsoleEntry } from '@/stores/terminal'
|
||||
import { useTerminalConsoleStore, useTerminalStore } from '@/stores/terminal'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
@@ -226,7 +227,6 @@ const isEventFromEditableElement = (e: KeyboardEvent): boolean => {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check target and walk up ancestors in case editors render nested elements
|
||||
let el: HTMLElement | null = target
|
||||
while (el) {
|
||||
if (isEditable(el)) return true
|
||||
@@ -1159,6 +1159,17 @@ export const Terminal = memo(function Terminal() {
|
||||
clearCurrentWorkflowConsole()
|
||||
}, [clearCurrentWorkflowConsole])
|
||||
|
||||
const handleFixInCopilot = useCallback(
|
||||
(entry: ConsoleEntry) => {
|
||||
const errorMessage = entry.error ? String(entry.error) : 'Unknown error'
|
||||
const blockName = entry.blockName || 'Unknown Block'
|
||||
const message = `${errorMessage}\n\nError in ${blockName}.\n\nPlease fix this.`
|
||||
openCopilotWithMessage(message)
|
||||
closeLogRowMenu()
|
||||
},
|
||||
[closeLogRowMenu]
|
||||
)
|
||||
|
||||
const handleTrainingClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
@@ -1949,6 +1960,7 @@ export const Terminal = memo(function Terminal() {
|
||||
closeLogRowMenu()
|
||||
}}
|
||||
onClearConsole={handleClearConsoleFromMenu}
|
||||
onFixInCopilot={handleFixInCopilot}
|
||||
hasActiveFilters={hasActiveFilters}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -692,7 +692,8 @@ const WorkflowContent = React.memo(() => {
|
||||
parentId?: string,
|
||||
extent?: 'parent',
|
||||
autoConnectEdge?: Edge,
|
||||
triggerMode?: boolean
|
||||
triggerMode?: boolean,
|
||||
presetSubBlockValues?: Record<string, unknown>
|
||||
) => {
|
||||
setPendingSelection([id])
|
||||
setSelectedEdges(new Map())
|
||||
@@ -722,6 +723,14 @@ const WorkflowContent = React.memo(() => {
|
||||
}
|
||||
}
|
||||
|
||||
// Apply preset subblock values (e.g., from tool-operation search)
|
||||
if (presetSubBlockValues) {
|
||||
if (!subBlockValues[id]) {
|
||||
subBlockValues[id] = {}
|
||||
}
|
||||
Object.assign(subBlockValues[id], presetSubBlockValues)
|
||||
}
|
||||
|
||||
collaborativeBatchAddBlocks(
|
||||
[block],
|
||||
autoConnectEdge ? [autoConnectEdge] : [],
|
||||
@@ -1489,7 +1498,7 @@ const WorkflowContent = React.memo(() => {
|
||||
return
|
||||
}
|
||||
|
||||
const { type, enableTriggerMode } = event.detail
|
||||
const { type, enableTriggerMode, presetOperation } = event.detail
|
||||
|
||||
if (!type) return
|
||||
if (type === 'connectionBlock') return
|
||||
@@ -1552,7 +1561,8 @@ const WorkflowContent = React.memo(() => {
|
||||
undefined,
|
||||
undefined,
|
||||
autoConnectEdge,
|
||||
enableTriggerMode
|
||||
enableTriggerMode,
|
||||
presetOperation ? { operation: presetOperation } : undefined
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useParams, useRouter } from 'next/navigation'
|
||||
import { Dialog, DialogPortal, DialogTitle } from '@/components/ui/dialog'
|
||||
import { useBrandConfig } from '@/lib/branding/branding'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { getToolOperationsIndex } from '@/lib/search/tool-operations'
|
||||
import { getTriggersForSidebar, hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils'
|
||||
import { searchItems } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-utils'
|
||||
import { SIDEBAR_SCROLL_EVENT } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar'
|
||||
@@ -81,10 +82,12 @@ type SearchItem = {
|
||||
color?: string
|
||||
href?: string
|
||||
shortcut?: string
|
||||
type: 'block' | 'trigger' | 'tool' | 'workflow' | 'workspace' | 'page' | 'doc'
|
||||
type: 'block' | 'trigger' | 'tool' | 'tool-operation' | 'workflow' | 'workspace' | 'page' | 'doc'
|
||||
isCurrent?: boolean
|
||||
blockType?: string
|
||||
config?: any
|
||||
operationId?: string
|
||||
aliases?: string[]
|
||||
}
|
||||
|
||||
interface SearchResultItemProps {
|
||||
@@ -101,7 +104,11 @@ const SearchResultItem = memo(function SearchResultItem({
|
||||
onItemClick,
|
||||
}: SearchResultItemProps) {
|
||||
const Icon = item.icon
|
||||
const showColoredIcon = item.type === 'block' || item.type === 'trigger' || item.type === 'tool'
|
||||
const showColoredIcon =
|
||||
item.type === 'block' ||
|
||||
item.type === 'trigger' ||
|
||||
item.type === 'tool' ||
|
||||
item.type === 'tool-operation'
|
||||
const isWorkflow = item.type === 'workflow'
|
||||
const isWorkspace = item.type === 'workspace'
|
||||
|
||||
@@ -278,6 +285,24 @@ export const SearchModal = memo(function SearchModal({
|
||||
)
|
||||
}, [open, isOnWorkflowPage, filterBlocks])
|
||||
|
||||
const toolOperations = useMemo(() => {
|
||||
if (!open || !isOnWorkflowPage) return []
|
||||
|
||||
const allowedBlockTypes = new Set(tools.map((t) => t.type))
|
||||
|
||||
return getToolOperationsIndex()
|
||||
.filter((op) => allowedBlockTypes.has(op.blockType))
|
||||
.map((op) => ({
|
||||
id: op.id,
|
||||
name: `${op.serviceName}: ${op.operationName}`,
|
||||
icon: op.icon,
|
||||
bgColor: op.bgColor,
|
||||
blockType: op.blockType,
|
||||
operationId: op.operationId,
|
||||
aliases: op.aliases,
|
||||
}))
|
||||
}, [open, isOnWorkflowPage, tools])
|
||||
|
||||
const pages = useMemo(
|
||||
(): PageItem[] => [
|
||||
{
|
||||
@@ -396,6 +421,19 @@ export const SearchModal = memo(function SearchModal({
|
||||
})
|
||||
})
|
||||
|
||||
toolOperations.forEach((op) => {
|
||||
items.push({
|
||||
id: op.id,
|
||||
name: op.name,
|
||||
icon: op.icon,
|
||||
bgColor: op.bgColor,
|
||||
type: 'tool-operation',
|
||||
blockType: op.blockType,
|
||||
operationId: op.operationId,
|
||||
aliases: op.aliases,
|
||||
})
|
||||
})
|
||||
|
||||
docs.forEach((doc) => {
|
||||
items.push({
|
||||
id: doc.id,
|
||||
@@ -407,10 +445,10 @@ export const SearchModal = memo(function SearchModal({
|
||||
})
|
||||
|
||||
return items
|
||||
}, [workspaces, workflows, pages, blocks, triggers, tools, docs])
|
||||
}, [workspaces, workflows, pages, blocks, triggers, tools, toolOperations, docs])
|
||||
|
||||
const sectionOrder = useMemo<SearchItem['type'][]>(
|
||||
() => ['block', 'tool', 'trigger', 'workflow', 'workspace', 'page', 'doc'],
|
||||
() => ['block', 'tool', 'tool-operation', 'trigger', 'workflow', 'workspace', 'page', 'doc'],
|
||||
[]
|
||||
)
|
||||
|
||||
@@ -457,6 +495,7 @@ export const SearchModal = memo(function SearchModal({
|
||||
page: [],
|
||||
trigger: [],
|
||||
block: [],
|
||||
'tool-operation': [],
|
||||
tool: [],
|
||||
doc: [],
|
||||
}
|
||||
@@ -512,6 +551,17 @@ export const SearchModal = memo(function SearchModal({
|
||||
window.dispatchEvent(event)
|
||||
}
|
||||
break
|
||||
case 'tool-operation':
|
||||
if (item.blockType && item.operationId) {
|
||||
const event = new CustomEvent('add-block-from-toolbar', {
|
||||
detail: {
|
||||
type: item.blockType,
|
||||
presetOperation: item.operationId,
|
||||
},
|
||||
})
|
||||
window.dispatchEvent(event)
|
||||
}
|
||||
break
|
||||
case 'workspace':
|
||||
if (item.isCurrent) {
|
||||
break
|
||||
@@ -592,6 +642,7 @@ export const SearchModal = memo(function SearchModal({
|
||||
page: 'Pages',
|
||||
trigger: 'Triggers',
|
||||
block: 'Blocks',
|
||||
'tool-operation': 'Tool Operations',
|
||||
tool: 'Tools',
|
||||
doc: 'Docs',
|
||||
}
|
||||
|
||||
@@ -8,17 +8,19 @@ export interface SearchableItem {
|
||||
name: string
|
||||
description?: string
|
||||
type: string
|
||||
aliases?: string[]
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
export interface SearchResult<T extends SearchableItem> {
|
||||
item: T
|
||||
score: number
|
||||
matchType: 'exact' | 'prefix' | 'word-boundary' | 'substring' | 'description'
|
||||
matchType: 'exact' | 'prefix' | 'alias' | 'word-boundary' | 'substring' | 'description'
|
||||
}
|
||||
|
||||
const SCORE_EXACT_MATCH = 10000
|
||||
const SCORE_PREFIX_MATCH = 5000
|
||||
const SCORE_ALIAS_MATCH = 3000
|
||||
const SCORE_WORD_BOUNDARY = 1000
|
||||
const SCORE_SUBSTRING_MATCH = 100
|
||||
const DESCRIPTION_WEIGHT = 0.3
|
||||
@@ -67,6 +69,39 @@ function calculateFieldScore(
|
||||
return { score: 0, matchType: null }
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if query matches any alias in the item's aliases array
|
||||
* Returns the alias score if a match is found, 0 otherwise
|
||||
*/
|
||||
function calculateAliasScore(
|
||||
query: string,
|
||||
aliases?: string[]
|
||||
): { score: number; matchType: 'alias' | null } {
|
||||
if (!aliases || aliases.length === 0) {
|
||||
return { score: 0, matchType: null }
|
||||
}
|
||||
|
||||
const normalizedQuery = query.toLowerCase().trim()
|
||||
|
||||
for (const alias of aliases) {
|
||||
const normalizedAlias = alias.toLowerCase().trim()
|
||||
|
||||
if (normalizedAlias === normalizedQuery) {
|
||||
return { score: SCORE_ALIAS_MATCH, matchType: 'alias' }
|
||||
}
|
||||
|
||||
if (normalizedAlias.startsWith(normalizedQuery)) {
|
||||
return { score: SCORE_ALIAS_MATCH * 0.8, matchType: 'alias' }
|
||||
}
|
||||
|
||||
if (normalizedQuery.includes(normalizedAlias) || normalizedAlias.includes(normalizedQuery)) {
|
||||
return { score: SCORE_ALIAS_MATCH * 0.6, matchType: 'alias' }
|
||||
}
|
||||
}
|
||||
|
||||
return { score: 0, matchType: null }
|
||||
}
|
||||
|
||||
/**
|
||||
* Search items using tiered matching algorithm
|
||||
* Returns items sorted by relevance (highest score first)
|
||||
@@ -90,15 +125,20 @@ export function searchItems<T extends SearchableItem>(
|
||||
? calculateFieldScore(normalizedQuery, item.description)
|
||||
: { score: 0, matchType: null }
|
||||
|
||||
const aliasMatch = calculateAliasScore(normalizedQuery, item.aliases)
|
||||
|
||||
const nameScore = nameMatch.score
|
||||
const descScore = descMatch.score * DESCRIPTION_WEIGHT
|
||||
const aliasScore = aliasMatch.score
|
||||
|
||||
const bestScore = Math.max(nameScore, descScore)
|
||||
const bestScore = Math.max(nameScore, descScore, aliasScore)
|
||||
|
||||
if (bestScore > 0) {
|
||||
let matchType: SearchResult<T>['matchType'] = 'substring'
|
||||
if (nameScore >= descScore) {
|
||||
if (nameScore >= descScore && nameScore >= aliasScore) {
|
||||
matchType = nameMatch.matchType || 'substring'
|
||||
} else if (aliasScore >= descScore) {
|
||||
matchType = 'alias'
|
||||
} else {
|
||||
matchType = 'description'
|
||||
}
|
||||
@@ -125,6 +165,8 @@ export function getMatchTypeLabel(matchType: SearchResult<any>['matchType']): st
|
||||
return 'Exact match'
|
||||
case 'prefix':
|
||||
return 'Starts with'
|
||||
case 'alias':
|
||||
return 'Similar to'
|
||||
case 'word-boundary':
|
||||
return 'Word match'
|
||||
case 'substring':
|
||||
|
||||
@@ -11,7 +11,7 @@ export const BrowserUseBlock: BlockConfig<BrowserUseResponse> = {
|
||||
'Integrate Browser Use into the workflow. Can navigate the web and perform actions as if a real user was interacting with the browser.',
|
||||
docsLink: 'https://docs.sim.ai/tools/browser_use',
|
||||
category: 'tools',
|
||||
bgColor: '#E0E0E0',
|
||||
bgColor: '#181C1E',
|
||||
icon: BrowserUseIcon,
|
||||
subBlocks: [
|
||||
{
|
||||
|
||||
@@ -1739,12 +1739,12 @@ export function BrowserUseIcon(props: SVGProps<SVGSVGElement>) {
|
||||
{...props}
|
||||
version='1.0'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='150pt'
|
||||
height='150pt'
|
||||
width='28'
|
||||
height='28'
|
||||
viewBox='0 0 150 150'
|
||||
preserveAspectRatio='xMidYMid meet'
|
||||
>
|
||||
<g transform='translate(0,150) scale(0.05,-0.05)' fill='#000000' stroke='none'>
|
||||
<g transform='translate(0,150) scale(0.05,-0.05)' fill='currentColor' stroke='none'>
|
||||
<path
|
||||
d='M786 2713 c-184 -61 -353 -217 -439 -405 -76 -165 -65 -539 19 -666
|
||||
l57 -85 -48 -124 c-203 -517 -79 -930 346 -1155 159 -85 441 -71 585 28 l111
|
||||
|
||||
@@ -203,10 +203,11 @@ function resolveProjectSelector(
|
||||
): SelectorResolution {
|
||||
const serviceId = subBlock.serviceId
|
||||
const context = buildBaseContext(args)
|
||||
const selectorId = subBlock.canonicalParamId ?? subBlock.id
|
||||
|
||||
switch (serviceId) {
|
||||
case 'linear': {
|
||||
const key: SelectorKey = subBlock.id === 'teamId' ? 'linear.teams' : 'linear.projects'
|
||||
const key: SelectorKey = selectorId === 'teamId' ? 'linear.teams' : 'linear.projects'
|
||||
return { key, context, allowSearch: true }
|
||||
}
|
||||
case 'jira':
|
||||
|
||||
@@ -21,6 +21,8 @@ import {
|
||||
type BatchToggleEnabledOperation,
|
||||
type BatchToggleHandlesOperation,
|
||||
type BatchUpdateParentOperation,
|
||||
captureLatestEdges,
|
||||
captureLatestSubBlockValues,
|
||||
createOperationEntry,
|
||||
runWithUndoRedoRecordingSuspended,
|
||||
type UpdateParentOperation,
|
||||
@@ -28,7 +30,6 @@ import {
|
||||
} from '@/stores/undo-redo'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import { mergeSubblockState } from '@/stores/workflows/utils'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
import type { BlockState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
@@ -445,34 +446,19 @@ export function useUndoRedo() {
|
||||
break
|
||||
}
|
||||
|
||||
const latestEdges = useWorkflowStore
|
||||
.getState()
|
||||
.edges.filter(
|
||||
(e) => existingBlockIds.includes(e.source) || existingBlockIds.includes(e.target)
|
||||
)
|
||||
const latestEdges = captureLatestEdges(
|
||||
useWorkflowStore.getState().edges,
|
||||
existingBlockIds
|
||||
)
|
||||
batchRemoveOp.data.edgeSnapshots = latestEdges
|
||||
|
||||
const latestSubBlockValues: Record<string, Record<string, unknown>> = {}
|
||||
existingBlockIds.forEach((blockId) => {
|
||||
const merged = mergeSubblockState(
|
||||
useWorkflowStore.getState().blocks,
|
||||
activeWorkflowId,
|
||||
blockId
|
||||
)
|
||||
const block = merged[blockId]
|
||||
if (block?.subBlocks) {
|
||||
const values: Record<string, unknown> = {}
|
||||
Object.entries(block.subBlocks).forEach(([subBlockId, subBlock]) => {
|
||||
if (subBlock.value !== null && subBlock.value !== undefined) {
|
||||
values[subBlockId] = subBlock.value
|
||||
}
|
||||
})
|
||||
if (Object.keys(values).length > 0) {
|
||||
latestSubBlockValues[blockId] = values
|
||||
}
|
||||
}
|
||||
})
|
||||
const latestSubBlockValues = captureLatestSubBlockValues(
|
||||
useWorkflowStore.getState().blocks,
|
||||
activeWorkflowId,
|
||||
existingBlockIds
|
||||
)
|
||||
batchRemoveOp.data.subBlockValues = latestSubBlockValues
|
||||
;(entry.operation as BatchAddBlocksOperation).data.subBlockValues = latestSubBlockValues
|
||||
|
||||
addToQueue({
|
||||
id: opId,
|
||||
@@ -1153,6 +1139,20 @@ export function useUndoRedo() {
|
||||
break
|
||||
}
|
||||
|
||||
const latestEdges = captureLatestEdges(
|
||||
useWorkflowStore.getState().edges,
|
||||
existingBlockIds
|
||||
)
|
||||
batchOp.data.edgeSnapshots = latestEdges
|
||||
|
||||
const latestSubBlockValues = captureLatestSubBlockValues(
|
||||
useWorkflowStore.getState().blocks,
|
||||
activeWorkflowId,
|
||||
existingBlockIds
|
||||
)
|
||||
batchOp.data.subBlockValues = latestSubBlockValues
|
||||
;(entry.inverse as BatchAddBlocksOperation).data.subBlockValues = latestSubBlockValues
|
||||
|
||||
addToQueue({
|
||||
id: opId,
|
||||
operation: {
|
||||
|
||||
@@ -29,13 +29,11 @@ export class DocsChunker {
|
||||
private readonly baseUrl: string
|
||||
|
||||
constructor(options: DocsChunkerOptions = {}) {
|
||||
// Use the existing TextChunker for chunking logic
|
||||
this.textChunker = new TextChunker({
|
||||
chunkSize: options.chunkSize ?? 300, // Max 300 tokens per chunk
|
||||
minCharactersPerChunk: options.minCharactersPerChunk ?? 1,
|
||||
chunkOverlap: options.chunkOverlap ?? 50,
|
||||
})
|
||||
// Use localhost docs in development, production docs otherwise
|
||||
this.baseUrl = options.baseUrl ?? 'https://docs.sim.ai'
|
||||
}
|
||||
|
||||
@@ -74,24 +72,18 @@ export class DocsChunker {
|
||||
const content = await fs.readFile(filePath, 'utf-8')
|
||||
const relativePath = path.relative(basePath, filePath)
|
||||
|
||||
// Parse frontmatter and content
|
||||
const { data: frontmatter, content: markdownContent } = this.parseFrontmatter(content)
|
||||
|
||||
// Extract headers from the content
|
||||
const headers = this.extractHeaders(markdownContent)
|
||||
|
||||
// Generate document URL
|
||||
const documentUrl = this.generateDocumentUrl(relativePath)
|
||||
|
||||
// Split content into chunks
|
||||
const textChunks = await this.splitContent(markdownContent)
|
||||
|
||||
// Generate embeddings for all chunks at once (batch processing)
|
||||
logger.info(`Generating embeddings for ${textChunks.length} chunks in ${relativePath}`)
|
||||
const embeddings = textChunks.length > 0 ? await generateEmbeddings(textChunks) : []
|
||||
const embeddingModel = 'text-embedding-3-small'
|
||||
|
||||
// Convert to DocChunk objects with header context and embeddings
|
||||
const chunks: DocChunk[] = []
|
||||
let currentPosition = 0
|
||||
|
||||
@@ -100,7 +92,6 @@ export class DocsChunker {
|
||||
const chunkStart = currentPosition
|
||||
const chunkEnd = currentPosition + chunkText.length
|
||||
|
||||
// Find the most relevant header for this chunk
|
||||
const relevantHeader = this.findRelevantHeader(headers, chunkStart)
|
||||
|
||||
const chunk: DocChunk = {
|
||||
@@ -186,11 +177,21 @@ export class DocsChunker {
|
||||
|
||||
/**
|
||||
* Generate document URL from relative path
|
||||
* Handles index.mdx files specially - they are served at the parent directory path
|
||||
*/
|
||||
private generateDocumentUrl(relativePath: string): string {
|
||||
// Convert file path to URL path
|
||||
// e.g., "tools/knowledge.mdx" -> "/tools/knowledge"
|
||||
const urlPath = relativePath.replace(/\.mdx$/, '').replace(/\\/g, '/') // Handle Windows paths
|
||||
// e.g., "triggers/index.mdx" -> "/triggers" (NOT "/triggers/index")
|
||||
let urlPath = relativePath.replace(/\.mdx$/, '').replace(/\\/g, '/') // Handle Windows paths
|
||||
|
||||
// In fumadocs, index.mdx files are served at the parent directory path
|
||||
// e.g., "triggers/index" -> "triggers"
|
||||
if (urlPath.endsWith('/index')) {
|
||||
urlPath = urlPath.slice(0, -6) // Remove "/index"
|
||||
} else if (urlPath === 'index') {
|
||||
urlPath = '' // Root index.mdx
|
||||
}
|
||||
|
||||
return `${this.baseUrl}/${urlPath}`
|
||||
}
|
||||
@@ -201,7 +202,6 @@ export class DocsChunker {
|
||||
private findRelevantHeader(headers: HeaderInfo[], position: number): HeaderInfo | null {
|
||||
if (headers.length === 0) return null
|
||||
|
||||
// Find the last header that comes before this position
|
||||
let relevantHeader: HeaderInfo | null = null
|
||||
|
||||
for (const header of headers) {
|
||||
@@ -219,23 +219,18 @@ export class DocsChunker {
|
||||
* Split content into chunks using the existing TextChunker with table awareness
|
||||
*/
|
||||
private async splitContent(content: string): Promise<string[]> {
|
||||
// Clean the content first
|
||||
const cleanedContent = this.cleanContent(content)
|
||||
|
||||
// Detect table boundaries to avoid splitting them
|
||||
const tableBoundaries = this.detectTableBoundaries(cleanedContent)
|
||||
|
||||
// Use the existing TextChunker
|
||||
const chunks = await this.textChunker.chunk(cleanedContent)
|
||||
|
||||
// Post-process chunks to ensure tables aren't split
|
||||
const processedChunks = this.mergeTableChunks(
|
||||
chunks.map((chunk) => chunk.text),
|
||||
tableBoundaries,
|
||||
cleanedContent
|
||||
)
|
||||
|
||||
// Ensure no chunk exceeds 300 tokens
|
||||
const finalChunks = this.enforceSizeLimit(processedChunks)
|
||||
|
||||
return finalChunks
|
||||
@@ -273,7 +268,6 @@ export class DocsChunker {
|
||||
const [, frontmatterText, markdownContent] = match
|
||||
const data: Frontmatter = {}
|
||||
|
||||
// Simple YAML parsing for title and description
|
||||
const lines = frontmatterText.split('\n')
|
||||
for (const line of lines) {
|
||||
const colonIndex = line.indexOf(':')
|
||||
@@ -294,7 +288,6 @@ export class DocsChunker {
|
||||
* Estimate token count (rough approximation)
|
||||
*/
|
||||
private estimateTokens(text: string): number {
|
||||
// Rough approximation: 1 token ≈ 4 characters
|
||||
return Math.ceil(text.length / 4)
|
||||
}
|
||||
|
||||
@@ -311,17 +304,13 @@ export class DocsChunker {
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i].trim()
|
||||
|
||||
// Detect table start (markdown table row with pipes)
|
||||
if (line.includes('|') && line.split('|').length >= 3 && !inTable) {
|
||||
// Check if next line is table separator (contains dashes and pipes)
|
||||
const nextLine = lines[i + 1]?.trim()
|
||||
if (nextLine?.includes('|') && nextLine.includes('-')) {
|
||||
inTable = true
|
||||
tableStart = i
|
||||
}
|
||||
}
|
||||
// Detect table end (empty line or non-table content)
|
||||
else if (inTable && (!line.includes('|') || line === '' || line.startsWith('#'))) {
|
||||
} else if (inTable && (!line.includes('|') || line === '' || line.startsWith('#'))) {
|
||||
tables.push({
|
||||
start: this.getCharacterPosition(lines, tableStart),
|
||||
end: this.getCharacterPosition(lines, i - 1) + lines[i - 1]?.length || 0,
|
||||
@@ -330,7 +319,6 @@ export class DocsChunker {
|
||||
}
|
||||
}
|
||||
|
||||
// Handle table at end of content
|
||||
if (inTable && tableStart >= 0) {
|
||||
tables.push({
|
||||
start: this.getCharacterPosition(lines, tableStart),
|
||||
@@ -367,7 +355,6 @@ export class DocsChunker {
|
||||
const chunkStart = originalContent.indexOf(chunk, currentPosition)
|
||||
const chunkEnd = chunkStart + chunk.length
|
||||
|
||||
// Check if this chunk intersects with any table
|
||||
const intersectsTable = tableBoundaries.some(
|
||||
(table) =>
|
||||
(chunkStart >= table.start && chunkStart <= table.end) ||
|
||||
@@ -376,7 +363,6 @@ export class DocsChunker {
|
||||
)
|
||||
|
||||
if (intersectsTable) {
|
||||
// Find which table(s) this chunk intersects with
|
||||
const affectedTables = tableBoundaries.filter(
|
||||
(table) =>
|
||||
(chunkStart >= table.start && chunkStart <= table.end) ||
|
||||
@@ -384,12 +370,10 @@ export class DocsChunker {
|
||||
(chunkStart <= table.start && chunkEnd >= table.end)
|
||||
)
|
||||
|
||||
// Create a chunk that includes the complete table(s)
|
||||
const minStart = Math.min(chunkStart, ...affectedTables.map((t) => t.start))
|
||||
const maxEnd = Math.max(chunkEnd, ...affectedTables.map((t) => t.end))
|
||||
const completeChunk = originalContent.slice(minStart, maxEnd)
|
||||
|
||||
// Only add if we haven't already included this content
|
||||
if (!mergedChunks.some((existing) => existing.includes(completeChunk.trim()))) {
|
||||
mergedChunks.push(completeChunk.trim())
|
||||
}
|
||||
@@ -400,7 +384,7 @@ export class DocsChunker {
|
||||
currentPosition = chunkEnd
|
||||
}
|
||||
|
||||
return mergedChunks.filter((chunk) => chunk.length > 50) // Filter out tiny chunks
|
||||
return mergedChunks.filter((chunk) => chunk.length > 50)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -413,10 +397,8 @@ export class DocsChunker {
|
||||
const tokens = this.estimateTokens(chunk)
|
||||
|
||||
if (tokens <= 300) {
|
||||
// Chunk is within limit
|
||||
finalChunks.push(chunk)
|
||||
} else {
|
||||
// Chunk is too large - split it
|
||||
const lines = chunk.split('\n')
|
||||
let currentChunk = ''
|
||||
|
||||
@@ -426,7 +408,6 @@ export class DocsChunker {
|
||||
if (this.estimateTokens(testChunk) <= 300) {
|
||||
currentChunk = testChunk
|
||||
} else {
|
||||
// Adding this line would exceed limit
|
||||
if (currentChunk.trim()) {
|
||||
finalChunks.push(currentChunk.trim())
|
||||
}
|
||||
@@ -434,7 +415,6 @@ export class DocsChunker {
|
||||
}
|
||||
}
|
||||
|
||||
// Add final chunk if it has content
|
||||
if (currentChunk.trim()) {
|
||||
finalChunks.push(currentChunk.trim())
|
||||
}
|
||||
|
||||
@@ -209,13 +209,17 @@ export class SetGlobalWorkflowVariablesClientTool extends BaseClientTool {
|
||||
}
|
||||
}
|
||||
|
||||
const variablesArray = Object.values(byName)
|
||||
// Convert byName (keyed by name) to record keyed by ID for the API
|
||||
const variablesRecord: Record<string, any> = {}
|
||||
for (const v of Object.values(byName)) {
|
||||
variablesRecord[v.id] = v
|
||||
}
|
||||
|
||||
// POST full variables array to persist
|
||||
// POST full variables record to persist
|
||||
const res = await fetch(`/api/workflows/${payload.workflowId}/variables`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ variables: variablesArray }),
|
||||
body: JSON.stringify({ variables: variablesRecord }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const txt = await res.text().catch(() => '')
|
||||
|
||||
@@ -817,6 +817,8 @@ function normalizeResponseFormat(value: any): string {
|
||||
interface EdgeHandleValidationResult {
|
||||
valid: boolean
|
||||
error?: string
|
||||
/** The normalized handle to use (e.g., simple 'if' normalized to 'condition-{uuid}') */
|
||||
normalizedHandle?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -851,13 +853,6 @@ function validateSourceHandleForBlock(
|
||||
}
|
||||
|
||||
case 'condition': {
|
||||
if (!sourceHandle.startsWith(EDGE.CONDITION_PREFIX)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Invalid source handle "${sourceHandle}" for condition block. Must start with "${EDGE.CONDITION_PREFIX}"`,
|
||||
}
|
||||
}
|
||||
|
||||
const conditionsValue = sourceBlock?.subBlocks?.conditions?.value
|
||||
if (!conditionsValue) {
|
||||
return {
|
||||
@@ -866,6 +861,8 @@ function validateSourceHandleForBlock(
|
||||
}
|
||||
}
|
||||
|
||||
// validateConditionHandle accepts simple format (if, else-if-0, else),
|
||||
// legacy format (condition-{blockId}-if), and internal ID format (condition-{uuid})
|
||||
return validateConditionHandle(sourceHandle, sourceBlock.id, conditionsValue)
|
||||
}
|
||||
|
||||
@@ -879,13 +876,6 @@ function validateSourceHandleForBlock(
|
||||
}
|
||||
|
||||
case 'router_v2': {
|
||||
if (!sourceHandle.startsWith(EDGE.ROUTER_PREFIX)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Invalid source handle "${sourceHandle}" for router_v2 block. Must start with "${EDGE.ROUTER_PREFIX}"`,
|
||||
}
|
||||
}
|
||||
|
||||
const routesValue = sourceBlock?.subBlocks?.routes?.value
|
||||
if (!routesValue) {
|
||||
return {
|
||||
@@ -894,6 +884,8 @@ function validateSourceHandleForBlock(
|
||||
}
|
||||
}
|
||||
|
||||
// validateRouterHandle accepts simple format (route-0, route-1),
|
||||
// legacy format (router-{blockId}-route-1), and internal ID format (router-{uuid})
|
||||
return validateRouterHandle(sourceHandle, sourceBlock.id, routesValue)
|
||||
}
|
||||
|
||||
@@ -910,7 +902,12 @@ function validateSourceHandleForBlock(
|
||||
|
||||
/**
|
||||
* Validates condition handle references a valid condition in the block.
|
||||
* Accepts both internal IDs (condition-blockId-if) and semantic keys (condition-blockId-else-if)
|
||||
* Accepts multiple formats:
|
||||
* - Simple format: "if", "else-if-0", "else-if-1", "else"
|
||||
* - Legacy semantic format: "condition-{blockId}-if", "condition-{blockId}-else-if"
|
||||
* - Internal ID format: "condition-{conditionId}"
|
||||
*
|
||||
* Returns the normalized handle (condition-{conditionId}) for storage.
|
||||
*/
|
||||
function validateConditionHandle(
|
||||
sourceHandle: string,
|
||||
@@ -943,48 +940,80 @@ function validateConditionHandle(
|
||||
}
|
||||
}
|
||||
|
||||
const validHandles = new Set<string>()
|
||||
const semanticPrefix = `condition-${blockId}-`
|
||||
let elseIfCount = 0
|
||||
// Build a map of all valid handle formats -> normalized handle (condition-{conditionId})
|
||||
const handleToNormalized = new Map<string, string>()
|
||||
const legacySemanticPrefix = `condition-${blockId}-`
|
||||
let elseIfIndex = 0
|
||||
|
||||
for (const condition of conditions) {
|
||||
if (condition.id) {
|
||||
validHandles.add(`condition-${condition.id}`)
|
||||
}
|
||||
if (!condition.id) continue
|
||||
|
||||
const normalizedHandle = `condition-${condition.id}`
|
||||
const title = condition.title?.toLowerCase()
|
||||
|
||||
// Always accept internal ID format
|
||||
handleToNormalized.set(normalizedHandle, normalizedHandle)
|
||||
|
||||
if (title === 'if') {
|
||||
// Simple format: "if"
|
||||
handleToNormalized.set('if', normalizedHandle)
|
||||
// Legacy format: "condition-{blockId}-if"
|
||||
handleToNormalized.set(`${legacySemanticPrefix}if`, normalizedHandle)
|
||||
} else if (title === 'else if') {
|
||||
// Simple format: "else-if-0", "else-if-1", etc. (0-indexed)
|
||||
handleToNormalized.set(`else-if-${elseIfIndex}`, normalizedHandle)
|
||||
// Legacy format: "condition-{blockId}-else-if" for first, "condition-{blockId}-else-if-2" for second
|
||||
if (elseIfIndex === 0) {
|
||||
handleToNormalized.set(`${legacySemanticPrefix}else-if`, normalizedHandle)
|
||||
} else {
|
||||
handleToNormalized.set(
|
||||
`${legacySemanticPrefix}else-if-${elseIfIndex + 1}`,
|
||||
normalizedHandle
|
||||
)
|
||||
}
|
||||
elseIfIndex++
|
||||
} else if (title === 'else') {
|
||||
// Simple format: "else"
|
||||
handleToNormalized.set('else', normalizedHandle)
|
||||
// Legacy format: "condition-{blockId}-else"
|
||||
handleToNormalized.set(`${legacySemanticPrefix}else`, normalizedHandle)
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedHandle = handleToNormalized.get(sourceHandle)
|
||||
if (normalizedHandle) {
|
||||
return { valid: true, normalizedHandle }
|
||||
}
|
||||
|
||||
// Build list of valid simple format options for error message
|
||||
const simpleOptions: string[] = []
|
||||
elseIfIndex = 0
|
||||
for (const condition of conditions) {
|
||||
const title = condition.title?.toLowerCase()
|
||||
if (title === 'if') {
|
||||
validHandles.add(`${semanticPrefix}if`)
|
||||
simpleOptions.push('if')
|
||||
} else if (title === 'else if') {
|
||||
elseIfCount++
|
||||
validHandles.add(
|
||||
elseIfCount === 1 ? `${semanticPrefix}else-if` : `${semanticPrefix}else-if-${elseIfCount}`
|
||||
)
|
||||
simpleOptions.push(`else-if-${elseIfIndex}`)
|
||||
elseIfIndex++
|
||||
} else if (title === 'else') {
|
||||
validHandles.add(`${semanticPrefix}else`)
|
||||
simpleOptions.push('else')
|
||||
}
|
||||
}
|
||||
|
||||
if (validHandles.has(sourceHandle)) {
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
const validOptions = Array.from(validHandles).slice(0, 5)
|
||||
const moreCount = validHandles.size - validOptions.length
|
||||
let validOptionsStr = validOptions.join(', ')
|
||||
if (moreCount > 0) {
|
||||
validOptionsStr += `, ... and ${moreCount} more`
|
||||
}
|
||||
|
||||
return {
|
||||
valid: false,
|
||||
error: `Invalid condition handle "${sourceHandle}". Valid handles: ${validOptionsStr}`,
|
||||
error: `Invalid condition handle "${sourceHandle}". Valid handles: ${simpleOptions.join(', ')}`,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates router handle references a valid route in the block.
|
||||
* Accepts both internal IDs (router-{routeId}) and semantic keys (router-{blockId}-route-1)
|
||||
* Accepts multiple formats:
|
||||
* - Simple format: "route-0", "route-1", "route-2" (0-indexed)
|
||||
* - Legacy semantic format: "router-{blockId}-route-1" (1-indexed)
|
||||
* - Internal ID format: "router-{routeId}"
|
||||
*
|
||||
* Returns the normalized handle (router-{routeId}) for storage.
|
||||
*/
|
||||
function validateRouterHandle(
|
||||
sourceHandle: string,
|
||||
@@ -1017,47 +1046,48 @@ function validateRouterHandle(
|
||||
}
|
||||
}
|
||||
|
||||
const validHandles = new Set<string>()
|
||||
const semanticPrefix = `router-${blockId}-`
|
||||
// Build a map of all valid handle formats -> normalized handle (router-{routeId})
|
||||
const handleToNormalized = new Map<string, string>()
|
||||
const legacySemanticPrefix = `router-${blockId}-`
|
||||
|
||||
for (let i = 0; i < routes.length; i++) {
|
||||
const route = routes[i]
|
||||
if (!route.id) continue
|
||||
|
||||
// Accept internal ID format: router-{uuid}
|
||||
if (route.id) {
|
||||
validHandles.add(`router-${route.id}`)
|
||||
}
|
||||
const normalizedHandle = `router-${route.id}`
|
||||
|
||||
// Accept 1-indexed route number format: router-{blockId}-route-1, router-{blockId}-route-2, etc.
|
||||
validHandles.add(`${semanticPrefix}route-${i + 1}`)
|
||||
// Always accept internal ID format: router-{uuid}
|
||||
handleToNormalized.set(normalizedHandle, normalizedHandle)
|
||||
|
||||
// Simple format: route-0, route-1, etc. (0-indexed)
|
||||
handleToNormalized.set(`route-${i}`, normalizedHandle)
|
||||
|
||||
// Legacy 1-indexed route number format: router-{blockId}-route-1
|
||||
handleToNormalized.set(`${legacySemanticPrefix}route-${i + 1}`, normalizedHandle)
|
||||
|
||||
// Accept normalized title format: router-{blockId}-{normalized-title}
|
||||
// Normalize: lowercase, replace spaces with dashes, remove special chars
|
||||
if (route.title && typeof route.title === 'string') {
|
||||
const normalizedTitle = route.title
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/[^a-z0-9-]/g, '')
|
||||
if (normalizedTitle) {
|
||||
validHandles.add(`${semanticPrefix}${normalizedTitle}`)
|
||||
handleToNormalized.set(`${legacySemanticPrefix}${normalizedTitle}`, normalizedHandle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (validHandles.has(sourceHandle)) {
|
||||
return { valid: true }
|
||||
const normalizedHandle = handleToNormalized.get(sourceHandle)
|
||||
if (normalizedHandle) {
|
||||
return { valid: true, normalizedHandle }
|
||||
}
|
||||
|
||||
const validOptions = Array.from(validHandles).slice(0, 5)
|
||||
const moreCount = validHandles.size - validOptions.length
|
||||
let validOptionsStr = validOptions.join(', ')
|
||||
if (moreCount > 0) {
|
||||
validOptionsStr += `, ... and ${moreCount} more`
|
||||
}
|
||||
// Build list of valid simple format options for error message
|
||||
const simpleOptions = routes.map((_, i) => `route-${i}`)
|
||||
|
||||
return {
|
||||
valid: false,
|
||||
error: `Invalid router handle "${sourceHandle}". Valid handles: ${validOptionsStr}`,
|
||||
error: `Invalid router handle "${sourceHandle}". Valid handles: ${simpleOptions.join(', ')}`,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1172,10 +1202,13 @@ function createValidatedEdge(
|
||||
return false
|
||||
}
|
||||
|
||||
// Use normalized handle if available (e.g., 'if' -> 'condition-{uuid}')
|
||||
const finalSourceHandle = sourceValidation.normalizedHandle || sourceHandle
|
||||
|
||||
modifiedState.edges.push({
|
||||
id: crypto.randomUUID(),
|
||||
source: sourceBlockId,
|
||||
sourceHandle,
|
||||
sourceHandle: finalSourceHandle,
|
||||
target: targetBlockId,
|
||||
targetHandle,
|
||||
type: 'default',
|
||||
@@ -1184,7 +1217,11 @@ function createValidatedEdge(
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds connections as edges for a block
|
||||
* Adds connections as edges for a block.
|
||||
* Supports multiple target formats:
|
||||
* - String: "target-block-id"
|
||||
* - Object: { block: "target-block-id", handle?: "custom-target-handle" }
|
||||
* - Array of strings or objects
|
||||
*/
|
||||
function addConnectionsAsEdges(
|
||||
modifiedState: any,
|
||||
@@ -1194,19 +1231,34 @@ function addConnectionsAsEdges(
|
||||
skippedItems?: SkippedItem[]
|
||||
): void {
|
||||
Object.entries(connections).forEach(([sourceHandle, targets]) => {
|
||||
const targetArray = Array.isArray(targets) ? targets : [targets]
|
||||
targetArray.forEach((targetId: string) => {
|
||||
if (targets === null) return
|
||||
|
||||
const addEdgeForTarget = (targetBlock: string, targetHandle?: string) => {
|
||||
createValidatedEdge(
|
||||
modifiedState,
|
||||
blockId,
|
||||
targetId,
|
||||
targetBlock,
|
||||
sourceHandle,
|
||||
'target',
|
||||
targetHandle || 'target',
|
||||
'add_edge',
|
||||
logger,
|
||||
skippedItems
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
if (typeof targets === 'string') {
|
||||
addEdgeForTarget(targets)
|
||||
} else if (Array.isArray(targets)) {
|
||||
targets.forEach((target: any) => {
|
||||
if (typeof target === 'string') {
|
||||
addEdgeForTarget(target)
|
||||
} else if (target?.block) {
|
||||
addEdgeForTarget(target.block, target.handle)
|
||||
}
|
||||
})
|
||||
} else if (typeof targets === 'object' && targets?.block) {
|
||||
addEdgeForTarget(targets.block, targets.handle)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -326,32 +326,32 @@ export const env = createEnv({
|
||||
|
||||
NEXT_PUBLIC_E2B_ENABLED: z.string().optional(),
|
||||
NEXT_PUBLIC_COPILOT_TRAINING_ENABLED: z.string().optional(),
|
||||
NEXT_PUBLIC_ENABLE_PLAYGROUND: z.string().optional(), // Enable component playground at /playground
|
||||
NEXT_PUBLIC_ENABLE_PLAYGROUND: z.string().optional(), // Enable component playground at /playground
|
||||
NEXT_PUBLIC_DOCUMENTATION_URL: z.string().url().optional(), // Custom documentation URL
|
||||
NEXT_PUBLIC_TERMS_URL: z.string().url().optional(), // Custom terms of service URL
|
||||
NEXT_PUBLIC_PRIVACY_URL: z.string().url().optional(), // Custom privacy policy URL
|
||||
|
||||
// Theme Customization
|
||||
NEXT_PUBLIC_BRAND_PRIMARY_COLOR: z.string().regex(/^#[0-9A-Fa-f]{6}$/).optional(), // Primary brand color (hex format, e.g., "#701ffc")
|
||||
NEXT_PUBLIC_BRAND_PRIMARY_HOVER_COLOR: z.string().regex(/^#[0-9A-Fa-f]{6}$/).optional(), // Primary brand hover state (hex format)
|
||||
NEXT_PUBLIC_BRAND_PRIMARY_HOVER_COLOR: z.string().regex(/^#[0-9A-Fa-f]{6}$/).optional(), // Primary brand hover state (hex format)
|
||||
NEXT_PUBLIC_BRAND_ACCENT_COLOR: z.string().regex(/^#[0-9A-Fa-f]{6}$/).optional(), // Accent brand color (hex format)
|
||||
NEXT_PUBLIC_BRAND_ACCENT_HOVER_COLOR: z.string().regex(/^#[0-9A-Fa-f]{6}$/).optional(), // Accent brand hover state (hex format)
|
||||
NEXT_PUBLIC_BRAND_BACKGROUND_COLOR: z.string().regex(/^#[0-9A-Fa-f]{6}$/).optional(), // Brand background color (hex format)
|
||||
|
||||
// Feature Flags
|
||||
NEXT_PUBLIC_TRIGGER_DEV_ENABLED: z.boolean().optional(), // Client-side gate for async executions UI
|
||||
NEXT_PUBLIC_SSO_ENABLED: z.boolean().optional(), // Enable SSO login UI components
|
||||
NEXT_PUBLIC_CREDENTIAL_SETS_ENABLED: z.boolean().optional(), // Enable credential sets (email polling) on self-hosted
|
||||
NEXT_PUBLIC_ACCESS_CONTROL_ENABLED: z.boolean().optional(), // Enable access control (permission groups) on self-hosted
|
||||
NEXT_PUBLIC_ORGANIZATIONS_ENABLED: z.boolean().optional(), // Enable organizations on self-hosted (bypasses plan requirements)
|
||||
NEXT_PUBLIC_DISABLE_INVITATIONS: z.boolean().optional(), // Disable workspace invitations globally (for self-hosted deployments)
|
||||
NEXT_PUBLIC_TRIGGER_DEV_ENABLED: z.boolean().optional(), // Client-side gate for async executions UI
|
||||
NEXT_PUBLIC_SSO_ENABLED: z.boolean().optional(), // Enable SSO login UI components
|
||||
NEXT_PUBLIC_CREDENTIAL_SETS_ENABLED: z.boolean().optional(), // Enable credential sets (email polling) on self-hosted
|
||||
NEXT_PUBLIC_ACCESS_CONTROL_ENABLED: z.boolean().optional(), // Enable access control (permission groups) on self-hosted
|
||||
NEXT_PUBLIC_ORGANIZATIONS_ENABLED: z.boolean().optional(), // Enable organizations on self-hosted (bypasses plan requirements)
|
||||
NEXT_PUBLIC_DISABLE_INVITATIONS: z.boolean().optional(), // Disable workspace invitations globally (for self-hosted deployments)
|
||||
NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED: z.boolean().optional().default(true), // Control visibility of email/password login forms
|
||||
},
|
||||
|
||||
// Variables available on both server and client
|
||||
shared: {
|
||||
NODE_ENV: z.enum(['development', 'test', 'production']).optional(), // Runtime environment
|
||||
NEXT_TELEMETRY_DISABLED: z.string().optional(), // Disable Next.js telemetry collection
|
||||
NEXT_TELEMETRY_DISABLED: z.string().optional(), // Disable Next.js telemetry collection
|
||||
},
|
||||
|
||||
experimental__runtimeEnv: {
|
||||
|
||||
193
apps/sim/lib/search/tool-operations.ts
Normal file
193
apps/sim/lib/search/tool-operations.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import type { ComponentType } from 'react'
|
||||
import { getAllBlocks } from '@/blocks'
|
||||
import type { BlockConfig, SubBlockConfig } from '@/blocks/types'
|
||||
|
||||
/**
|
||||
* Represents a searchable tool operation extracted from block configurations.
|
||||
* Each operation maps to a specific tool that can be invoked when the block
|
||||
* is configured with that operation selected.
|
||||
*/
|
||||
export interface ToolOperationItem {
|
||||
/** Unique identifier combining block type and operation ID (e.g., "slack_send") */
|
||||
id: string
|
||||
/** The block type this operation belongs to (e.g., "slack") */
|
||||
blockType: string
|
||||
/** The operation dropdown value (e.g., "send") */
|
||||
operationId: string
|
||||
/** Human-readable service name from the block (e.g., "Slack") */
|
||||
serviceName: string
|
||||
/** Human-readable operation name from the dropdown label (e.g., "Send Message") */
|
||||
operationName: string
|
||||
/** The block's icon component */
|
||||
icon: ComponentType<{ className?: string }>
|
||||
/** The block's background color */
|
||||
bgColor: string
|
||||
/** Search aliases for common synonyms */
|
||||
aliases: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps common action verbs to their synonyms for better search matching.
|
||||
* When a user searches for "post message", it should match "send message".
|
||||
* Based on analysis of 1000+ tool operations in the codebase.
|
||||
*/
|
||||
const ACTION_VERB_ALIASES: Record<string, string[]> = {
|
||||
get: ['read', 'fetch', 'retrieve', 'load', 'obtain'],
|
||||
read: ['get', 'fetch', 'retrieve', 'load'],
|
||||
create: ['make', 'new', 'add', 'generate', 'insert'],
|
||||
add: ['create', 'insert', 'append', 'include'],
|
||||
update: ['edit', 'modify', 'change', 'patch', 'set'],
|
||||
set: ['update', 'configure', 'assign'],
|
||||
delete: ['remove', 'trash', 'destroy', 'erase'],
|
||||
remove: ['delete', 'clear', 'drop', 'unset'],
|
||||
list: ['show', 'display', 'view', 'browse', 'enumerate'],
|
||||
search: ['find', 'query', 'lookup', 'locate'],
|
||||
query: ['search', 'find', 'lookup'],
|
||||
send: ['post', 'write', 'deliver', 'transmit', 'publish'],
|
||||
write: ['send', 'post', 'compose'],
|
||||
download: ['export', 'save', 'pull', 'fetch'],
|
||||
upload: ['import', 'push', 'transfer', 'attach'],
|
||||
execute: ['run', 'invoke', 'trigger', 'perform', 'start'],
|
||||
check: ['verify', 'validate', 'test', 'inspect'],
|
||||
cancel: ['abort', 'stop', 'terminate', 'revoke'],
|
||||
archive: ['store', 'backup', 'preserve'],
|
||||
copy: ['duplicate', 'clone', 'replicate'],
|
||||
move: ['transfer', 'relocate', 'migrate'],
|
||||
share: ['publish', 'distribute', 'broadcast'],
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates search aliases for an operation name by finding synonyms
|
||||
* for action verbs in the operation name.
|
||||
*/
|
||||
function generateAliases(operationName: string): string[] {
|
||||
const aliases: string[] = []
|
||||
const lowerName = operationName.toLowerCase()
|
||||
|
||||
for (const [verb, synonyms] of Object.entries(ACTION_VERB_ALIASES)) {
|
||||
if (lowerName.includes(verb)) {
|
||||
for (const synonym of synonyms) {
|
||||
aliases.push(lowerName.replace(verb, synonym))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return aliases
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the operation dropdown subblock from a block's configuration.
|
||||
* Returns null if no operation dropdown exists.
|
||||
*/
|
||||
function findOperationDropdown(block: BlockConfig): SubBlockConfig | null {
|
||||
return (
|
||||
block.subBlocks.find(
|
||||
(sb) => sb.id === 'operation' && sb.type === 'dropdown' && Array.isArray(sb.options)
|
||||
) ?? null
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the tool ID for a given operation using the block's tool config.
|
||||
* Falls back to checking tools.access if no config.tool function exists.
|
||||
*/
|
||||
function resolveToolId(block: BlockConfig, operationId: string): string | null {
|
||||
if (!block.tools) return null
|
||||
|
||||
if (block.tools.config?.tool) {
|
||||
try {
|
||||
return block.tools.config.tool({ operation: operationId })
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
if (block.tools.access?.length === 1) {
|
||||
return block.tools.access[0]
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds an index of all tool operations from the block registry.
|
||||
* This index is used by the search modal to enable operation-level discovery.
|
||||
*
|
||||
* The function iterates through all blocks that have:
|
||||
* 1. A tools.access array (indicating they use tools)
|
||||
* 2. An "operation" dropdown subblock with options
|
||||
*
|
||||
* For each operation option, it creates a ToolOperationItem that maps
|
||||
* the operation to its corresponding tool.
|
||||
*/
|
||||
export function buildToolOperationsIndex(): ToolOperationItem[] {
|
||||
const operations: ToolOperationItem[] = []
|
||||
const allBlocks = getAllBlocks()
|
||||
|
||||
for (const block of allBlocks) {
|
||||
if (!block.tools?.access?.length || block.hideFromToolbar) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (block.category !== 'tools') {
|
||||
continue
|
||||
}
|
||||
|
||||
const operationDropdown = findOperationDropdown(block)
|
||||
if (!operationDropdown) {
|
||||
continue
|
||||
}
|
||||
|
||||
const options =
|
||||
typeof operationDropdown.options === 'function'
|
||||
? operationDropdown.options()
|
||||
: operationDropdown.options
|
||||
|
||||
if (!options) continue
|
||||
|
||||
for (const option of options) {
|
||||
if (!resolveToolId(block, option.id)) continue
|
||||
|
||||
const operationName = option.label
|
||||
const aliases = generateAliases(operationName)
|
||||
|
||||
operations.push({
|
||||
id: `${block.type}_${option.id}`,
|
||||
blockType: block.type,
|
||||
operationId: option.id,
|
||||
serviceName: block.name,
|
||||
operationName,
|
||||
icon: block.icon,
|
||||
bgColor: block.bgColor,
|
||||
aliases,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return operations
|
||||
}
|
||||
|
||||
/**
|
||||
* Cached operations index to avoid rebuilding on every search.
|
||||
* The index is built lazily on first access.
|
||||
*/
|
||||
let cachedOperations: ToolOperationItem[] | null = null
|
||||
|
||||
/**
|
||||
* Returns the tool operations index, building it if necessary.
|
||||
* The index is cached after first build since block registry is static.
|
||||
*/
|
||||
export function getToolOperationsIndex(): ToolOperationItem[] {
|
||||
if (!cachedOperations) {
|
||||
cachedOperations = buildToolOperationsIndex()
|
||||
}
|
||||
return cachedOperations
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the cached operations index.
|
||||
* Useful for testing or if blocks are dynamically modified.
|
||||
*/
|
||||
export function clearToolOperationsCache(): void {
|
||||
cachedOperations = null
|
||||
}
|
||||
@@ -269,11 +269,12 @@ function sanitizeSubBlocks(
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert internal condition handle (condition-{uuid}) to semantic format (condition-{blockId}-if)
|
||||
* Convert internal condition handle (condition-{uuid}) to simple format (if, else-if-0, else)
|
||||
* Uses 0-indexed numbering for else-if conditions
|
||||
*/
|
||||
function convertConditionHandleToSemantic(
|
||||
function convertConditionHandleToSimple(
|
||||
handle: string,
|
||||
blockId: string,
|
||||
_blockId: string,
|
||||
block: BlockState
|
||||
): string {
|
||||
if (!handle.startsWith('condition-')) {
|
||||
@@ -300,27 +301,24 @@ function convertConditionHandleToSemantic(
|
||||
return handle
|
||||
}
|
||||
|
||||
// Find the condition by ID and generate semantic handle
|
||||
let elseIfCount = 0
|
||||
// Find the condition by ID and generate simple handle
|
||||
let elseIfIndex = 0
|
||||
for (const condition of conditions) {
|
||||
const title = condition.title?.toLowerCase()
|
||||
if (condition.id === conditionId) {
|
||||
if (title === 'if') {
|
||||
return `condition-${blockId}-if`
|
||||
return 'if'
|
||||
}
|
||||
if (title === 'else if') {
|
||||
elseIfCount++
|
||||
return elseIfCount === 1
|
||||
? `condition-${blockId}-else-if`
|
||||
: `condition-${blockId}-else-if-${elseIfCount}`
|
||||
return `else-if-${elseIfIndex}`
|
||||
}
|
||||
if (title === 'else') {
|
||||
return `condition-${blockId}-else`
|
||||
return 'else'
|
||||
}
|
||||
}
|
||||
// Count else-ifs as we iterate
|
||||
// Count else-ifs as we iterate (for index tracking)
|
||||
if (title === 'else if') {
|
||||
elseIfCount++
|
||||
elseIfIndex++
|
||||
}
|
||||
}
|
||||
|
||||
@@ -329,9 +327,10 @@ function convertConditionHandleToSemantic(
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert internal router handle (router-{uuid}) to semantic format (router-{blockId}-route-N)
|
||||
* Convert internal router handle (router-{uuid}) to simple format (route-0, route-1)
|
||||
* Uses 0-indexed numbering for routes
|
||||
*/
|
||||
function convertRouterHandleToSemantic(handle: string, blockId: string, block: BlockState): string {
|
||||
function convertRouterHandleToSimple(handle: string, _blockId: string, block: BlockState): string {
|
||||
if (!handle.startsWith('router-')) {
|
||||
return handle
|
||||
}
|
||||
@@ -356,10 +355,10 @@ function convertRouterHandleToSemantic(handle: string, blockId: string, block: B
|
||||
return handle
|
||||
}
|
||||
|
||||
// Find the route by ID and generate semantic handle (1-indexed)
|
||||
// Find the route by ID and generate simple handle (0-indexed)
|
||||
for (let i = 0; i < routes.length; i++) {
|
||||
if (routes[i].id === routeId) {
|
||||
return `router-${blockId}-route-${i + 1}`
|
||||
return `route-${i}`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -368,15 +367,16 @@ function convertRouterHandleToSemantic(handle: string, blockId: string, block: B
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert source handle to semantic format for condition and router blocks
|
||||
* Convert source handle to simple format for condition and router blocks
|
||||
* Outputs: if, else-if-0, else (for conditions) and route-0, route-1 (for routers)
|
||||
*/
|
||||
function convertToSemanticHandle(handle: string, blockId: string, block: BlockState): string {
|
||||
function convertToSimpleHandle(handle: string, blockId: string, block: BlockState): string {
|
||||
if (handle.startsWith('condition-') && block.type === 'condition') {
|
||||
return convertConditionHandleToSemantic(handle, blockId, block)
|
||||
return convertConditionHandleToSimple(handle, blockId, block)
|
||||
}
|
||||
|
||||
if (handle.startsWith('router-') && block.type === 'router_v2') {
|
||||
return convertRouterHandleToSemantic(handle, blockId, block)
|
||||
return convertRouterHandleToSimple(handle, blockId, block)
|
||||
}
|
||||
|
||||
return handle
|
||||
@@ -400,12 +400,12 @@ function extractConnectionsForBlock(
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Group by source handle (converting to semantic format)
|
||||
// Group by source handle (converting to simple format)
|
||||
for (const edge of outgoingEdges) {
|
||||
let handle = edge.sourceHandle || 'source'
|
||||
|
||||
// Convert internal UUID handles to semantic format
|
||||
handle = convertToSemanticHandle(handle, blockId, block)
|
||||
// Convert internal UUID handles to simple format (if, else-if-0, route-0, etc.)
|
||||
handle = convertToSimpleHandle(handle, blockId, block)
|
||||
|
||||
if (!connections[handle]) {
|
||||
connections[handle] = []
|
||||
|
||||
@@ -1736,13 +1736,8 @@ const sseHandlers: Record<string, SSEHandler> = {
|
||||
}
|
||||
},
|
||||
done: (_data, context) => {
|
||||
logger.info('[SSE] DONE EVENT RECEIVED', {
|
||||
doneEventCount: context.doneEventCount,
|
||||
data: _data,
|
||||
})
|
||||
context.doneEventCount++
|
||||
if (context.doneEventCount >= 1) {
|
||||
logger.info('[SSE] Setting streamComplete = true, stream will terminate')
|
||||
context.streamComplete = true
|
||||
}
|
||||
},
|
||||
@@ -2547,7 +2542,6 @@ export const useCopilotStore = create<CopilotStore>()(
|
||||
set({
|
||||
chats: [],
|
||||
isLoadingChats: false,
|
||||
chatsLoadedForWorkflow: workflowId,
|
||||
error: error instanceof Error ? error.message : 'Failed to load chats',
|
||||
})
|
||||
}
|
||||
|
||||
394
apps/sim/stores/undo-redo/utils.test.ts
Normal file
394
apps/sim/stores/undo-redo/utils.test.ts
Normal file
@@ -0,0 +1,394 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
import type { Edge } from 'reactflow'
|
||||
import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'
|
||||
import type { BlockState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
vi.mock('@/stores/workflows/utils', () => ({
|
||||
mergeSubblockState: vi.fn(),
|
||||
}))
|
||||
|
||||
import { mergeSubblockState } from '@/stores/workflows/utils'
|
||||
import { captureLatestEdges, captureLatestSubBlockValues } from './utils'
|
||||
|
||||
const mockMergeSubblockState = mergeSubblockState as Mock
|
||||
|
||||
describe('captureLatestEdges', () => {
|
||||
const createEdge = (id: string, source: string, target: string): Edge => ({
|
||||
id,
|
||||
source,
|
||||
target,
|
||||
})
|
||||
|
||||
it('should return edges where blockId is the source', () => {
|
||||
const edges = [
|
||||
createEdge('edge-1', 'block-1', 'block-2'),
|
||||
createEdge('edge-2', 'block-3', 'block-4'),
|
||||
]
|
||||
|
||||
const result = captureLatestEdges(edges, ['block-1'])
|
||||
|
||||
expect(result).toEqual([createEdge('edge-1', 'block-1', 'block-2')])
|
||||
})
|
||||
|
||||
it('should return edges where blockId is the target', () => {
|
||||
const edges = [
|
||||
createEdge('edge-1', 'block-1', 'block-2'),
|
||||
createEdge('edge-2', 'block-3', 'block-4'),
|
||||
]
|
||||
|
||||
const result = captureLatestEdges(edges, ['block-2'])
|
||||
|
||||
expect(result).toEqual([createEdge('edge-1', 'block-1', 'block-2')])
|
||||
})
|
||||
|
||||
it('should return edges for multiple blocks', () => {
|
||||
const edges = [
|
||||
createEdge('edge-1', 'block-1', 'block-2'),
|
||||
createEdge('edge-2', 'block-3', 'block-4'),
|
||||
createEdge('edge-3', 'block-2', 'block-5'),
|
||||
]
|
||||
|
||||
const result = captureLatestEdges(edges, ['block-1', 'block-2'])
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result).toContainEqual(createEdge('edge-1', 'block-1', 'block-2'))
|
||||
expect(result).toContainEqual(createEdge('edge-3', 'block-2', 'block-5'))
|
||||
})
|
||||
|
||||
it('should return empty array when no edges match', () => {
|
||||
const edges = [
|
||||
createEdge('edge-1', 'block-1', 'block-2'),
|
||||
createEdge('edge-2', 'block-3', 'block-4'),
|
||||
]
|
||||
|
||||
const result = captureLatestEdges(edges, ['block-99'])
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('should return empty array when blockIds is empty', () => {
|
||||
const edges = [
|
||||
createEdge('edge-1', 'block-1', 'block-2'),
|
||||
createEdge('edge-2', 'block-3', 'block-4'),
|
||||
]
|
||||
|
||||
const result = captureLatestEdges(edges, [])
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('should return edge when block has both source and target edges', () => {
|
||||
const edges = [
|
||||
createEdge('edge-1', 'block-1', 'block-2'),
|
||||
createEdge('edge-2', 'block-2', 'block-3'),
|
||||
createEdge('edge-3', 'block-4', 'block-2'),
|
||||
]
|
||||
|
||||
const result = captureLatestEdges(edges, ['block-2'])
|
||||
|
||||
expect(result).toHaveLength(3)
|
||||
expect(result).toContainEqual(createEdge('edge-1', 'block-1', 'block-2'))
|
||||
expect(result).toContainEqual(createEdge('edge-2', 'block-2', 'block-3'))
|
||||
expect(result).toContainEqual(createEdge('edge-3', 'block-4', 'block-2'))
|
||||
})
|
||||
|
||||
it('should handle empty edges array', () => {
|
||||
const result = captureLatestEdges([], ['block-1'])
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('should not duplicate edges when block appears in multiple blockIds', () => {
|
||||
const edges = [createEdge('edge-1', 'block-1', 'block-2')]
|
||||
|
||||
const result = captureLatestEdges(edges, ['block-1', 'block-2'])
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result).toContainEqual(createEdge('edge-1', 'block-1', 'block-2'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('captureLatestSubBlockValues', () => {
|
||||
const workflowId = 'wf-test'
|
||||
|
||||
const createBlockState = (
|
||||
id: string,
|
||||
subBlocks: Record<string, { id: string; type: string; value: unknown }>
|
||||
): BlockState =>
|
||||
({
|
||||
id,
|
||||
type: 'function',
|
||||
name: 'Test Block',
|
||||
position: { x: 0, y: 0 },
|
||||
subBlocks: Object.fromEntries(
|
||||
Object.entries(subBlocks).map(([subId, sb]) => [
|
||||
subId,
|
||||
{ id: sb.id, type: sb.type, value: sb.value },
|
||||
])
|
||||
),
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
}) as BlockState
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should capture single block with single subblock value', () => {
|
||||
const blocks: Record<string, BlockState> = {
|
||||
'block-1': createBlockState('block-1', {
|
||||
code: { id: 'code', type: 'code', value: 'console.log("hello")' },
|
||||
}),
|
||||
}
|
||||
|
||||
mockMergeSubblockState.mockReturnValue(blocks)
|
||||
|
||||
const result = captureLatestSubBlockValues(blocks, workflowId, ['block-1'])
|
||||
|
||||
expect(result).toEqual({
|
||||
'block-1': { code: 'console.log("hello")' },
|
||||
})
|
||||
})
|
||||
|
||||
it('should capture single block with multiple subblock values', () => {
|
||||
const blocks: Record<string, BlockState> = {
|
||||
'block-1': createBlockState('block-1', {
|
||||
code: { id: 'code', type: 'code', value: 'test code' },
|
||||
model: { id: 'model', type: 'dropdown', value: 'gpt-4' },
|
||||
temperature: { id: 'temperature', type: 'slider', value: 0.7 },
|
||||
}),
|
||||
}
|
||||
|
||||
mockMergeSubblockState.mockReturnValue(blocks)
|
||||
|
||||
const result = captureLatestSubBlockValues(blocks, workflowId, ['block-1'])
|
||||
|
||||
expect(result).toEqual({
|
||||
'block-1': {
|
||||
code: 'test code',
|
||||
model: 'gpt-4',
|
||||
temperature: 0.7,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should capture multiple blocks with values', () => {
|
||||
const blocks: Record<string, BlockState> = {
|
||||
'block-1': createBlockState('block-1', {
|
||||
code: { id: 'code', type: 'code', value: 'code 1' },
|
||||
}),
|
||||
'block-2': createBlockState('block-2', {
|
||||
prompt: { id: 'prompt', type: 'long-input', value: 'hello world' },
|
||||
}),
|
||||
}
|
||||
|
||||
mockMergeSubblockState.mockImplementation((_blocks, _wfId, blockId) => {
|
||||
if (blockId === 'block-1') return { 'block-1': blocks['block-1'] }
|
||||
if (blockId === 'block-2') return { 'block-2': blocks['block-2'] }
|
||||
return {}
|
||||
})
|
||||
|
||||
const result = captureLatestSubBlockValues(blocks, workflowId, ['block-1', 'block-2'])
|
||||
|
||||
expect(result).toEqual({
|
||||
'block-1': { code: 'code 1' },
|
||||
'block-2': { prompt: 'hello world' },
|
||||
})
|
||||
})
|
||||
|
||||
it('should skip null values', () => {
|
||||
const blocks: Record<string, BlockState> = {
|
||||
'block-1': createBlockState('block-1', {
|
||||
code: { id: 'code', type: 'code', value: 'valid code' },
|
||||
empty: { id: 'empty', type: 'short-input', value: null },
|
||||
}),
|
||||
}
|
||||
|
||||
mockMergeSubblockState.mockReturnValue(blocks)
|
||||
|
||||
const result = captureLatestSubBlockValues(blocks, workflowId, ['block-1'])
|
||||
|
||||
expect(result).toEqual({
|
||||
'block-1': { code: 'valid code' },
|
||||
})
|
||||
expect(result['block-1']).not.toHaveProperty('empty')
|
||||
})
|
||||
|
||||
it('should skip undefined values', () => {
|
||||
const blocks: Record<string, BlockState> = {
|
||||
'block-1': createBlockState('block-1', {
|
||||
code: { id: 'code', type: 'code', value: 'valid code' },
|
||||
empty: { id: 'empty', type: 'short-input', value: undefined },
|
||||
}),
|
||||
}
|
||||
|
||||
mockMergeSubblockState.mockReturnValue(blocks)
|
||||
|
||||
const result = captureLatestSubBlockValues(blocks, workflowId, ['block-1'])
|
||||
|
||||
expect(result).toEqual({
|
||||
'block-1': { code: 'valid code' },
|
||||
})
|
||||
})
|
||||
|
||||
it('should return empty object for block with no subBlocks', () => {
|
||||
const blocks: Record<string, BlockState> = {
|
||||
'block-1': {
|
||||
id: 'block-1',
|
||||
type: 'function',
|
||||
name: 'Test Block',
|
||||
position: { x: 0, y: 0 },
|
||||
subBlocks: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
} as BlockState,
|
||||
}
|
||||
|
||||
mockMergeSubblockState.mockReturnValue(blocks)
|
||||
|
||||
const result = captureLatestSubBlockValues(blocks, workflowId, ['block-1'])
|
||||
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
|
||||
it('should return empty object for non-existent blockId', () => {
|
||||
const blocks: Record<string, BlockState> = {
|
||||
'block-1': createBlockState('block-1', {
|
||||
code: { id: 'code', type: 'code', value: 'test' },
|
||||
}),
|
||||
}
|
||||
|
||||
mockMergeSubblockState.mockReturnValue({})
|
||||
|
||||
const result = captureLatestSubBlockValues(blocks, workflowId, ['non-existent'])
|
||||
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
|
||||
it('should return empty object when blockIds is empty', () => {
|
||||
const blocks: Record<string, BlockState> = {
|
||||
'block-1': createBlockState('block-1', {
|
||||
code: { id: 'code', type: 'code', value: 'test' },
|
||||
}),
|
||||
}
|
||||
|
||||
const result = captureLatestSubBlockValues(blocks, workflowId, [])
|
||||
|
||||
expect(result).toEqual({})
|
||||
expect(mockMergeSubblockState).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle various value types (string, number, array)', () => {
|
||||
const blocks: Record<string, BlockState> = {
|
||||
'block-1': createBlockState('block-1', {
|
||||
text: { id: 'text', type: 'short-input', value: 'string value' },
|
||||
number: { id: 'number', type: 'slider', value: 42 },
|
||||
array: {
|
||||
id: 'array',
|
||||
type: 'table',
|
||||
value: [
|
||||
['a', 'b'],
|
||||
['c', 'd'],
|
||||
],
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
mockMergeSubblockState.mockReturnValue(blocks)
|
||||
|
||||
const result = captureLatestSubBlockValues(blocks, workflowId, ['block-1'])
|
||||
|
||||
expect(result).toEqual({
|
||||
'block-1': {
|
||||
text: 'string value',
|
||||
number: 42,
|
||||
array: [
|
||||
['a', 'b'],
|
||||
['c', 'd'],
|
||||
],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should only capture values for blockIds in the list', () => {
|
||||
const blocks: Record<string, BlockState> = {
|
||||
'block-1': createBlockState('block-1', {
|
||||
code: { id: 'code', type: 'code', value: 'code 1' },
|
||||
}),
|
||||
'block-2': createBlockState('block-2', {
|
||||
code: { id: 'code', type: 'code', value: 'code 2' },
|
||||
}),
|
||||
'block-3': createBlockState('block-3', {
|
||||
code: { id: 'code', type: 'code', value: 'code 3' },
|
||||
}),
|
||||
}
|
||||
|
||||
mockMergeSubblockState.mockImplementation((_blocks, _wfId, blockId) => {
|
||||
if (blockId === 'block-1') return { 'block-1': blocks['block-1'] }
|
||||
if (blockId === 'block-3') return { 'block-3': blocks['block-3'] }
|
||||
return {}
|
||||
})
|
||||
|
||||
const result = captureLatestSubBlockValues(blocks, workflowId, ['block-1', 'block-3'])
|
||||
|
||||
expect(result).toEqual({
|
||||
'block-1': { code: 'code 1' },
|
||||
'block-3': { code: 'code 3' },
|
||||
})
|
||||
expect(result).not.toHaveProperty('block-2')
|
||||
})
|
||||
|
||||
it('should handle block without subBlocks property', () => {
|
||||
const blocks: Record<string, BlockState> = {
|
||||
'block-1': {
|
||||
id: 'block-1',
|
||||
type: 'function',
|
||||
name: 'Test Block',
|
||||
position: { x: 0, y: 0 },
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
} as BlockState,
|
||||
}
|
||||
|
||||
mockMergeSubblockState.mockReturnValue(blocks)
|
||||
|
||||
const result = captureLatestSubBlockValues(blocks, workflowId, ['block-1'])
|
||||
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
|
||||
it('should handle empty string values', () => {
|
||||
const blocks: Record<string, BlockState> = {
|
||||
'block-1': createBlockState('block-1', {
|
||||
code: { id: 'code', type: 'code', value: '' },
|
||||
}),
|
||||
}
|
||||
|
||||
mockMergeSubblockState.mockReturnValue(blocks)
|
||||
|
||||
const result = captureLatestSubBlockValues(blocks, workflowId, ['block-1'])
|
||||
|
||||
expect(result).toEqual({
|
||||
'block-1': { code: '' },
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle zero numeric values', () => {
|
||||
const blocks: Record<string, BlockState> = {
|
||||
'block-1': createBlockState('block-1', {
|
||||
temperature: { id: 'temperature', type: 'slider', value: 0 },
|
||||
}),
|
||||
}
|
||||
|
||||
mockMergeSubblockState.mockReturnValue(blocks)
|
||||
|
||||
const result = captureLatestSubBlockValues(blocks, workflowId, ['block-1'])
|
||||
|
||||
expect(result).toEqual({
|
||||
'block-1': { temperature: 0 },
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Edge } from 'reactflow'
|
||||
import { UNDO_REDO_OPERATIONS } from '@/socket/constants'
|
||||
import type {
|
||||
BatchAddBlocksOperation,
|
||||
@@ -9,6 +10,8 @@ import type {
|
||||
Operation,
|
||||
OperationEntry,
|
||||
} from '@/stores/undo-redo/types'
|
||||
import { mergeSubblockState } from '@/stores/workflows/utils'
|
||||
import type { BlockState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
export function createOperationEntry(operation: Operation, inverse: Operation): OperationEntry {
|
||||
return {
|
||||
@@ -170,3 +173,31 @@ export function createInverseOperation(operation: Operation): Operation {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function captureLatestEdges(edges: Edge[], blockIds: string[]): Edge[] {
|
||||
return edges.filter((e) => blockIds.includes(e.source) || blockIds.includes(e.target))
|
||||
}
|
||||
|
||||
export function captureLatestSubBlockValues(
|
||||
blocks: Record<string, BlockState>,
|
||||
workflowId: string,
|
||||
blockIds: string[]
|
||||
): Record<string, Record<string, unknown>> {
|
||||
const values: Record<string, Record<string, unknown>> = {}
|
||||
blockIds.forEach((blockId) => {
|
||||
const merged = mergeSubblockState(blocks, workflowId, blockId)
|
||||
const block = merged[blockId]
|
||||
if (block?.subBlocks) {
|
||||
const blockValues: Record<string, unknown> = {}
|
||||
Object.entries(block.subBlocks).forEach(([subBlockId, subBlock]) => {
|
||||
if (subBlock.value !== null && subBlock.value !== undefined) {
|
||||
blockValues[subBlockId] = subBlock.value
|
||||
}
|
||||
})
|
||||
if (Object.keys(blockValues).length > 0) {
|
||||
values[blockId] = blockValues
|
||||
}
|
||||
}
|
||||
})
|
||||
return values
|
||||
}
|
||||
|
||||
@@ -46,11 +46,11 @@ export const runTaskTool: ToolConfig<BrowserUseRunTaskParams, BrowserUseRunTaskR
|
||||
},
|
||||
},
|
||||
request: {
|
||||
url: 'https://api.browser-use.com/api/v1/run-task',
|
||||
url: 'https://api.browser-use.com/api/v2/tasks',
|
||||
method: 'POST',
|
||||
headers: (params) => ({
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${params.apiKey}`,
|
||||
'X-Browser-Use-API-Key': params.apiKey,
|
||||
}),
|
||||
body: (params) => {
|
||||
const requestBody: Record<string, any> = {
|
||||
@@ -121,12 +121,15 @@ export const runTaskTool: ToolConfig<BrowserUseRunTaskParams, BrowserUseRunTaskR
|
||||
let liveUrlLogged = false
|
||||
|
||||
try {
|
||||
const initialTaskResponse = await fetch(`https://api.browser-use.com/api/v1/task/${taskId}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${params.apiKey}`,
|
||||
},
|
||||
})
|
||||
const initialTaskResponse = await fetch(
|
||||
`https://api.browser-use.com/api/v2/tasks/${taskId}`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'X-Browser-Use-API-Key': params.apiKey,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if (initialTaskResponse.ok) {
|
||||
const initialTaskData = await initialTaskResponse.json()
|
||||
@@ -145,60 +148,36 @@ export const runTaskTool: ToolConfig<BrowserUseRunTaskParams, BrowserUseRunTaskR
|
||||
|
||||
while (elapsedTime < MAX_POLL_TIME_MS) {
|
||||
try {
|
||||
const statusResponse = await fetch(
|
||||
`https://api.browser-use.com/api/v1/task/${taskId}/status`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${params.apiKey}`,
|
||||
},
|
||||
}
|
||||
)
|
||||
const statusResponse = await fetch(`https://api.browser-use.com/api/v2/tasks/${taskId}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'X-Browser-Use-API-Key': params.apiKey,
|
||||
},
|
||||
})
|
||||
|
||||
if (!statusResponse.ok) {
|
||||
throw new Error(`Failed to get task status: ${statusResponse.statusText}`)
|
||||
}
|
||||
|
||||
const status = await statusResponse.json()
|
||||
const taskData = await statusResponse.json()
|
||||
const status = taskData.status
|
||||
|
||||
logger.info(`BrowserUse task ${taskId} status: ${status}`)
|
||||
|
||||
if (['finished', 'failed', 'stopped'].includes(status)) {
|
||||
const taskResponse = await fetch(`https://api.browser-use.com/api/v1/task/${taskId}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${params.apiKey}`,
|
||||
},
|
||||
})
|
||||
|
||||
if (taskResponse.ok) {
|
||||
const taskData = await taskResponse.json()
|
||||
result.output = {
|
||||
id: taskId,
|
||||
success: status === 'finished',
|
||||
output: taskData.output,
|
||||
steps: taskData.steps || [],
|
||||
}
|
||||
result.output = {
|
||||
id: taskId,
|
||||
success: status === 'finished',
|
||||
output: taskData.output ?? null,
|
||||
steps: taskData.steps || [],
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
if (!liveUrlLogged && status === 'running') {
|
||||
const taskResponse = await fetch(`https://api.browser-use.com/api/v1/task/${taskId}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${params.apiKey}`,
|
||||
},
|
||||
})
|
||||
|
||||
if (taskResponse.ok) {
|
||||
const taskData = await taskResponse.json()
|
||||
if (taskData.live_url) {
|
||||
logger.info(`BrowserUse task ${taskId} running with live URL: ${taskData.live_url}`)
|
||||
liveUrlLogged = true
|
||||
}
|
||||
}
|
||||
if (!liveUrlLogged && status === 'running' && taskData.live_url) {
|
||||
logger.info(`BrowserUse task ${taskId} running with live URL: ${taskData.live_url}`)
|
||||
liveUrlLogged = true
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS))
|
||||
|
||||
25
bun.lock
25
bun.lock
@@ -10,6 +10,7 @@
|
||||
"@octokit/rest": "^21.0.0",
|
||||
"@tailwindcss/typography": "0.5.19",
|
||||
"drizzle-kit": "^0.31.4",
|
||||
"glob": "13.0.0",
|
||||
"husky": "9.1.7",
|
||||
"lint-staged": "16.0.0",
|
||||
"turbo": "2.7.4",
|
||||
@@ -2237,7 +2238,7 @@
|
||||
|
||||
"github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
|
||||
|
||||
"glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="],
|
||||
"glob": ["glob@13.0.0", "", { "dependencies": { "minimatch": "^10.1.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA=="],
|
||||
|
||||
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
|
||||
|
||||
@@ -2539,7 +2540,7 @@
|
||||
|
||||
"loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="],
|
||||
|
||||
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||
"lru-cache": ["lru-cache@11.2.4", "", {}, "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg=="],
|
||||
|
||||
"lru.min": ["lru.min@1.1.3", "", {}, "sha512-Lkk/vx6ak3rYkRR0Nhu4lFUT2VDnQSxBe8Hbl7f36358p6ow8Bnvr8lrLt98H8J1aGxfhbX4Fs5tYg2+FTwr5Q=="],
|
||||
|
||||
@@ -2699,7 +2700,7 @@
|
||||
|
||||
"minimal-polyfills": ["minimal-polyfills@2.2.3", "", {}, "sha512-oxdmJ9cL+xV72h0xYxp4tP2d5/fTBpP45H8DIOn9pASuF8a3IYTf+25fMGDYGiWW+MFsuog6KD6nfmhZJQ+uUw=="],
|
||||
|
||||
"minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
|
||||
"minimatch": ["minimatch@10.1.1", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ=="],
|
||||
|
||||
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
|
||||
|
||||
@@ -3691,6 +3692,8 @@
|
||||
|
||||
"@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
"@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||
|
||||
"@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
"@better-auth/sso/jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="],
|
||||
@@ -3953,6 +3956,8 @@
|
||||
|
||||
"dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
||||
|
||||
"e2b/glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="],
|
||||
|
||||
"engine.io/@types/node": ["@types/node@24.2.1", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ=="],
|
||||
|
||||
"engine.io/ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="],
|
||||
@@ -3993,8 +3998,6 @@
|
||||
|
||||
"get-uri/data-uri-to-buffer": ["data-uri-to-buffer@6.0.2", "", {}, "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw=="],
|
||||
|
||||
"glob/minimatch": ["minimatch@10.1.1", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ=="],
|
||||
|
||||
"gray-matter/js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="],
|
||||
|
||||
"groq-sdk/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
|
||||
@@ -4043,8 +4046,6 @@
|
||||
|
||||
"log-update/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="],
|
||||
|
||||
"lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||
|
||||
"mammoth/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
|
||||
|
||||
"mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
|
||||
@@ -4083,8 +4084,6 @@
|
||||
|
||||
"parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
|
||||
|
||||
"path-scurry/lru-cache": ["lru-cache@11.2.4", "", {}, "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg=="],
|
||||
|
||||
"pdf-lib/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="],
|
||||
|
||||
"pino/thread-stream": ["thread-stream@3.1.0", "", { "dependencies": { "real-require": "^0.2.0" } }, "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A=="],
|
||||
@@ -4113,6 +4112,8 @@
|
||||
|
||||
"react-email/commander": ["commander@13.1.0", "", {}, "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw=="],
|
||||
|
||||
"react-email/glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="],
|
||||
|
||||
"react-promise-suspense/fast-deep-equal": ["fast-deep-equal@2.0.1", "", {}, "sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w=="],
|
||||
|
||||
"readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
|
||||
@@ -4171,6 +4172,8 @@
|
||||
|
||||
"test-exclude/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="],
|
||||
|
||||
"test-exclude/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
|
||||
|
||||
"thriftrw/long": ["long@2.4.0", "", {}, "sha512-ijUtjmO/n2A5PaosNG9ZGDsQ3vxJg7ZW8vsY8Kp0f2yIZWhSJvjmegV7t+9RPQKxKrvj8yKGehhS+po14hPLGQ=="],
|
||||
|
||||
"tsyringe/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="],
|
||||
@@ -4249,6 +4252,8 @@
|
||||
|
||||
"@aws-sdk/middleware-sdk-s3/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.969.0", "", { "dependencies": { "@smithy/types": "^4.12.0", "fast-xml-parser": "5.2.5", "tslib": "^2.6.2" } }, "sha512-BSe4Lx/qdRQQdX8cSSI7Et20vqBspzAjBy8ZmXVoyLkol3y4sXBXzn+BiLtR+oh60ExQn6o2DU4QjdOZbXaKIQ=="],
|
||||
|
||||
"@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||
|
||||
"@browserbasehq/sdk/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
|
||||
|
||||
"@browserbasehq/sdk/node-fetch/whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
|
||||
@@ -4579,6 +4584,8 @@
|
||||
|
||||
"rimraf/glob/jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="],
|
||||
|
||||
"rimraf/glob/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
|
||||
|
||||
"rimraf/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
|
||||
|
||||
"sim/@types/node/undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="],
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
"@octokit/rest": "^21.0.0",
|
||||
"@tailwindcss/typography": "0.5.19",
|
||||
"drizzle-kit": "^0.31.4",
|
||||
"glob": "13.0.0",
|
||||
"husky": "9.1.7",
|
||||
"lint-staged": "16.0.0",
|
||||
"turbo": "2.7.4"
|
||||
|
||||
Reference in New Issue
Block a user