mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-19 20:08:04 -05:00
Compare commits
28 Commits
fix/copilo
...
v0.5.61
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af82820a28 | ||
|
|
4372841797 | ||
|
|
5e8c843241 | ||
|
|
7bf3d73ee6 | ||
|
|
7ffc11a738 | ||
|
|
be578e2ed7 | ||
|
|
f415e5edc4 | ||
|
|
13a6e6c3fa | ||
|
|
f5ab7f21ae | ||
|
|
bfb6fffe38 | ||
|
|
4fbec0a43f | ||
|
|
585f5e365b | ||
|
|
3792bdd252 | ||
|
|
eb5d1f3e5b | ||
|
|
54ab82c8dd | ||
|
|
f895bf469b | ||
|
|
dd3209af06 | ||
|
|
b6ba3b50a7 | ||
|
|
b304233062 | ||
|
|
57e4b49bd6 | ||
|
|
e12dd204ed | ||
|
|
3d9d9cbc54 | ||
|
|
0f4ec962ad | ||
|
|
4827866f9a | ||
|
|
3e697d9ed9 | ||
|
|
4431a1a484 | ||
|
|
4d1a9a3f22 | ||
|
|
eb07a080fb |
@@ -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>) => {}
|
||||
```
|
||||
@@ -9,12 +9,12 @@
|
||||
<p align="center">
|
||||
<a href="https://sim.ai" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/badge/sim.ai-6F3DFA" alt="Sim.ai"></a>
|
||||
<a href="https://discord.gg/Hr4UWYEcTT" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/badge/Discord-Join%20Server-5865F2?logo=discord&logoColor=white" alt="Discord"></a>
|
||||
<a href="https://x.com/simdotai" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/twitter/follow/simdotai?style=social" alt="Twitter"></a>
|
||||
<a href="https://docs.sim.ai" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/badge/Docs-6F3DFA.svg" alt="Documentation"></a>
|
||||
<a href="https://x.com/simdotai" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/twitter/follow/simstudioai?style=social" alt="Twitter"></a>
|
||||
<a href="https://docs.sim.ai" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/badge/Docs-6F3DFA.svg" alt="Documentation"></a> <a href="https://deepwiki.com/simstudioai/sim" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/badge/DeepWiki-1E90FF.svg" alt="DeepWiki"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://deepwiki.com/simstudioai/sim" target="_blank" rel="noopener noreferrer"><img src="https://deepwiki.com/badge.svg" alt="Ask DeepWiki"></a> <a href="https://cursor.com/link/prompt?text=Help%20me%20set%20up%20Sim%20Studio%20locally.%20Follow%20these%20steps%3A%0A%0A1.%20First%2C%20verify%20Docker%20is%20installed%20and%20running%3A%0A%20%20%20docker%20--version%0A%20%20%20docker%20info%0A%0A2.%20Clone%20the%20repository%3A%0A%20%20%20git%20clone%20https%3A%2F%2Fgithub.com%2Fsimstudioai%2Fsim.git%0A%20%20%20cd%20sim%0A%0A3.%20Start%20the%20services%20with%20Docker%20Compose%3A%0A%20%20%20docker%20compose%20-f%20docker-compose.prod.yml%20up%20-d%0A%0A4.%20Wait%20for%20all%20containers%20to%20be%20healthy%20(this%20may%20take%201-2%20minutes)%3A%0A%20%20%20docker%20compose%20-f%20docker-compose.prod.yml%20ps%0A%0A5.%20Verify%20the%20app%20is%20accessible%20at%20http%3A%2F%2Flocalhost%3A3000%0A%0AIf%20there%20are%20any%20errors%2C%20help%20me%20troubleshoot%20them.%20Common%20issues%3A%0A-%20Port%203000%2C%203002%2C%20or%205432%20already%20in%20use%0A-%20Docker%20not%20running%0A-%20Insufficient%20memory%20(needs%2012GB%2B%20RAM)%0A%0AFor%20local%20AI%20models%20with%20Ollama%2C%20use%20this%20instead%20of%20step%203%3A%0A%20%20%20docker%20compose%20-f%20docker-compose.ollama.yml%20--profile%20setup%20up%20-d"><img src="https://img.shields.io/badge/Set%20Up%20with-Cursor-000000?logo=cursor&logoColor=white" alt="Set Up with Cursor"></a>
|
||||
<a href="https://cursor.com/link/prompt?text=Help%20me%20set%20up%20Sim%20Studio%20locally.%20Follow%20these%20steps%3A%0A%0A1.%20First%2C%20verify%20Docker%20is%20installed%20and%20running%3A%0A%20%20%20docker%20--version%0A%20%20%20docker%20info%0A%0A2.%20Clone%20the%20repository%3A%0A%20%20%20git%20clone%20https%3A%2F%2Fgithub.com%2Fsimstudioai%2Fsim.git%0A%20%20%20cd%20sim%0A%0A3.%20Start%20the%20services%20with%20Docker%20Compose%3A%0A%20%20%20docker%20compose%20-f%20docker-compose.prod.yml%20up%20-d%0A%0A4.%20Wait%20for%20all%20containers%20to%20be%20healthy%20(this%20may%20take%201-2%20minutes)%3A%0A%20%20%20docker%20compose%20-f%20docker-compose.prod.yml%20ps%0A%0A5.%20Verify%20the%20app%20is%20accessible%20at%20http%3A%2F%2Flocalhost%3A3000%0A%0AIf%20there%20are%20any%20errors%2C%20help%20me%20troubleshoot%20them.%20Common%20issues%3A%0A-%20Port%203000%2C%203002%2C%20or%205432%20already%20in%20use%0A-%20Docker%20not%20running%0A-%20Insufficient%20memory%20(needs%2012GB%2B%20RAM)%0A%0AFor%20local%20AI%20models%20with%20Ollama%2C%20use%20this%20instead%20of%20step%203%3A%0A%20%20%20docker%20compose%20-f%20docker-compose.ollama.yml%20--profile%20setup%20up%20-d"><img src="https://img.shields.io/badge/Set%20Up%20with-Cursor-000000?logo=cursor&logoColor=white" alt="Set Up with Cursor"></a>
|
||||
</p>
|
||||
|
||||
### Build Workflows with Ease
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -119,145 +119,6 @@ Get a specific event from Google Calendar. Returns API-aligned fields only.
|
||||
| `creator` | json | Event creator |
|
||||
| `organizer` | json | Event organizer |
|
||||
|
||||
### `google_calendar_update`
|
||||
|
||||
Update an existing event in Google Calendar. Returns API-aligned fields only.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `calendarId` | string | No | Calendar ID \(defaults to primary\) |
|
||||
| `eventId` | string | Yes | Event ID to update |
|
||||
| `summary` | string | No | New event title/summary |
|
||||
| `description` | string | No | New event description |
|
||||
| `location` | string | No | New event location |
|
||||
| `startDateTime` | string | No | New start date and time. MUST include timezone offset \(e.g., 2025-06-03T10:00:00-08:00\) OR provide timeZone parameter |
|
||||
| `endDateTime` | string | No | New end date and time. MUST include timezone offset \(e.g., 2025-06-03T11:00:00-08:00\) OR provide timeZone parameter |
|
||||
| `timeZone` | string | No | Time zone \(e.g., America/Los_Angeles\). Required if datetime does not include offset. |
|
||||
| `attendees` | array | No | Array of attendee email addresses \(replaces existing attendees\) |
|
||||
| `sendUpdates` | string | No | How to send updates to attendees: all, externalOnly, or none |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `id` | string | Event ID |
|
||||
| `htmlLink` | string | Event link |
|
||||
| `status` | string | Event status |
|
||||
| `summary` | string | Event title |
|
||||
| `description` | string | Event description |
|
||||
| `location` | string | Event location |
|
||||
| `start` | json | Event start |
|
||||
| `end` | json | Event end |
|
||||
| `attendees` | json | Event attendees |
|
||||
| `creator` | json | Event creator |
|
||||
| `organizer` | json | Event organizer |
|
||||
|
||||
### `google_calendar_delete`
|
||||
|
||||
Delete an event from Google Calendar. Returns API-aligned fields only.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `calendarId` | string | No | Calendar ID \(defaults to primary\) |
|
||||
| `eventId` | string | Yes | Event ID to delete |
|
||||
| `sendUpdates` | string | No | How to send updates to attendees: all, externalOnly, or none |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `eventId` | string | Deleted event ID |
|
||||
| `deleted` | boolean | Whether deletion was successful |
|
||||
|
||||
### `google_calendar_move`
|
||||
|
||||
Move an event to a different calendar. Returns API-aligned fields only.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `calendarId` | string | No | Source calendar ID \(defaults to primary\) |
|
||||
| `eventId` | string | Yes | Event ID to move |
|
||||
| `destinationCalendarId` | string | Yes | Destination calendar ID |
|
||||
| `sendUpdates` | string | No | How to send updates to attendees: all, externalOnly, or none |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `id` | string | Event ID |
|
||||
| `htmlLink` | string | Event link |
|
||||
| `status` | string | Event status |
|
||||
| `summary` | string | Event title |
|
||||
| `description` | string | Event description |
|
||||
| `location` | string | Event location |
|
||||
| `start` | json | Event start |
|
||||
| `end` | json | Event end |
|
||||
| `attendees` | json | Event attendees |
|
||||
| `creator` | json | Event creator |
|
||||
| `organizer` | json | Event organizer |
|
||||
|
||||
### `google_calendar_instances`
|
||||
|
||||
Get instances of a recurring event from Google Calendar. Returns API-aligned fields only.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `calendarId` | string | No | Calendar ID \(defaults to primary\) |
|
||||
| `eventId` | string | Yes | Recurring event ID to get instances of |
|
||||
| `timeMin` | string | No | Lower bound for instances \(RFC3339 timestamp, e.g., 2025-06-03T00:00:00Z\) |
|
||||
| `timeMax` | string | No | Upper bound for instances \(RFC3339 timestamp, e.g., 2025-06-04T00:00:00Z\) |
|
||||
| `maxResults` | number | No | Maximum number of instances to return \(default 250, max 2500\) |
|
||||
| `pageToken` | string | No | Token for retrieving subsequent pages of results |
|
||||
| `showDeleted` | boolean | No | Include deleted instances |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `nextPageToken` | string | Next page token |
|
||||
| `timeZone` | string | Calendar time zone |
|
||||
| `instances` | json | List of recurring event instances |
|
||||
|
||||
### `google_calendar_list_calendars`
|
||||
|
||||
List all calendars in the user
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `minAccessRole` | string | No | Minimum access role for returned calendars: freeBusyReader, reader, writer, or owner |
|
||||
| `maxResults` | number | No | Maximum number of calendars to return \(default 100, max 250\) |
|
||||
| `pageToken` | string | No | Token for retrieving subsequent pages of results |
|
||||
| `showDeleted` | boolean | No | Include deleted calendars |
|
||||
| `showHidden` | boolean | No | Include hidden calendars |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `nextPageToken` | string | Next page token |
|
||||
| `calendars` | array | List of calendars |
|
||||
| ↳ `id` | string | Calendar ID |
|
||||
| ↳ `summary` | string | Calendar title |
|
||||
| ↳ `description` | string | Calendar description |
|
||||
| ↳ `location` | string | Calendar location |
|
||||
| ↳ `timeZone` | string | Calendar time zone |
|
||||
| ↳ `accessRole` | string | Access role for the calendar |
|
||||
| ↳ `backgroundColor` | string | Calendar background color |
|
||||
| ↳ `foregroundColor` | string | Calendar foreground color |
|
||||
| ↳ `primary` | boolean | Whether this is the primary calendar |
|
||||
| ↳ `hidden` | boolean | Whether the calendar is hidden |
|
||||
| ↳ `selected` | boolean | Whether the calendar is selected |
|
||||
|
||||
### `google_calendar_quick_add`
|
||||
|
||||
Create events from natural language text. Returns API-aligned fields only.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: Google Drive
|
||||
description: Manage files, folders, and permissions
|
||||
description: Create, upload, and list files
|
||||
---
|
||||
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
@@ -40,179 +40,12 @@ In Sim, the Google Drive integration enables your agents to interact directly wi
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Integrate Google Drive into the workflow. Can create, upload, download, copy, move, delete, share files and manage permissions.
|
||||
Integrate Google Drive into the workflow. Can create, upload, and list files.
|
||||
|
||||
|
||||
|
||||
## Tools
|
||||
|
||||
### `google_drive_list`
|
||||
|
||||
List files and folders in Google Drive with complete metadata
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `folderSelector` | string | No | Select the folder to list files from |
|
||||
| `folderId` | string | No | The ID of the folder to list files from \(internal use\) |
|
||||
| `query` | string | No | Search term to filter files by name \(e.g. "budget" finds files with "budget" in the name\). Do NOT use Google Drive query syntax here - just provide a plain search term. |
|
||||
| `pageSize` | number | No | The maximum number of files to return \(default: 100\) |
|
||||
| `pageToken` | string | No | The page token to use for pagination |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `files` | array | Array of file metadata objects from Google Drive |
|
||||
| ↳ `id` | string | Google Drive file ID |
|
||||
| ↳ `kind` | string | Resource type identifier |
|
||||
| ↳ `name` | string | File name |
|
||||
| ↳ `mimeType` | string | MIME type |
|
||||
| ↳ `description` | string | File description |
|
||||
| ↳ `originalFilename` | string | Original uploaded filename |
|
||||
| ↳ `fullFileExtension` | string | Full file extension |
|
||||
| ↳ `fileExtension` | string | File extension |
|
||||
| ↳ `owners` | json | List of file owners |
|
||||
| ↳ `permissions` | json | File permissions |
|
||||
| ↳ `permissionIds` | json | Permission IDs |
|
||||
| ↳ `shared` | boolean | Whether file is shared |
|
||||
| ↳ `ownedByMe` | boolean | Whether owned by current user |
|
||||
| ↳ `writersCanShare` | boolean | Whether writers can share |
|
||||
| ↳ `viewersCanCopyContent` | boolean | Whether viewers can copy |
|
||||
| ↳ `copyRequiresWriterPermission` | boolean | Whether copy requires writer permission |
|
||||
| ↳ `sharingUser` | json | User who shared the file |
|
||||
| ↳ `starred` | boolean | Whether file is starred |
|
||||
| ↳ `trashed` | boolean | Whether file is in trash |
|
||||
| ↳ `explicitlyTrashed` | boolean | Whether explicitly trashed |
|
||||
| ↳ `appProperties` | json | App-specific properties |
|
||||
| ↳ `createdTime` | string | File creation time |
|
||||
| ↳ `modifiedTime` | string | Last modification time |
|
||||
| ↳ `modifiedByMeTime` | string | When modified by current user |
|
||||
| ↳ `viewedByMeTime` | string | When last viewed by current user |
|
||||
| ↳ `sharedWithMeTime` | string | When shared with current user |
|
||||
| ↳ `lastModifyingUser` | json | User who last modified the file |
|
||||
| ↳ `viewedByMe` | boolean | Whether viewed by current user |
|
||||
| ↳ `modifiedByMe` | boolean | Whether modified by current user |
|
||||
| ↳ `webViewLink` | string | URL to view in browser |
|
||||
| ↳ `webContentLink` | string | Direct download URL |
|
||||
| ↳ `iconLink` | string | URL to file icon |
|
||||
| ↳ `thumbnailLink` | string | URL to thumbnail |
|
||||
| ↳ `exportLinks` | json | Export format links |
|
||||
| ↳ `size` | string | File size in bytes |
|
||||
| ↳ `quotaBytesUsed` | string | Storage quota used |
|
||||
| ↳ `md5Checksum` | string | MD5 hash |
|
||||
| ↳ `sha1Checksum` | string | SHA-1 hash |
|
||||
| ↳ `sha256Checksum` | string | SHA-256 hash |
|
||||
| ↳ `parents` | json | Parent folder IDs |
|
||||
| ↳ `spaces` | json | Spaces containing file |
|
||||
| ↳ `driveId` | string | Shared drive ID |
|
||||
| ↳ `capabilities` | json | User capabilities on file |
|
||||
| ↳ `version` | string | Version number |
|
||||
| ↳ `headRevisionId` | string | Head revision ID |
|
||||
| ↳ `hasThumbnail` | boolean | Whether has thumbnail |
|
||||
| ↳ `thumbnailVersion` | string | Thumbnail version |
|
||||
| ↳ `imageMediaMetadata` | json | Image-specific metadata |
|
||||
| ↳ `videoMediaMetadata` | json | Video-specific metadata |
|
||||
| ↳ `isAppAuthorized` | boolean | Whether created by requesting app |
|
||||
| ↳ `contentRestrictions` | json | Content restrictions |
|
||||
| ↳ `linkShareMetadata` | json | Link share metadata |
|
||||
| `nextPageToken` | string | Token for fetching the next page of results |
|
||||
|
||||
### `google_drive_get_file`
|
||||
|
||||
Get metadata for a specific file in Google Drive by its ID
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `fileId` | string | Yes | The ID of the file to retrieve |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `file` | json | The file metadata |
|
||||
| ↳ `id` | string | Google Drive file ID |
|
||||
| ↳ `kind` | string | Resource type identifier |
|
||||
| ↳ `name` | string | File name |
|
||||
| ↳ `mimeType` | string | MIME type |
|
||||
| ↳ `description` | string | File description |
|
||||
| ↳ `size` | string | File size in bytes |
|
||||
| ↳ `starred` | boolean | Whether file is starred |
|
||||
| ↳ `trashed` | boolean | Whether file is in trash |
|
||||
| ↳ `webViewLink` | string | URL to view in browser |
|
||||
| ↳ `webContentLink` | string | Direct download URL |
|
||||
| ↳ `iconLink` | string | URL to file icon |
|
||||
| ↳ `thumbnailLink` | string | URL to thumbnail |
|
||||
| ↳ `parents` | json | Parent folder IDs |
|
||||
| ↳ `owners` | json | List of file owners |
|
||||
| ↳ `permissions` | json | File permissions |
|
||||
| ↳ `createdTime` | string | File creation time |
|
||||
| ↳ `modifiedTime` | string | Last modification time |
|
||||
| ↳ `lastModifyingUser` | json | User who last modified the file |
|
||||
| ↳ `shared` | boolean | Whether file is shared |
|
||||
| ↳ `ownedByMe` | boolean | Whether owned by current user |
|
||||
| ↳ `capabilities` | json | User capabilities on file |
|
||||
| ↳ `md5Checksum` | string | MD5 hash |
|
||||
| ↳ `version` | string | Version number |
|
||||
|
||||
### `google_drive_create_folder`
|
||||
|
||||
Create a new folder in Google Drive with complete metadata returned
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `fileName` | string | Yes | Name of the folder to create |
|
||||
| `folderSelector` | string | No | Select the parent folder to create the folder in |
|
||||
| `folderId` | string | No | ID of the parent folder \(internal use\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `file` | object | Complete created folder metadata from Google Drive |
|
||||
| ↳ `id` | string | Google Drive folder ID |
|
||||
| ↳ `kind` | string | Resource type identifier |
|
||||
| ↳ `name` | string | Folder name |
|
||||
| ↳ `mimeType` | string | MIME type \(application/vnd.google-apps.folder\) |
|
||||
| ↳ `description` | string | Folder description |
|
||||
| ↳ `owners` | json | List of folder owners |
|
||||
| ↳ `permissions` | json | Folder permissions |
|
||||
| ↳ `permissionIds` | json | Permission IDs |
|
||||
| ↳ `shared` | boolean | Whether folder is shared |
|
||||
| ↳ `ownedByMe` | boolean | Whether owned by current user |
|
||||
| ↳ `writersCanShare` | boolean | Whether writers can share |
|
||||
| ↳ `viewersCanCopyContent` | boolean | Whether viewers can copy |
|
||||
| ↳ `copyRequiresWriterPermission` | boolean | Whether copy requires writer permission |
|
||||
| ↳ `sharingUser` | json | User who shared the folder |
|
||||
| ↳ `starred` | boolean | Whether folder is starred |
|
||||
| ↳ `trashed` | boolean | Whether folder is in trash |
|
||||
| ↳ `explicitlyTrashed` | boolean | Whether explicitly trashed |
|
||||
| ↳ `appProperties` | json | App-specific properties |
|
||||
| ↳ `folderColorRgb` | string | Folder color |
|
||||
| ↳ `createdTime` | string | Folder creation time |
|
||||
| ↳ `modifiedTime` | string | Last modification time |
|
||||
| ↳ `modifiedByMeTime` | string | When modified by current user |
|
||||
| ↳ `viewedByMeTime` | string | When last viewed by current user |
|
||||
| ↳ `sharedWithMeTime` | string | When shared with current user |
|
||||
| ↳ `lastModifyingUser` | json | User who last modified the folder |
|
||||
| ↳ `viewedByMe` | boolean | Whether viewed by current user |
|
||||
| ↳ `modifiedByMe` | boolean | Whether modified by current user |
|
||||
| ↳ `webViewLink` | string | URL to view in browser |
|
||||
| ↳ `iconLink` | string | URL to folder icon |
|
||||
| ↳ `parents` | json | Parent folder IDs |
|
||||
| ↳ `spaces` | json | Spaces containing folder |
|
||||
| ↳ `driveId` | string | Shared drive ID |
|
||||
| ↳ `capabilities` | json | User capabilities on folder |
|
||||
| ↳ `version` | string | Version number |
|
||||
| ↳ `isAppAuthorized` | boolean | Whether created by requesting app |
|
||||
| ↳ `contentRestrictions` | json | Content restrictions |
|
||||
| ↳ `linkShareMetadata` | json | Link share metadata |
|
||||
|
||||
### `google_drive_upload`
|
||||
|
||||
Upload a file to Google Drive with complete metadata returned
|
||||
@@ -234,9 +67,9 @@ Upload a file to Google Drive with complete metadata returned
|
||||
| --------- | ---- | ----------- |
|
||||
| `file` | object | Complete uploaded file metadata from Google Drive |
|
||||
| ↳ `id` | string | Google Drive file ID |
|
||||
| ↳ `kind` | string | Resource type identifier |
|
||||
| ↳ `name` | string | File name |
|
||||
| ↳ `mimeType` | string | MIME type |
|
||||
| ↳ `kind` | string | Resource type identifier |
|
||||
| ↳ `description` | string | File description |
|
||||
| ↳ `originalFilename` | string | Original uploaded filename |
|
||||
| ↳ `fullFileExtension` | string | Full file extension |
|
||||
@@ -286,6 +119,61 @@ Upload a file to Google Drive with complete metadata returned
|
||||
| ↳ `contentRestrictions` | json | Content restrictions |
|
||||
| ↳ `linkShareMetadata` | json | Link share metadata |
|
||||
|
||||
### `google_drive_create_folder`
|
||||
|
||||
Create a new folder in Google Drive with complete metadata returned
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `fileName` | string | Yes | Name of the folder to create |
|
||||
| `folderSelector` | string | No | Select the parent folder to create the folder in |
|
||||
| `folderId` | string | No | ID of the parent folder \(internal use\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `file` | object | Complete created folder metadata from Google Drive |
|
||||
| ↳ `id` | string | Google Drive folder ID |
|
||||
| ↳ `name` | string | Folder name |
|
||||
| ↳ `mimeType` | string | MIME type \(application/vnd.google-apps.folder\) |
|
||||
| ↳ `kind` | string | Resource type identifier |
|
||||
| ↳ `description` | string | Folder description |
|
||||
| ↳ `owners` | json | List of folder owners |
|
||||
| ↳ `permissions` | json | Folder permissions |
|
||||
| ↳ `permissionIds` | json | Permission IDs |
|
||||
| ↳ `shared` | boolean | Whether folder is shared |
|
||||
| ↳ `ownedByMe` | boolean | Whether owned by current user |
|
||||
| ↳ `writersCanShare` | boolean | Whether writers can share |
|
||||
| ↳ `viewersCanCopyContent` | boolean | Whether viewers can copy |
|
||||
| ↳ `copyRequiresWriterPermission` | boolean | Whether copy requires writer permission |
|
||||
| ↳ `sharingUser` | json | User who shared the folder |
|
||||
| ↳ `starred` | boolean | Whether folder is starred |
|
||||
| ↳ `trashed` | boolean | Whether folder is in trash |
|
||||
| ↳ `explicitlyTrashed` | boolean | Whether explicitly trashed |
|
||||
| ↳ `appProperties` | json | App-specific properties |
|
||||
| ↳ `folderColorRgb` | string | Folder color |
|
||||
| ↳ `createdTime` | string | Folder creation time |
|
||||
| ↳ `modifiedTime` | string | Last modification time |
|
||||
| ↳ `modifiedByMeTime` | string | When modified by current user |
|
||||
| ↳ `viewedByMeTime` | string | When last viewed by current user |
|
||||
| ↳ `sharedWithMeTime` | string | When shared with current user |
|
||||
| ↳ `lastModifyingUser` | json | User who last modified the folder |
|
||||
| ↳ `viewedByMe` | boolean | Whether viewed by current user |
|
||||
| ↳ `modifiedByMe` | boolean | Whether modified by current user |
|
||||
| ↳ `webViewLink` | string | URL to view in browser |
|
||||
| ↳ `iconLink` | string | URL to folder icon |
|
||||
| ↳ `parents` | json | Parent folder IDs |
|
||||
| ↳ `spaces` | json | Spaces containing folder |
|
||||
| ↳ `driveId` | string | Shared drive ID |
|
||||
| ↳ `capabilities` | json | User capabilities on folder |
|
||||
| ↳ `version` | string | Version number |
|
||||
| ↳ `isAppAuthorized` | boolean | Whether created by requesting app |
|
||||
| ↳ `contentRestrictions` | json | Content restrictions |
|
||||
| ↳ `linkShareMetadata` | json | Link share metadata |
|
||||
|
||||
### `google_drive_download`
|
||||
|
||||
Download a file from Google Drive with complete metadata (exports Google Workspace files automatically)
|
||||
@@ -310,9 +198,9 @@ Download a file from Google Drive with complete metadata (exports Google Workspa
|
||||
| ↳ `size` | number | File size in bytes |
|
||||
| `metadata` | object | Complete file metadata from Google Drive |
|
||||
| ↳ `id` | string | Google Drive file ID |
|
||||
| ↳ `kind` | string | Resource type identifier |
|
||||
| ↳ `name` | string | File name |
|
||||
| ↳ `mimeType` | string | MIME type |
|
||||
| ↳ `kind` | string | Resource type identifier |
|
||||
| ↳ `description` | string | File description |
|
||||
| ↳ `originalFilename` | string | Original uploaded filename |
|
||||
| ↳ `fullFileExtension` | string | Full file extension |
|
||||
@@ -363,211 +251,77 @@ Download a file from Google Drive with complete metadata (exports Google Workspa
|
||||
| ↳ `linkShareMetadata` | json | Link share metadata |
|
||||
| ↳ `revisions` | json | File revision history \(first 100 revisions only\) |
|
||||
|
||||
### `google_drive_copy`
|
||||
### `google_drive_list`
|
||||
|
||||
Create a copy of a file in Google Drive
|
||||
List files and folders in Google Drive with complete metadata
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `fileId` | string | Yes | The ID of the file to copy |
|
||||
| `newName` | string | No | Name for the copied file \(defaults to "Copy of \[original name\]"\) |
|
||||
| `destinationFolderId` | string | No | ID of the folder to place the copy in \(defaults to same location as original\) |
|
||||
| `folderSelector` | string | No | Select the folder to list files from |
|
||||
| `folderId` | string | No | The ID of the folder to list files from \(internal use\) |
|
||||
| `query` | string | No | Search term to filter files by name \(e.g. "budget" finds files with "budget" in the name\). Do NOT use Google Drive query syntax here - just provide a plain search term. |
|
||||
| `pageSize` | number | No | The maximum number of files to return \(default: 100\) |
|
||||
| `pageToken` | string | No | The page token to use for pagination |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `file` | json | The copied file metadata |
|
||||
| ↳ `id` | string | Google Drive file ID of the copy |
|
||||
| ↳ `kind` | string | Resource type identifier |
|
||||
| ↳ `name` | string | File name |
|
||||
| ↳ `mimeType` | string | MIME type |
|
||||
| ↳ `webViewLink` | string | URL to view in browser |
|
||||
| ↳ `parents` | json | Parent folder IDs |
|
||||
| ↳ `createdTime` | string | File creation time |
|
||||
| ↳ `modifiedTime` | string | Last modification time |
|
||||
| ↳ `owners` | json | List of file owners |
|
||||
| ↳ `size` | string | File size in bytes |
|
||||
|
||||
### `google_drive_update`
|
||||
|
||||
Update file metadata in Google Drive (rename, move, star, add description)
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `fileId` | string | Yes | The ID of the file to update |
|
||||
| `name` | string | No | New name for the file |
|
||||
| `description` | string | No | New description for the file |
|
||||
| `addParents` | string | No | Comma-separated list of parent folder IDs to add \(moves file to these folders\) |
|
||||
| `removeParents` | string | No | Comma-separated list of parent folder IDs to remove |
|
||||
| `starred` | boolean | No | Whether to star or unstar the file |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `file` | json | The updated file metadata |
|
||||
| ↳ `id` | string | Google Drive file ID |
|
||||
| ↳ `kind` | string | Resource type identifier |
|
||||
| ↳ `name` | string | File name |
|
||||
| ↳ `mimeType` | string | MIME type |
|
||||
| ↳ `description` | string | File description |
|
||||
| ↳ `starred` | boolean | Whether file is starred |
|
||||
| ↳ `webViewLink` | string | URL to view in browser |
|
||||
| ↳ `parents` | json | Parent folder IDs |
|
||||
| ↳ `modifiedTime` | string | Last modification time |
|
||||
|
||||
### `google_drive_trash`
|
||||
|
||||
Move a file to the trash in Google Drive (can be restored later)
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `fileId` | string | Yes | The ID of the file to move to trash |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `file` | json | The trashed file metadata |
|
||||
| ↳ `id` | string | Google Drive file ID |
|
||||
| ↳ `kind` | string | Resource type identifier |
|
||||
| ↳ `name` | string | File name |
|
||||
| ↳ `mimeType` | string | MIME type |
|
||||
| ↳ `trashed` | boolean | Whether file is in trash \(should be true\) |
|
||||
| ↳ `trashedTime` | string | When file was trashed |
|
||||
| ↳ `webViewLink` | string | URL to view in browser |
|
||||
|
||||
### `google_drive_delete`
|
||||
|
||||
Permanently delete a file from Google Drive (bypasses trash)
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `fileId` | string | Yes | The ID of the file to permanently delete |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `deleted` | boolean | Whether the file was successfully deleted |
|
||||
| `fileId` | string | The ID of the deleted file |
|
||||
|
||||
### `google_drive_share`
|
||||
|
||||
Share a file with a user, group, domain, or make it public
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `fileId` | string | Yes | The ID of the file to share |
|
||||
| `type` | string | Yes | Type of grantee: user, group, domain, or anyone |
|
||||
| `role` | string | Yes | Permission role: owner \(transfer ownership\), organizer \(shared drive only\), fileOrganizer \(shared drive only\), writer \(edit\), commenter \(view and comment\), reader \(view only\) |
|
||||
| `email` | string | No | Email address of the user or group \(required for type=user or type=group\) |
|
||||
| `domain` | string | No | Domain to share with \(required for type=domain\) |
|
||||
| `transferOwnership` | boolean | No | Required when role is owner. Transfers ownership to the specified user. |
|
||||
| `moveToNewOwnersRoot` | boolean | No | When transferring ownership, move the file to the new owner's My Drive root folder. |
|
||||
| `sendNotification` | boolean | No | Whether to send an email notification \(default: true\) |
|
||||
| `emailMessage` | string | No | Custom message to include in the notification email |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `permission` | json | The created permission details |
|
||||
| ↳ `id` | string | Permission ID |
|
||||
| ↳ `type` | string | Grantee type \(user, group, domain, anyone\) |
|
||||
| ↳ `role` | string | Permission role |
|
||||
| ↳ `emailAddress` | string | Email of the grantee |
|
||||
| ↳ `displayName` | string | Display name of the grantee |
|
||||
| ↳ `domain` | string | Domain of the grantee |
|
||||
| ↳ `expirationTime` | string | Expiration time |
|
||||
| ↳ `deleted` | boolean | Whether grantee is deleted |
|
||||
|
||||
### `google_drive_unshare`
|
||||
|
||||
Remove a permission from a file (revoke access)
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `fileId` | string | Yes | The ID of the file to modify permissions on |
|
||||
| `permissionId` | string | Yes | The ID of the permission to remove \(use list_permissions to find this\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `removed` | boolean | Whether the permission was successfully removed |
|
||||
| `fileId` | string | The ID of the file |
|
||||
| `permissionId` | string | The ID of the removed permission |
|
||||
|
||||
### `google_drive_list_permissions`
|
||||
|
||||
List all permissions (who has access) for a file in Google Drive
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `fileId` | string | Yes | The ID of the file to list permissions for |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `permissions` | array | List of permissions on the file |
|
||||
| ↳ `id` | string | Permission ID \(use to remove permission\) |
|
||||
| ↳ `type` | string | Grantee type \(user, group, domain, anyone\) |
|
||||
| ↳ `role` | string | Permission role \(owner, organizer, fileOrganizer, writer, commenter, reader\) |
|
||||
| ↳ `emailAddress` | string | Email of the grantee |
|
||||
| ↳ `displayName` | string | Display name of the grantee |
|
||||
| ↳ `photoLink` | string | Photo URL of the grantee |
|
||||
| ↳ `domain` | string | Domain of the grantee |
|
||||
| ↳ `expirationTime` | string | When permission expires |
|
||||
| ↳ `deleted` | boolean | Whether grantee account is deleted |
|
||||
| ↳ `allowFileDiscovery` | boolean | Whether file is discoverable by grantee |
|
||||
| ↳ `pendingOwner` | boolean | Whether ownership transfer is pending |
|
||||
| ↳ `permissionDetails` | json | Details about inherited permissions |
|
||||
| `nextPageToken` | string | Token for fetching the next page of permissions |
|
||||
|
||||
### `google_drive_get_about`
|
||||
|
||||
Get information about the user and their Google Drive (storage quota, capabilities)
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `user` | json | Information about the authenticated user |
|
||||
| ↳ `displayName` | string | User display name |
|
||||
| ↳ `emailAddress` | string | User email address |
|
||||
| ↳ `photoLink` | string | URL to user profile photo |
|
||||
| ↳ `permissionId` | string | User permission ID |
|
||||
| ↳ `me` | boolean | Whether this is the authenticated user |
|
||||
| `storageQuota` | json | Storage quota information in bytes |
|
||||
| ↳ `limit` | string | Total storage limit in bytes \(null for unlimited\) |
|
||||
| ↳ `usage` | string | Total storage used in bytes |
|
||||
| ↳ `usageInDrive` | string | Storage used by Drive files in bytes |
|
||||
| ↳ `usageInDriveTrash` | string | Storage used by trashed files in bytes |
|
||||
| `canCreateDrives` | boolean | Whether user can create shared drives |
|
||||
| `importFormats` | json | Map of MIME types that can be imported and their target formats |
|
||||
| `exportFormats` | json | Map of Google Workspace MIME types and their exportable formats |
|
||||
| `maxUploadSize` | string | Maximum upload size in bytes |
|
||||
| `files` | array | Array of file metadata objects from Google Drive |
|
||||
| ↳ `id` | string | Google Drive file ID |
|
||||
| ↳ `name` | string | File name |
|
||||
| ↳ `mimeType` | string | MIME type |
|
||||
| ↳ `kind` | string | Resource type identifier |
|
||||
| ↳ `description` | string | File description |
|
||||
| ↳ `originalFilename` | string | Original uploaded filename |
|
||||
| ↳ `fullFileExtension` | string | Full file extension |
|
||||
| ↳ `fileExtension` | string | File extension |
|
||||
| ↳ `owners` | json | List of file owners |
|
||||
| ↳ `permissions` | json | File permissions |
|
||||
| ↳ `permissionIds` | json | Permission IDs |
|
||||
| ↳ `shared` | boolean | Whether file is shared |
|
||||
| ↳ `ownedByMe` | boolean | Whether owned by current user |
|
||||
| ↳ `writersCanShare` | boolean | Whether writers can share |
|
||||
| ↳ `viewersCanCopyContent` | boolean | Whether viewers can copy |
|
||||
| ↳ `copyRequiresWriterPermission` | boolean | Whether copy requires writer permission |
|
||||
| ↳ `sharingUser` | json | User who shared the file |
|
||||
| ↳ `starred` | boolean | Whether file is starred |
|
||||
| ↳ `trashed` | boolean | Whether file is in trash |
|
||||
| ↳ `explicitlyTrashed` | boolean | Whether explicitly trashed |
|
||||
| ↳ `appProperties` | json | App-specific properties |
|
||||
| ↳ `createdTime` | string | File creation time |
|
||||
| ↳ `modifiedTime` | string | Last modification time |
|
||||
| ↳ `modifiedByMeTime` | string | When modified by current user |
|
||||
| ↳ `viewedByMeTime` | string | When last viewed by current user |
|
||||
| ↳ `sharedWithMeTime` | string | When shared with current user |
|
||||
| ↳ `lastModifyingUser` | json | User who last modified the file |
|
||||
| ↳ `viewedByMe` | boolean | Whether viewed by current user |
|
||||
| ↳ `modifiedByMe` | boolean | Whether modified by current user |
|
||||
| ↳ `webViewLink` | string | URL to view in browser |
|
||||
| ↳ `webContentLink` | string | Direct download URL |
|
||||
| ↳ `iconLink` | string | URL to file icon |
|
||||
| ↳ `thumbnailLink` | string | URL to thumbnail |
|
||||
| ↳ `exportLinks` | json | Export format links |
|
||||
| ↳ `size` | string | File size in bytes |
|
||||
| ↳ `quotaBytesUsed` | string | Storage quota used |
|
||||
| ↳ `md5Checksum` | string | MD5 hash |
|
||||
| ↳ `sha1Checksum` | string | SHA-1 hash |
|
||||
| ↳ `sha256Checksum` | string | SHA-256 hash |
|
||||
| ↳ `parents` | json | Parent folder IDs |
|
||||
| ↳ `spaces` | json | Spaces containing file |
|
||||
| ↳ `driveId` | string | Shared drive ID |
|
||||
| ↳ `capabilities` | json | User capabilities on file |
|
||||
| ↳ `version` | string | Version number |
|
||||
| ↳ `headRevisionId` | string | Head revision ID |
|
||||
| ↳ `hasThumbnail` | boolean | Whether has thumbnail |
|
||||
| ↳ `thumbnailVersion` | string | Thumbnail version |
|
||||
| ↳ `imageMediaMetadata` | json | Image-specific metadata |
|
||||
| ↳ `videoMediaMetadata` | json | Video-specific metadata |
|
||||
| ↳ `isAppAuthorized` | boolean | Whether created by requesting app |
|
||||
| ↳ `contentRestrictions` | json | Content restrictions |
|
||||
| ↳ `linkShareMetadata` | json | Link share metadata |
|
||||
| `nextPageToken` | string | Token for fetching the next page of results |
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: Google Forms
|
||||
description: Manage Google Forms and responses
|
||||
description: Read responses from a Google Form
|
||||
---
|
||||
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
@@ -29,7 +29,7 @@ In Sim, the Google Forms integration enables your agents to programmatically acc
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Integrate Google Forms into your workflow. Read form structure, get responses, create forms, update content, and manage notification watches.
|
||||
Integrate Google Forms into your workflow. Provide a Form ID to list responses, or specify a Response ID to fetch a single response. Requires OAuth.
|
||||
|
||||
|
||||
|
||||
@@ -37,246 +37,15 @@ Integrate Google Forms into your workflow. Read form structure, get responses, c
|
||||
|
||||
### `google_forms_get_responses`
|
||||
|
||||
Retrieve a single response or list responses from a Google Form
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `formId` | string | Yes | The ID of the Google Form |
|
||||
| `responseId` | string | No | If provided, returns this specific response |
|
||||
| `pageSize` | number | No | Maximum number of responses to return \(service may return fewer\). Defaults to 5000. |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `responses` | array | Array of form responses \(when no responseId provided\) |
|
||||
| ↳ `responseId` | string | Unique response ID |
|
||||
| ↳ `createTime` | string | When the response was created |
|
||||
| ↳ `lastSubmittedTime` | string | When the response was last submitted |
|
||||
| ↳ `answers` | json | Map of question IDs to answer values |
|
||||
| `response` | object | Single form response \(when responseId is provided\) |
|
||||
| ↳ `responseId` | string | Unique response ID |
|
||||
| ↳ `createTime` | string | When the response was created |
|
||||
| ↳ `lastSubmittedTime` | string | When the response was last submitted |
|
||||
| ↳ `answers` | json | Map of question IDs to answer values |
|
||||
| `raw` | json | Raw API response data |
|
||||
|
||||
### `google_forms_get_form`
|
||||
|
||||
Retrieve a form structure including its items, settings, and metadata
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `formId` | string | Yes | The ID of the Google Form to retrieve |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `formId` | string | The form ID |
|
||||
| `title` | string | The form title visible to responders |
|
||||
| `description` | string | The form description |
|
||||
| `documentTitle` | string | The document title visible in Drive |
|
||||
| `responderUri` | string | The URI to share with responders |
|
||||
| `linkedSheetId` | string | The ID of the linked Google Sheet |
|
||||
| `revisionId` | string | The revision ID of the form |
|
||||
| `items` | array | The form items \(questions, sections, etc.\) |
|
||||
| ↳ `itemId` | string | Item ID |
|
||||
| ↳ `title` | string | Item title |
|
||||
| ↳ `description` | string | Item description |
|
||||
| `settings` | json | Form settings |
|
||||
| `publishSettings` | json | Form publish settings |
|
||||
|
||||
### `google_forms_create_form`
|
||||
|
||||
Create a new Google Form with a title
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `title` | string | Yes | The title of the form visible to responders |
|
||||
| `documentTitle` | string | No | The document title visible in Drive \(defaults to form title\) |
|
||||
| `unpublished` | boolean | No | If true, create an unpublished form that does not accept responses |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `formId` | string | The ID of the created form |
|
||||
| `title` | string | The form title |
|
||||
| `documentTitle` | string | The document title in Drive |
|
||||
| `responderUri` | string | The URI to share with responders |
|
||||
| `revisionId` | string | The revision ID of the form |
|
||||
|
||||
### `google_forms_batch_update`
|
||||
|
||||
Apply multiple updates to a form (add items, update info, change settings, etc.)
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `formId` | string | Yes | The ID of the Google Form to update |
|
||||
| `requests` | json | Yes | Array of update requests \(updateFormInfo, updateSettings, createItem, updateItem, moveItem, deleteItem\) |
|
||||
| `includeFormInResponse` | boolean | No | Whether to return the updated form in the response |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `replies` | array | The replies from each update request |
|
||||
| `writeControl` | object | Write control information with revision IDs |
|
||||
| ↳ `requiredRevisionId` | string | Required revision ID for conflict detection |
|
||||
| ↳ `targetRevisionId` | string | Target revision ID |
|
||||
| `form` | object | The updated form \(if includeFormInResponse was true\) |
|
||||
| ↳ `formId` | string | The form ID |
|
||||
| ↳ `info` | object | Form info containing title and description |
|
||||
| ↳ `title` | string | The form title visible to responders |
|
||||
| ↳ `description` | string | The form description |
|
||||
| ↳ `documentTitle` | string | The document title visible in Drive |
|
||||
| ↳ `title` | string | Item title |
|
||||
| ↳ `description` | string | Item description |
|
||||
| ↳ `documentTitle` | string | The document title visible in Drive |
|
||||
| ↳ `settings` | object | Form settings |
|
||||
| ↳ `quizSettings` | object | Quiz settings |
|
||||
| ↳ `isQuiz` | boolean | Whether the form is a quiz |
|
||||
| ↳ `isQuiz` | boolean | Whether the form is a quiz |
|
||||
| ↳ `emailCollectionType` | string | Email collection type |
|
||||
| ↳ `quizSettings` | object | Quiz settings |
|
||||
| ↳ `isQuiz` | boolean | Whether the form is a quiz |
|
||||
| ↳ `isQuiz` | boolean | Whether the form is a quiz |
|
||||
| ↳ `emailCollectionType` | string | Email collection type |
|
||||
| ↳ `itemId` | string | Item ID |
|
||||
| ↳ `questionItem` | json | Question item configuration |
|
||||
| ↳ `questionGroupItem` | json | Question group configuration |
|
||||
| ↳ `pageBreakItem` | json | Page break configuration |
|
||||
| ↳ `textItem` | json | Text item configuration |
|
||||
| ↳ `imageItem` | json | Image item configuration |
|
||||
| ↳ `videoItem` | json | Video item configuration |
|
||||
| ↳ `revisionId` | string | The revision ID of the form |
|
||||
| ↳ `responderUri` | string | The URI to share with responders |
|
||||
| ↳ `linkedSheetId` | string | The ID of the linked Google Sheet |
|
||||
| ↳ `publishSettings` | object | Form publish settings |
|
||||
| ↳ `publishState` | object | Current publish state |
|
||||
| ↳ `isPublished` | boolean | Whether the form is published |
|
||||
| ↳ `isAcceptingResponses` | boolean | Whether the form is accepting responses |
|
||||
| ↳ `isPublished` | boolean | Whether the form is published |
|
||||
| ↳ `isAcceptingResponses` | boolean | Whether the form is accepting responses |
|
||||
| ↳ `publishState` | object | Current publish state |
|
||||
| ↳ `isPublished` | boolean | Whether the form is published |
|
||||
| ↳ `isAcceptingResponses` | boolean | Whether the form is accepting responses |
|
||||
| ↳ `isPublished` | boolean | Whether the form is published |
|
||||
| ↳ `isAcceptingResponses` | boolean | Whether the form is accepting responses |
|
||||
|
||||
### `google_forms_set_publish_settings`
|
||||
|
||||
Update the publish settings of a form (publish/unpublish, accept responses)
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `formId` | string | Yes | The ID of the Google Form |
|
||||
| `isPublished` | boolean | Yes | Whether the form is published and visible to others |
|
||||
| `isAcceptingResponses` | boolean | No | Whether the form accepts responses \(forced to false if isPublished is false\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `formId` | string | The form ID |
|
||||
| `publishSettings` | json | The updated publish settings |
|
||||
| ↳ `publishState` | object | The publish state |
|
||||
| ↳ `isPublished` | boolean | Whether the form is published |
|
||||
| ↳ `isAcceptingResponses` | boolean | Whether the form accepts responses |
|
||||
| ↳ `isPublished` | boolean | Whether the form is published |
|
||||
| ↳ `isAcceptingResponses` | boolean | Whether the form accepts responses |
|
||||
|
||||
### `google_forms_create_watch`
|
||||
|
||||
Create a notification watch for form changes (schema changes or new responses)
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `formId` | string | Yes | The ID of the Google Form to watch |
|
||||
| `eventType` | string | Yes | Event type to watch: SCHEMA \(form changes\) or RESPONSES \(new submissions\) |
|
||||
| `topicName` | string | Yes | The Cloud Pub/Sub topic name \(format: projects/\{project\}/topics/\{topic\}\) |
|
||||
| `watchId` | string | No | Custom watch ID \(4-63 chars, lowercase letters, numbers, hyphens\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `id` | string | The watch ID |
|
||||
| `eventType` | string | The event type being watched |
|
||||
| `topicName` | string | The Cloud Pub/Sub topic |
|
||||
| `createTime` | string | When the watch was created |
|
||||
| `expireTime` | string | When the watch expires \(7 days after creation\) |
|
||||
| `state` | string | The watch state \(ACTIVE, SUSPENDED\) |
|
||||
|
||||
### `google_forms_list_watches`
|
||||
|
||||
List all notification watches for a form
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `formId` | string | Yes | The ID of the Google Form |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `watches` | array | List of watches for the form |
|
||||
| ↳ `id` | string | Watch ID |
|
||||
| ↳ `eventType` | string | Event type \(SCHEMA or RESPONSES\) |
|
||||
| ↳ `createTime` | string | When the watch was created |
|
||||
| ↳ `expireTime` | string | When the watch expires |
|
||||
| ↳ `state` | string | Watch state |
|
||||
|
||||
### `google_forms_delete_watch`
|
||||
|
||||
Delete a notification watch from a form
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `formId` | string | Yes | The ID of the Google Form |
|
||||
| `watchId` | string | Yes | The ID of the watch to delete |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `deleted` | boolean | Whether the watch was successfully deleted |
|
||||
|
||||
### `google_forms_renew_watch`
|
||||
|
||||
Renew a notification watch for another 7 days
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `formId` | string | Yes | The ID of the Google Form |
|
||||
| `watchId` | string | Yes | The ID of the watch to renew |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `id` | string | The watch ID |
|
||||
| `eventType` | string | The event type being watched |
|
||||
| `expireTime` | string | The new expiration time |
|
||||
| `state` | string | The watch state |
|
||||
| `data` | json | Response or list of responses |
|
||||
|
||||
|
||||
|
||||
@@ -215,191 +215,4 @@ Check if a user is a member of a Google Group
|
||||
| --------- | ---- | ----------- |
|
||||
| `isMember` | boolean | Whether the user is a member of the group |
|
||||
|
||||
### `google_groups_list_aliases`
|
||||
|
||||
List all email aliases for a Google Group
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `groupKey` | string | Yes | Group email address or unique group ID |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `aliases` | array | List of email aliases for the group |
|
||||
| ↳ `id` | string | Unique group identifier |
|
||||
| ↳ `primaryEmail` | string | Group |
|
||||
| ↳ `alias` | string | Alias email address |
|
||||
| ↳ `kind` | string | API resource type |
|
||||
| ↳ `etag` | string | Resource version identifier |
|
||||
|
||||
### `google_groups_add_alias`
|
||||
|
||||
Add an email alias to a Google Group
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `groupKey` | string | Yes | Group email address or unique group ID |
|
||||
| `alias` | string | Yes | The email alias to add to the group |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `id` | string | Unique group identifier |
|
||||
| `primaryEmail` | string | Group |
|
||||
| `alias` | string | The alias that was added |
|
||||
| `kind` | string | API resource type |
|
||||
| `etag` | string | Resource version identifier |
|
||||
|
||||
### `google_groups_remove_alias`
|
||||
|
||||
Remove an email alias from a Google Group
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `groupKey` | string | Yes | Group email address or unique group ID |
|
||||
| `alias` | string | Yes | The email alias to remove from the group |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `deleted` | boolean | Whether the alias was successfully deleted |
|
||||
|
||||
### `google_groups_get_settings`
|
||||
|
||||
Get the settings for a Google Group including access permissions, moderation, and posting options
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `groupEmail` | string | Yes | The email address of the group |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `email` | string | The group |
|
||||
| `name` | string | The group name \(max 75 characters\) |
|
||||
| `description` | string | The group description \(max 4096 characters\) |
|
||||
| `whoCanJoin` | string | Who can join the group \(ANYONE_CAN_JOIN, ALL_IN_DOMAIN_CAN_JOIN, INVITED_CAN_JOIN, CAN_REQUEST_TO_JOIN\) |
|
||||
| `whoCanViewMembership` | string | Who can view group membership |
|
||||
| `whoCanViewGroup` | string | Who can view group messages |
|
||||
| `whoCanPostMessage` | string | Who can post messages to the group |
|
||||
| `allowExternalMembers` | string | Whether external users can be members |
|
||||
| `allowWebPosting` | string | Whether web posting is allowed |
|
||||
| `primaryLanguage` | string | The group |
|
||||
| `isArchived` | string | Whether messages are archived |
|
||||
| `archiveOnly` | string | Whether the group is archive-only \(inactive\) |
|
||||
| `messageModerationLevel` | string | Message moderation level |
|
||||
| `spamModerationLevel` | string | Spam handling level \(ALLOW, MODERATE, SILENTLY_MODERATE, REJECT\) |
|
||||
| `replyTo` | string | Default reply destination |
|
||||
| `customReplyTo` | string | Custom email for replies |
|
||||
| `includeCustomFooter` | string | Whether to include custom footer |
|
||||
| `customFooterText` | string | Custom footer text \(max 1000 characters\) |
|
||||
| `sendMessageDenyNotification` | string | Whether to send rejection notifications |
|
||||
| `defaultMessageDenyNotificationText` | string | Default rejection message text |
|
||||
| `membersCanPostAsTheGroup` | string | Whether members can post as the group |
|
||||
| `includeInGlobalAddressList` | string | Whether included in Global Address List |
|
||||
| `whoCanLeaveGroup` | string | Who can leave the group |
|
||||
| `whoCanContactOwner` | string | Who can contact the group owner |
|
||||
| `favoriteRepliesOnTop` | string | Whether favorite replies appear at top |
|
||||
| `whoCanApproveMembers` | string | Who can approve new members |
|
||||
| `whoCanBanUsers` | string | Who can ban users |
|
||||
| `whoCanModerateMembers` | string | Who can manage members |
|
||||
| `whoCanModerateContent` | string | Who can moderate content |
|
||||
| `whoCanAssistContent` | string | Who can assist with content metadata |
|
||||
| `enableCollaborativeInbox` | string | Whether collaborative inbox is enabled |
|
||||
| `whoCanDiscoverGroup` | string | Who can discover the group |
|
||||
| `defaultSender` | string | Default sender identity \(DEFAULT_SELF or GROUP\) |
|
||||
|
||||
### `google_groups_update_settings`
|
||||
|
||||
Update the settings for a Google Group including access permissions, moderation, and posting options
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `groupEmail` | string | Yes | The email address of the group |
|
||||
| `name` | string | No | The group name \(max 75 characters\) |
|
||||
| `description` | string | No | The group description \(max 4096 characters\) |
|
||||
| `whoCanJoin` | string | No | Who can join: ANYONE_CAN_JOIN, ALL_IN_DOMAIN_CAN_JOIN, INVITED_CAN_JOIN, CAN_REQUEST_TO_JOIN |
|
||||
| `whoCanViewMembership` | string | No | Who can view membership: ALL_IN_DOMAIN_CAN_VIEW, ALL_MEMBERS_CAN_VIEW, ALL_MANAGERS_CAN_VIEW |
|
||||
| `whoCanViewGroup` | string | No | Who can view group messages: ANYONE_CAN_VIEW, ALL_IN_DOMAIN_CAN_VIEW, ALL_MEMBERS_CAN_VIEW, ALL_MANAGERS_CAN_VIEW |
|
||||
| `whoCanPostMessage` | string | No | Who can post: NONE_CAN_POST, ALL_MANAGERS_CAN_POST, ALL_MEMBERS_CAN_POST, ALL_OWNERS_CAN_POST, ALL_IN_DOMAIN_CAN_POST, ANYONE_CAN_POST |
|
||||
| `allowExternalMembers` | string | No | Whether external users can be members: true or false |
|
||||
| `allowWebPosting` | string | No | Whether web posting is allowed: true or false |
|
||||
| `primaryLanguage` | string | No | The group's primary language \(e.g., en\) |
|
||||
| `isArchived` | string | No | Whether messages are archived: true or false |
|
||||
| `archiveOnly` | string | No | Whether the group is archive-only \(inactive\): true or false |
|
||||
| `messageModerationLevel` | string | No | Message moderation: MODERATE_ALL_MESSAGES, MODERATE_NON_MEMBERS, MODERATE_NEW_MEMBERS, MODERATE_NONE |
|
||||
| `spamModerationLevel` | string | No | Spam handling: ALLOW, MODERATE, SILENTLY_MODERATE, REJECT |
|
||||
| `replyTo` | string | No | Default reply: REPLY_TO_CUSTOM, REPLY_TO_SENDER, REPLY_TO_LIST, REPLY_TO_OWNER, REPLY_TO_IGNORE, REPLY_TO_MANAGERS |
|
||||
| `customReplyTo` | string | No | Custom email for replies \(when replyTo is REPLY_TO_CUSTOM\) |
|
||||
| `includeCustomFooter` | string | No | Whether to include custom footer: true or false |
|
||||
| `customFooterText` | string | No | Custom footer text \(max 1000 characters\) |
|
||||
| `sendMessageDenyNotification` | string | No | Whether to send rejection notifications: true or false |
|
||||
| `defaultMessageDenyNotificationText` | string | No | Default rejection message text |
|
||||
| `membersCanPostAsTheGroup` | string | No | Whether members can post as the group: true or false |
|
||||
| `includeInGlobalAddressList` | string | No | Whether included in Global Address List: true or false |
|
||||
| `whoCanLeaveGroup` | string | No | Who can leave: ALL_MANAGERS_CAN_LEAVE, ALL_MEMBERS_CAN_LEAVE, NONE_CAN_LEAVE |
|
||||
| `whoCanContactOwner` | string | No | Who can contact owner: ALL_IN_DOMAIN_CAN_CONTACT, ALL_MANAGERS_CAN_CONTACT, ALL_MEMBERS_CAN_CONTACT, ANYONE_CAN_CONTACT |
|
||||
| `favoriteRepliesOnTop` | string | No | Whether favorite replies appear at top: true or false |
|
||||
| `whoCanApproveMembers` | string | No | Who can approve members: ALL_OWNERS_CAN_APPROVE, ALL_MANAGERS_CAN_APPROVE, ALL_MEMBERS_CAN_APPROVE, NONE_CAN_APPROVE |
|
||||
| `whoCanBanUsers` | string | No | Who can ban users: OWNERS_ONLY, OWNERS_AND_MANAGERS, NONE |
|
||||
| `whoCanModerateMembers` | string | No | Who can manage members: OWNERS_ONLY, OWNERS_AND_MANAGERS, ALL_MEMBERS, NONE |
|
||||
| `whoCanModerateContent` | string | No | Who can moderate content: OWNERS_ONLY, OWNERS_AND_MANAGERS, ALL_MEMBERS, NONE |
|
||||
| `whoCanAssistContent` | string | No | Who can assist with content metadata: OWNERS_ONLY, OWNERS_AND_MANAGERS, ALL_MEMBERS, NONE |
|
||||
| `enableCollaborativeInbox` | string | No | Whether collaborative inbox is enabled: true or false |
|
||||
| `whoCanDiscoverGroup` | string | No | Who can discover: ANYONE_CAN_DISCOVER, ALL_IN_DOMAIN_CAN_DISCOVER, ALL_MEMBERS_CAN_DISCOVER |
|
||||
| `defaultSender` | string | No | Default sender: DEFAULT_SELF or GROUP |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `email` | string | The group |
|
||||
| `name` | string | The group name |
|
||||
| `description` | string | The group description |
|
||||
| `whoCanJoin` | string | Who can join the group |
|
||||
| `whoCanViewMembership` | string | Who can view group membership |
|
||||
| `whoCanViewGroup` | string | Who can view group messages |
|
||||
| `whoCanPostMessage` | string | Who can post messages to the group |
|
||||
| `allowExternalMembers` | string | Whether external users can be members |
|
||||
| `allowWebPosting` | string | Whether web posting is allowed |
|
||||
| `primaryLanguage` | string | The group |
|
||||
| `isArchived` | string | Whether messages are archived |
|
||||
| `archiveOnly` | string | Whether the group is archive-only |
|
||||
| `messageModerationLevel` | string | Message moderation level |
|
||||
| `spamModerationLevel` | string | Spam handling level |
|
||||
| `replyTo` | string | Default reply destination |
|
||||
| `customReplyTo` | string | Custom email for replies |
|
||||
| `includeCustomFooter` | string | Whether to include custom footer |
|
||||
| `customFooterText` | string | Custom footer text |
|
||||
| `sendMessageDenyNotification` | string | Whether to send rejection notifications |
|
||||
| `defaultMessageDenyNotificationText` | string | Default rejection message text |
|
||||
| `membersCanPostAsTheGroup` | string | Whether members can post as the group |
|
||||
| `includeInGlobalAddressList` | string | Whether included in Global Address List |
|
||||
| `whoCanLeaveGroup` | string | Who can leave the group |
|
||||
| `whoCanContactOwner` | string | Who can contact the group owner |
|
||||
| `favoriteRepliesOnTop` | string | Whether favorite replies appear at top |
|
||||
| `whoCanApproveMembers` | string | Who can approve new members |
|
||||
| `whoCanBanUsers` | string | Who can ban users |
|
||||
| `whoCanModerateMembers` | string | Who can manage members |
|
||||
| `whoCanModerateContent` | string | Who can moderate content |
|
||||
| `whoCanAssistContent` | string | Who can assist with content metadata |
|
||||
| `enableCollaborativeInbox` | string | Whether collaborative inbox is enabled |
|
||||
| `whoCanDiscoverGroup` | string | Who can discover the group |
|
||||
| `defaultSender` | string | Default sender identity |
|
||||
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ In Sim, the Google Sheets integration empowers your agents to automate reading f
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Integrate Google Sheets into the workflow with explicit sheet selection. Can read, write, append, update, clear data, create spreadsheets, get spreadsheet info, and copy sheets.
|
||||
Integrate Google Sheets into the workflow with explicit sheet selection. Can read, write, append, and update data in specific sheets.
|
||||
|
||||
|
||||
|
||||
@@ -42,8 +42,9 @@ Read data from a specific sheet in a Google Sheets spreadsheet
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `spreadsheetId` | string | Yes | The ID of the spreadsheet \(found in the URL: docs.google.com/spreadsheets/d/\{SPREADSHEET_ID\}/edit\). |
|
||||
| `range` | string | No | The A1 notation range to read \(e.g. "Sheet1!A1:D10", "A1:B5"\). Defaults to first sheet A1:Z1000 if not specified. |
|
||||
| `spreadsheetId` | string | Yes | The ID of the spreadsheet |
|
||||
| `sheetName` | string | Yes | The name of the sheet/tab to read from |
|
||||
| `cellRange` | string | No | The cell range to read \(e.g. "A1:D10"\). Defaults to "A1:Z1000" if not specified. |
|
||||
|
||||
#### Output
|
||||
|
||||
@@ -65,7 +66,8 @@ Write data to a specific sheet in a Google Sheets spreadsheet
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `spreadsheetId` | string | Yes | The ID of the spreadsheet |
|
||||
| `range` | string | No | The A1 notation range to write to \(e.g. "Sheet1!A1:D10", "A1:B5"\) |
|
||||
| `sheetName` | string | Yes | The name of the sheet/tab to write to |
|
||||
| `cellRange` | string | No | The cell range to write to \(e.g. "A1:D10", "A1"\). Defaults to "A1" if not specified. |
|
||||
| `values` | array | Yes | The data to write as a 2D array \(e.g. \[\["Name", "Age"\], \["Alice", 30\], \["Bob", 25\]\]\) or array of objects. |
|
||||
| `valueInputOption` | string | No | The format of the data to write |
|
||||
| `includeValuesInResponse` | boolean | No | Whether to include the written values in the response |
|
||||
@@ -91,7 +93,8 @@ Update data in a specific sheet in a Google Sheets spreadsheet
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `spreadsheetId` | string | Yes | The ID of the spreadsheet to update |
|
||||
| `range` | string | No | The A1 notation range to update \(e.g. "Sheet1!A1:D10", "A1:B5"\) |
|
||||
| `sheetName` | string | Yes | The name of the sheet/tab to update |
|
||||
| `cellRange` | string | No | The cell range to update \(e.g. "A1:D10", "A1"\). Defaults to "A1" if not specified. |
|
||||
| `values` | array | Yes | The data to update as a 2D array \(e.g. \[\["Name", "Age"\], \["Alice", 30\]\]\) or array of objects. |
|
||||
| `valueInputOption` | string | No | The format of the data to update |
|
||||
| `includeValuesInResponse` | boolean | No | Whether to include the updated values in the response |
|
||||
@@ -117,7 +120,7 @@ Append data to the end of a specific sheet in a Google Sheets spreadsheet
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `spreadsheetId` | string | Yes | The ID of the spreadsheet to append to |
|
||||
| `range` | string | No | The A1 notation range to append after \(e.g. "Sheet1", "Sheet1!A:D"\) |
|
||||
| `sheetName` | string | Yes | The name of the sheet/tab to append to |
|
||||
| `values` | array | Yes | The data to append as a 2D array \(e.g. \[\["Alice", 30\], \["Bob", 25\]\]\) or array of objects. |
|
||||
| `valueInputOption` | string | No | The format of the data to append |
|
||||
| `insertDataOption` | string | No | How to insert the data \(OVERWRITE or INSERT_ROWS\) |
|
||||
@@ -136,180 +139,4 @@ Append data to the end of a specific sheet in a Google Sheets spreadsheet
|
||||
| ↳ `spreadsheetId` | string | Google Sheets spreadsheet ID |
|
||||
| ↳ `spreadsheetUrl` | string | Spreadsheet URL |
|
||||
|
||||
### `google_sheets_clear`
|
||||
|
||||
Clear values from a specific range in a Google Sheets spreadsheet
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `spreadsheetId` | string | Yes | The ID of the spreadsheet |
|
||||
| `sheetName` | string | Yes | The name of the sheet/tab to clear |
|
||||
| `cellRange` | string | No | The cell range to clear \(e.g. "A1:D10"\). Clears entire sheet if not specified. |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `clearedRange` | string | The range that was cleared |
|
||||
| `sheetName` | string | Name of the sheet that was cleared |
|
||||
| `metadata` | json | Spreadsheet metadata including ID and URL |
|
||||
| ↳ `spreadsheetId` | string | Google Sheets spreadsheet ID |
|
||||
| ↳ `spreadsheetUrl` | string | Spreadsheet URL |
|
||||
|
||||
### `google_sheets_get_spreadsheet`
|
||||
|
||||
Get metadata about a Google Sheets spreadsheet including title and sheet list
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `spreadsheetId` | string | Yes | The ID of the spreadsheet |
|
||||
| `includeGridData` | boolean | No | Whether to include grid data \(cell values\). Defaults to false. |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `spreadsheetId` | string | The spreadsheet ID |
|
||||
| `title` | string | The title of the spreadsheet |
|
||||
| `locale` | string | The locale of the spreadsheet |
|
||||
| `timeZone` | string | The time zone of the spreadsheet |
|
||||
| `spreadsheetUrl` | string | URL to the spreadsheet |
|
||||
| `sheets` | array | List of sheets in the spreadsheet |
|
||||
| ↳ `sheetId` | number | The sheet ID |
|
||||
| ↳ `title` | string | The sheet title/name |
|
||||
| ↳ `index` | number | The sheet index \(position\) |
|
||||
| ↳ `rowCount` | number | Number of rows in the sheet |
|
||||
| ↳ `columnCount` | number | Number of columns in the sheet |
|
||||
| ↳ `hidden` | boolean | Whether the sheet is hidden |
|
||||
|
||||
### `google_sheets_create_spreadsheet`
|
||||
|
||||
Create a new Google Sheets spreadsheet
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `title` | string | Yes | The title of the new spreadsheet |
|
||||
| `sheetTitles` | json | No | Array of sheet names to create \(e.g., \["Sheet1", "Data", "Summary"\]\). Defaults to a single "Sheet1". |
|
||||
| `locale` | string | No | The locale of the spreadsheet \(e.g., "en_US"\) |
|
||||
| `timeZone` | string | No | The time zone of the spreadsheet \(e.g., "America/New_York"\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `spreadsheetId` | string | The ID of the created spreadsheet |
|
||||
| `title` | string | The title of the created spreadsheet |
|
||||
| `spreadsheetUrl` | string | URL to the created spreadsheet |
|
||||
| `sheets` | array | List of sheets created in the spreadsheet |
|
||||
| ↳ `sheetId` | number | The sheet ID |
|
||||
| ↳ `title` | string | The sheet title/name |
|
||||
| ↳ `index` | number | The sheet index \(position\) |
|
||||
|
||||
### `google_sheets_batch_get`
|
||||
|
||||
Read multiple ranges from a Google Sheets spreadsheet in a single request
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `spreadsheetId` | string | Yes | The ID of the spreadsheet |
|
||||
| `ranges` | json | Yes | Array of ranges to read \(e.g., \["Sheet1!A1:D10", "Sheet2!A1:B5"\]\). Each range should include sheet name. |
|
||||
| `majorDimension` | string | No | The major dimension of values: "ROWS" \(default\) or "COLUMNS" |
|
||||
| `valueRenderOption` | string | No | How values should be rendered: "FORMATTED_VALUE" \(default\), "UNFORMATTED_VALUE", or "FORMULA" |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `spreadsheetId` | string | The spreadsheet ID |
|
||||
| `valueRanges` | array | Array of value ranges read from the spreadsheet |
|
||||
| ↳ `range` | string | The range that was read |
|
||||
| ↳ `majorDimension` | string | Major dimension \(ROWS or COLUMNS\) |
|
||||
| ↳ `values` | array | The cell values as a 2D array |
|
||||
| `metadata` | json | Spreadsheet metadata including ID and URL |
|
||||
| ↳ `spreadsheetId` | string | Google Sheets spreadsheet ID |
|
||||
| ↳ `spreadsheetUrl` | string | Spreadsheet URL |
|
||||
|
||||
### `google_sheets_batch_update`
|
||||
|
||||
Update multiple ranges in a Google Sheets spreadsheet in a single request
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `spreadsheetId` | string | Yes | The ID of the spreadsheet |
|
||||
| `data` | json | Yes | Array of value ranges to update. Each item should have "range" \(e.g., "Sheet1!A1:D10"\) and "values" \(2D array\). |
|
||||
| `valueInputOption` | string | No | How input data should be interpreted: "RAW" or "USER_ENTERED" \(default\). USER_ENTERED parses formulas. |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `spreadsheetId` | string | The spreadsheet ID |
|
||||
| `totalUpdatedRows` | number | Total number of rows updated |
|
||||
| `totalUpdatedColumns` | number | Total number of columns updated |
|
||||
| `totalUpdatedCells` | number | Total number of cells updated |
|
||||
| `totalUpdatedSheets` | number | Total number of sheets updated |
|
||||
| `responses` | array | Array of update responses for each range |
|
||||
| ↳ `spreadsheetId` | string | The spreadsheet ID |
|
||||
| ↳ `updatedRange` | string | The range that was updated |
|
||||
| ↳ `updatedRows` | number | Number of rows updated in this range |
|
||||
| ↳ `updatedColumns` | number | Number of columns updated in this range |
|
||||
| ↳ `updatedCells` | number | Number of cells updated in this range |
|
||||
| `metadata` | json | Spreadsheet metadata including ID and URL |
|
||||
| ↳ `spreadsheetId` | string | Google Sheets spreadsheet ID |
|
||||
| ↳ `spreadsheetUrl` | string | Spreadsheet URL |
|
||||
|
||||
### `google_sheets_batch_clear`
|
||||
|
||||
Clear multiple ranges in a Google Sheets spreadsheet in a single request
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `spreadsheetId` | string | Yes | The ID of the spreadsheet |
|
||||
| `ranges` | json | Yes | Array of ranges to clear \(e.g., \["Sheet1!A1:D10", "Sheet2!A1:B5"\]\). Each range should include sheet name. |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `spreadsheetId` | string | The spreadsheet ID |
|
||||
| `clearedRanges` | array | Array of ranges that were cleared |
|
||||
| `metadata` | json | Spreadsheet metadata including ID and URL |
|
||||
| ↳ `spreadsheetId` | string | Google Sheets spreadsheet ID |
|
||||
| ↳ `spreadsheetUrl` | string | Spreadsheet URL |
|
||||
|
||||
### `google_sheets_copy_sheet`
|
||||
|
||||
Copy a sheet from one spreadsheet to another
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `sourceSpreadsheetId` | string | Yes | The ID of the source spreadsheet |
|
||||
| `sheetId` | number | Yes | The ID of the sheet to copy \(numeric ID, not the sheet name\). Use Get Spreadsheet to find sheet IDs. |
|
||||
| `destinationSpreadsheetId` | string | Yes | The ID of the destination spreadsheet where the sheet will be copied |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `sheetId` | number | The ID of the newly created sheet in the destination |
|
||||
| `title` | string | The title of the copied sheet |
|
||||
| `index` | number | The index \(position\) of the copied sheet |
|
||||
| `sheetType` | string | The type of the sheet \(GRID, CHART, etc.\) |
|
||||
| `destinationSpreadsheetId` | string | The ID of the destination spreadsheet |
|
||||
| `destinationSpreadsheetUrl` | string | URL to the destination spreadsheet |
|
||||
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ In Sim, the Google Slides integration enables your agents to interact directly w
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Integrate Google Slides into the workflow. Can read, write, create presentations, replace text, add slides, add images, get thumbnails, get page details, delete objects, duplicate objects, reorder slides, create tables, create shapes, and insert text.
|
||||
Integrate Google Slides into the workflow. Can read, write, create presentations, replace text, add slides, add images, and get thumbnails.
|
||||
|
||||
|
||||
|
||||
@@ -177,177 +177,4 @@ Generate a thumbnail image of a specific slide in a Google Slides presentation
|
||||
| `height` | number | Height of the thumbnail in pixels |
|
||||
| `metadata` | json | Operation metadata including presentation ID and page object ID |
|
||||
|
||||
### `google_slides_get_page`
|
||||
|
||||
Get detailed information about a specific slide/page in a Google Slides presentation
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `presentationId` | string | Yes | The ID of the presentation |
|
||||
| `pageObjectId` | string | Yes | The object ID of the slide/page to retrieve |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `objectId` | string | The object ID of the page |
|
||||
| `pageType` | string | The type of page \(SLIDE, MASTER, LAYOUT, NOTES, NOTES_MASTER\) |
|
||||
| `pageElements` | array | Array of page elements \(shapes, images, tables, etc.\) on this page |
|
||||
| `slideProperties` | object | Properties specific to slides \(layout, master, notes\) |
|
||||
| ↳ `layoutObjectId` | string | Object ID of the layout this slide is based on |
|
||||
| ↳ `masterObjectId` | string | Object ID of the master this slide is based on |
|
||||
| ↳ `notesPage` | json | The notes page associated with the slide |
|
||||
| `metadata` | object | Operation metadata including presentation ID and URL |
|
||||
| ↳ `presentationId` | string | The presentation ID |
|
||||
| ↳ `url` | string | URL to the presentation |
|
||||
|
||||
### `google_slides_delete_object`
|
||||
|
||||
Delete a page element (shape, image, table, etc.) or an entire slide from a Google Slides presentation
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `presentationId` | string | Yes | The ID of the presentation |
|
||||
| `objectId` | string | Yes | The object ID of the element or slide to delete |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `deleted` | boolean | Whether the object was successfully deleted |
|
||||
| `objectId` | string | The object ID that was deleted |
|
||||
| `metadata` | object | Operation metadata including presentation ID and URL |
|
||||
| ↳ `presentationId` | string | The presentation ID |
|
||||
| ↳ `url` | string | URL to the presentation |
|
||||
|
||||
### `google_slides_duplicate_object`
|
||||
|
||||
Duplicate an object (slide, shape, image, table, etc.) in a Google Slides presentation
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `presentationId` | string | Yes | The ID of the presentation |
|
||||
| `objectId` | string | Yes | The object ID of the element or slide to duplicate |
|
||||
| `objectIds` | string | No | Optional JSON object mapping source object IDs \(within the slide being duplicated\) to new object IDs for the duplicates. Format: \{"sourceId1":"newId1","sourceId2":"newId2"\} |
|
||||
| `Format` | string | No | No description |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `duplicatedObjectId` | string | The object ID of the newly created duplicate |
|
||||
| `metadata` | object | Operation metadata including presentation ID and source object ID |
|
||||
| ↳ `presentationId` | string | The presentation ID |
|
||||
| ↳ `sourceObjectId` | string | The original object ID that was duplicated |
|
||||
| ↳ `url` | string | URL to the presentation |
|
||||
|
||||
### `google_slides_update_slides_position`
|
||||
|
||||
Move one or more slides to a new position in a Google Slides presentation
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `presentationId` | string | Yes | The ID of the presentation |
|
||||
| `slideObjectIds` | string | Yes | Comma-separated list of slide object IDs to move. The slides will maintain their relative order. |
|
||||
| `insertionIndex` | number | Yes | The zero-based index where the slides should be moved. All slides with indices greater than or equal to this will be shifted right. |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `moved` | boolean | Whether the slides were successfully moved |
|
||||
| `slideObjectIds` | array | The slide object IDs that were moved |
|
||||
| `insertionIndex` | number | The index where the slides were moved to |
|
||||
| `metadata` | object | Operation metadata including presentation ID and URL |
|
||||
| ↳ `presentationId` | string | The presentation ID |
|
||||
| ↳ `url` | string | URL to the presentation |
|
||||
|
||||
### `google_slides_create_table`
|
||||
|
||||
Create a new table on a slide in a Google Slides presentation
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `presentationId` | string | Yes | The ID of the presentation |
|
||||
| `pageObjectId` | string | Yes | The object ID of the slide/page to add the table to |
|
||||
| `rows` | number | Yes | Number of rows in the table \(minimum 1\) |
|
||||
| `columns` | number | Yes | Number of columns in the table \(minimum 1\) |
|
||||
| `width` | number | No | Width of the table in points \(default: 400\) |
|
||||
| `height` | number | No | Height of the table in points \(default: 200\) |
|
||||
| `positionX` | number | No | X position from the left edge in points \(default: 100\) |
|
||||
| `positionY` | number | No | Y position from the top edge in points \(default: 100\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `tableId` | string | The object ID of the newly created table |
|
||||
| `rows` | number | Number of rows in the table |
|
||||
| `columns` | number | Number of columns in the table |
|
||||
| `metadata` | object | Operation metadata including presentation ID and page object ID |
|
||||
| ↳ `presentationId` | string | The presentation ID |
|
||||
| ↳ `pageObjectId` | string | The page object ID where the table was created |
|
||||
| ↳ `url` | string | URL to the presentation |
|
||||
|
||||
### `google_slides_create_shape`
|
||||
|
||||
Create a shape (rectangle, ellipse, text box, arrow, etc.) on a slide in a Google Slides presentation
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `presentationId` | string | Yes | The ID of the presentation |
|
||||
| `pageObjectId` | string | Yes | The object ID of the slide/page to add the shape to |
|
||||
| `shapeType` | string | Yes | The type of shape to create. Common types: TEXT_BOX, RECTANGLE, ROUND_RECTANGLE, ELLIPSE, TRIANGLE, DIAMOND, STAR_5, ARROW_EAST, HEART, CLOUD |
|
||||
| `width` | number | No | Width of the shape in points \(default: 200\) |
|
||||
| `height` | number | No | Height of the shape in points \(default: 100\) |
|
||||
| `positionX` | number | No | X position from the left edge in points \(default: 100\) |
|
||||
| `positionY` | number | No | Y position from the top edge in points \(default: 100\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `shapeId` | string | The object ID of the newly created shape |
|
||||
| `shapeType` | string | The type of shape that was created |
|
||||
| `metadata` | object | Operation metadata including presentation ID and page object ID |
|
||||
| ↳ `presentationId` | string | The presentation ID |
|
||||
| ↳ `pageObjectId` | string | The page object ID where the shape was created |
|
||||
| ↳ `url` | string | URL to the presentation |
|
||||
|
||||
### `google_slides_insert_text`
|
||||
|
||||
Insert text into a shape or table cell in a Google Slides presentation. Use this to add text to text boxes, shapes, or table cells.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `presentationId` | string | Yes | The ID of the presentation |
|
||||
| `objectId` | string | Yes | The object ID of the shape or table cell to insert text into. For table cells, use the cell object ID. |
|
||||
| `text` | string | Yes | The text to insert |
|
||||
| `insertionIndex` | number | No | The zero-based index at which to insert the text. If not specified, text is inserted at the beginning \(index 0\). |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `inserted` | boolean | Whether the text was successfully inserted |
|
||||
| `objectId` | string | The object ID where text was inserted |
|
||||
| `text` | string | The text that was inserted |
|
||||
| `metadata` | object | Operation metadata including presentation ID and URL |
|
||||
| ↳ `presentationId` | string | The presentation ID |
|
||||
| ↳ `url` | string | URL to the presentation |
|
||||
|
||||
|
||||
|
||||
@@ -51,7 +51,6 @@ Search for similar content in a knowledge base using vector similarity
|
||||
| `properties` | string | No | No description |
|
||||
| `tagName` | string | No | No description |
|
||||
| `tagValue` | string | No | No description |
|
||||
| `tagFilters` | string | No | No description |
|
||||
|
||||
#### Output
|
||||
|
||||
@@ -109,8 +108,19 @@ Create a new document in a knowledge base
|
||||
| `knowledgeBaseId` | string | Yes | ID of the knowledge base containing the document |
|
||||
| `name` | string | Yes | Name of the document |
|
||||
| `content` | string | Yes | Content of the document |
|
||||
| `documentTags` | object | No | Document tags |
|
||||
| `documentTags` | string | No | No description |
|
||||
| `tag1` | string | No | Tag 1 value for the document |
|
||||
| `tag2` | string | No | Tag 2 value for the document |
|
||||
| `tag3` | string | No | Tag 3 value for the document |
|
||||
| `tag4` | string | No | Tag 4 value for the document |
|
||||
| `tag5` | string | No | Tag 5 value for the document |
|
||||
| `tag6` | string | No | Tag 6 value for the document |
|
||||
| `tag7` | string | No | Tag 7 value for the document |
|
||||
| `documentTagsData` | array | No | Structured tag data with names, types, and values |
|
||||
| `items` | object | No | No description |
|
||||
| `properties` | string | No | No description |
|
||||
| `tagName` | string | No | No description |
|
||||
| `tagValue` | string | No | No description |
|
||||
| `tagType` | string | No | No description |
|
||||
|
||||
#### Output
|
||||
|
||||
|
||||
@@ -45,7 +45,8 @@ Read data from a specific sheet in a Microsoft Excel spreadsheet
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `spreadsheetId` | string | Yes | The ID of the spreadsheet to read from |
|
||||
| `range` | string | No | The range of cells to read from. Accepts "SheetName!A1:B2" for explicit ranges or just "SheetName" to read the used range of that sheet. If omitted, reads the used range of the first sheet. |
|
||||
| `sheetName` | string | Yes | The name of the sheet/tab to read from |
|
||||
| `cellRange` | string | No | The cell range to read \(e.g., "A1:D10"\). If not specified, reads the entire used range. |
|
||||
|
||||
#### Output
|
||||
|
||||
@@ -67,8 +68,9 @@ Write data to a specific sheet in a Microsoft Excel spreadsheet
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `spreadsheetId` | string | Yes | The ID of the spreadsheet to write to |
|
||||
| `range` | string | No | The range of cells to write to |
|
||||
| `values` | array | Yes | The data to write to the spreadsheet |
|
||||
| `sheetName` | string | Yes | The name of the sheet/tab to write to |
|
||||
| `cellRange` | string | No | The cell range to write to \(e.g., "A1:D10", "A1"\). Defaults to "A1" if not specified. |
|
||||
| `values` | array | Yes | The data to write as a 2D array \(e.g. \[\["Name", "Age"\], \["Alice", 30\], \["Bob", 25\]\]\) or array of objects. |
|
||||
| `valueInputOption` | string | No | The format of the data to write |
|
||||
| `includeValuesInResponse` | boolean | No | Whether to include the written values in the response |
|
||||
|
||||
|
||||
@@ -84,10 +84,9 @@ Send messages to Slack channels or direct messages. Supports Slack mrkdwn format
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `authMethod` | string | No | Authentication method: oauth or bot_token |
|
||||
| `destinationType` | string | No | Destination type: channel or dm |
|
||||
| `botToken` | string | No | Bot token for Custom Bot |
|
||||
| `channel` | string | No | Target Slack channel \(e.g., #general\) |
|
||||
| `dmUserId` | string | No | Target Slack user for direct messages |
|
||||
| `userId` | string | No | Target Slack user ID for direct messages \(e.g., U1234567890\) |
|
||||
| `text` | string | Yes | Message text to send \(supports Slack mrkdwn formatting\) |
|
||||
| `thread_ts` | string | No | Thread timestamp to reply to \(creates thread reply\) |
|
||||
| `files` | file[] | No | Files to attach to the message |
|
||||
@@ -133,10 +132,9 @@ Read the latest messages from Slack channels. Retrieve conversation history with
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `authMethod` | string | No | Authentication method: oauth or bot_token |
|
||||
| `destinationType` | string | No | Destination type: channel or dm |
|
||||
| `botToken` | string | No | Bot token for Custom Bot |
|
||||
| `channel` | string | No | Slack channel to read messages from \(e.g., #general\) |
|
||||
| `dmUserId` | string | No | Target Slack user for DM conversation |
|
||||
| `userId` | string | No | User ID for DM conversation \(e.g., U1234567890\) |
|
||||
| `limit` | number | No | Number of messages to retrieve \(default: 10, max: 15\) |
|
||||
| `oldest` | string | No | Start of time range \(timestamp\) |
|
||||
| `latest` | string | No | End of time range \(timestamp\) |
|
||||
|
||||
@@ -2,7 +2,7 @@ import { randomUUID } from 'crypto'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { SUPPORTED_FIELD_TYPES } from '@/lib/knowledge/constants'
|
||||
import { createTagDefinition, getTagDefinitions } from '@/lib/knowledge/tags/service'
|
||||
import { checkKnowledgeBaseAccess } from '@/app/api/knowledge/utils'
|
||||
@@ -19,32 +19,19 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
try {
|
||||
logger.info(`[${requestId}] Getting tag definitions for knowledge base ${knowledgeBaseId}`)
|
||||
|
||||
const auth = await checkHybridAuth(req, { requireWorkflowId: false })
|
||||
if (!auth.success) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Only allow session and internal JWT auth (not API key)
|
||||
if (auth.authType === 'api_key') {
|
||||
return NextResponse.json(
|
||||
{ error: 'API key auth not supported for this endpoint' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
// For session auth, verify KB access. Internal JWT is trusted.
|
||||
if (auth.authType === 'session' && auth.userId) {
|
||||
const accessCheck = await checkKnowledgeBaseAccess(knowledgeBaseId, auth.userId)
|
||||
if (!accessCheck.hasAccess) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
const accessCheck = await checkKnowledgeBaseAccess(knowledgeBaseId, session.user.id)
|
||||
if (!accessCheck.hasAccess) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const tagDefinitions = await getTagDefinitions(knowledgeBaseId)
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Retrieved ${tagDefinitions.length} tag definitions (${auth.authType})`
|
||||
)
|
||||
logger.info(`[${requestId}] Retrieved ${tagDefinitions.length} tag definitions`)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
@@ -64,25 +51,14 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
try {
|
||||
logger.info(`[${requestId}] Creating tag definition for knowledge base ${knowledgeBaseId}`)
|
||||
|
||||
const auth = await checkHybridAuth(req, { requireWorkflowId: false })
|
||||
if (!auth.success) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Only allow session and internal JWT auth (not API key)
|
||||
if (auth.authType === 'api_key') {
|
||||
return NextResponse.json(
|
||||
{ error: 'API key auth not supported for this endpoint' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
// For session auth, verify KB access. Internal JWT is trusted.
|
||||
if (auth.authType === 'session' && auth.userId) {
|
||||
const accessCheck = await checkKnowledgeBaseAccess(knowledgeBaseId, auth.userId)
|
||||
if (!accessCheck.hasAccess) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
const accessCheck = await checkKnowledgeBaseAccess(knowledgeBaseId, session.user.id)
|
||||
if (!accessCheck.hasAccess) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { type HTMLAttributes, memo, type ReactNode, useMemo } from 'react'
|
||||
import React, { type HTMLAttributes, type ReactNode } from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { Tooltip } from '@/components/emcn'
|
||||
@@ -23,16 +23,24 @@ export function LinkWithPreview({ href, children }: { href: string; children: Re
|
||||
)
|
||||
}
|
||||
|
||||
const REMARK_PLUGINS = [remarkGfm]
|
||||
export default function MarkdownRenderer({
|
||||
content,
|
||||
customLinkComponent,
|
||||
}: {
|
||||
content: string
|
||||
customLinkComponent?: typeof LinkWithPreview
|
||||
}) {
|
||||
const LinkComponent = customLinkComponent || LinkWithPreview
|
||||
|
||||
function createCustomComponents(LinkComponent: typeof LinkWithPreview) {
|
||||
return {
|
||||
const customComponents = {
|
||||
// Paragraph
|
||||
p: ({ children }: React.HTMLAttributes<HTMLParagraphElement>) => (
|
||||
<p className='mb-1 font-sans text-base text-gray-800 leading-relaxed last:mb-0 dark:text-gray-200'>
|
||||
{children}
|
||||
</p>
|
||||
),
|
||||
|
||||
// Headings
|
||||
h1: ({ children }: React.HTMLAttributes<HTMLHeadingElement>) => (
|
||||
<h1 className='mt-10 mb-5 font-sans font-semibold text-2xl text-gray-900 dark:text-gray-100'>
|
||||
{children}
|
||||
@@ -54,6 +62,7 @@ function createCustomComponents(LinkComponent: typeof LinkWithPreview) {
|
||||
</h4>
|
||||
),
|
||||
|
||||
// Lists
|
||||
ul: ({ children }: React.HTMLAttributes<HTMLUListElement>) => (
|
||||
<ul
|
||||
className='mt-1 mb-1 space-y-1 pl-6 font-sans text-gray-800 dark:text-gray-200'
|
||||
@@ -80,6 +89,7 @@ function createCustomComponents(LinkComponent: typeof LinkWithPreview) {
|
||||
</li>
|
||||
),
|
||||
|
||||
// Code blocks
|
||||
pre: ({ children }: HTMLAttributes<HTMLPreElement>) => {
|
||||
let codeProps: HTMLAttributes<HTMLElement> = {}
|
||||
let codeContent: ReactNode = children
|
||||
@@ -110,6 +120,7 @@ function createCustomComponents(LinkComponent: typeof LinkWithPreview) {
|
||||
)
|
||||
},
|
||||
|
||||
// Inline code
|
||||
code: ({
|
||||
inline,
|
||||
className,
|
||||
@@ -133,20 +144,24 @@ function createCustomComponents(LinkComponent: typeof LinkWithPreview) {
|
||||
)
|
||||
},
|
||||
|
||||
// Blockquotes
|
||||
blockquote: ({ children }: React.HTMLAttributes<HTMLQuoteElement>) => (
|
||||
<blockquote className='my-4 border-gray-300 border-l-4 py-1 pl-4 font-sans text-gray-700 italic dark:border-gray-600 dark:text-gray-300'>
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
|
||||
// Horizontal rule
|
||||
hr: () => <hr className='my-8 border-gray-500/[.07] border-t dark:border-gray-400/[.07]' />,
|
||||
|
||||
// Links
|
||||
a: ({ href, children, ...props }: React.AnchorHTMLAttributes<HTMLAnchorElement>) => (
|
||||
<LinkComponent href={href || '#'} {...props}>
|
||||
{children}
|
||||
</LinkComponent>
|
||||
),
|
||||
|
||||
// Tables
|
||||
table: ({ children }: React.TableHTMLAttributes<HTMLTableElement>) => (
|
||||
<div className='my-4 w-full overflow-x-auto'>
|
||||
<table className='min-w-full table-auto border border-gray-300 font-sans text-sm dark:border-gray-700'>
|
||||
@@ -178,6 +193,7 @@ function createCustomComponents(LinkComponent: typeof LinkWithPreview) {
|
||||
</td>
|
||||
),
|
||||
|
||||
// Images
|
||||
img: ({ src, alt, ...props }: React.ImgHTMLAttributes<HTMLImageElement>) => (
|
||||
<img
|
||||
src={src}
|
||||
@@ -187,33 +203,15 @@ function createCustomComponents(LinkComponent: typeof LinkWithPreview) {
|
||||
/>
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_COMPONENTS = createCustomComponents(LinkWithPreview)
|
||||
|
||||
const MarkdownRenderer = memo(function MarkdownRenderer({
|
||||
content,
|
||||
customLinkComponent,
|
||||
}: {
|
||||
content: string
|
||||
customLinkComponent?: typeof LinkWithPreview
|
||||
}) {
|
||||
const components = useMemo(() => {
|
||||
if (!customLinkComponent) {
|
||||
return DEFAULT_COMPONENTS
|
||||
}
|
||||
return createCustomComponents(customLinkComponent)
|
||||
}, [customLinkComponent])
|
||||
|
||||
// Pre-process content to fix common issues
|
||||
const processedContent = content.trim()
|
||||
|
||||
return (
|
||||
<div className='space-y-4 break-words font-sans text-[#0D0D0D] text-base leading-relaxed dark:text-gray-100'>
|
||||
<ReactMarkdown remarkPlugins={REMARK_PLUGINS} components={components}>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={customComponents}>
|
||||
{processedContent}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
export default MarkdownRenderer
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { generateBrandedMetadata, generateStructuredData } from '@/lib/branding/
|
||||
import { PostHogProvider } from '@/app/_shell/providers/posthog-provider'
|
||||
import '@/app/_styles/globals.css'
|
||||
import { OneDollarStats } from '@/components/analytics/onedollarstats'
|
||||
import { isReactGrabEnabled, isReactScanEnabled } from '@/lib/core/config/feature-flags'
|
||||
import { isReactGrabEnabled } from '@/lib/core/config/feature-flags'
|
||||
import { HydrationErrorHandler } from '@/app/_shell/hydration-error-handler'
|
||||
import { QueryProvider } from '@/app/_shell/providers/query-provider'
|
||||
import { SessionProvider } from '@/app/_shell/providers/session-provider'
|
||||
@@ -35,13 +35,6 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
return (
|
||||
<html lang='en' suppressHydrationWarning>
|
||||
<head>
|
||||
{isReactScanEnabled && (
|
||||
<Script
|
||||
src='https://unpkg.com/react-scan/dist/auto.global.js'
|
||||
crossOrigin='anonymous'
|
||||
strategy='beforeInteractive'
|
||||
/>
|
||||
)}
|
||||
{isReactGrabEnabled && (
|
||||
<Script
|
||||
src='https://unpkg.com/react-grab/dist/index.global.js'
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
'use client'
|
||||
|
||||
import { Suspense, useEffect, useState } from 'react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { CheckCircle, Heart, Info, Loader2, XCircle } from 'lucide-react'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { inter } from '@/app/_styles/fonts/inter/inter'
|
||||
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
|
||||
import { BrandedButton } from '@/app/(auth)/components/branded-button'
|
||||
import { SupportFooter } from '@/app/(auth)/components/support-footer'
|
||||
import { InviteLayout } from '@/app/invite/components'
|
||||
import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui'
|
||||
import { useBrandConfig } from '@/lib/branding/branding'
|
||||
|
||||
interface UnsubscribeData {
|
||||
success: boolean
|
||||
@@ -30,6 +27,7 @@ function UnsubscribeContent() {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [processing, setProcessing] = useState(false)
|
||||
const [unsubscribed, setUnsubscribed] = useState(false)
|
||||
const brand = useBrandConfig()
|
||||
|
||||
const email = searchParams.get('email')
|
||||
const token = searchParams.get('token')
|
||||
@@ -111,7 +109,7 @@ function UnsubscribeContent() {
|
||||
} else {
|
||||
setError(result.error || 'Failed to unsubscribe')
|
||||
}
|
||||
} catch {
|
||||
} catch (error) {
|
||||
setError('Failed to process unsubscribe request')
|
||||
} finally {
|
||||
setProcessing(false)
|
||||
@@ -120,171 +118,272 @@ function UnsubscribeContent() {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<InviteLayout>
|
||||
<div className='space-y-1 text-center'>
|
||||
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
|
||||
Loading
|
||||
</h1>
|
||||
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
|
||||
Validating your unsubscribe link...
|
||||
</p>
|
||||
</div>
|
||||
<div className={`${inter.className} mt-8 flex w-full items-center justify-center py-8`}>
|
||||
<Loader2 className='h-8 w-8 animate-spin text-muted-foreground' />
|
||||
</div>
|
||||
<SupportFooter position='absolute' />
|
||||
</InviteLayout>
|
||||
<div className='before:-z-50 relative flex min-h-screen items-center justify-center before:pointer-events-none before:fixed before:inset-0 before:bg-white'>
|
||||
<Card className='w-full max-w-md border shadow-sm'>
|
||||
<CardContent className='flex items-center justify-center p-8'>
|
||||
<Loader2 className='h-8 w-8 animate-spin text-muted-foreground' />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<InviteLayout>
|
||||
<div className='space-y-1 text-center'>
|
||||
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
|
||||
Invalid Unsubscribe Link
|
||||
</h1>
|
||||
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
|
||||
{error}
|
||||
</p>
|
||||
</div>
|
||||
<div className='before:-z-50 relative flex min-h-screen items-center justify-center p-4 before:pointer-events-none before:fixed before:inset-0 before:bg-white'>
|
||||
<Card className='w-full max-w-md border shadow-sm'>
|
||||
<CardHeader className='text-center'>
|
||||
<XCircle className='mx-auto mb-2 h-12 w-12 text-red-500' />
|
||||
<CardTitle className='text-foreground'>Invalid Unsubscribe Link</CardTitle>
|
||||
<CardDescription className='text-muted-foreground'>
|
||||
This unsubscribe link is invalid or has expired
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
<div className='rounded-lg border bg-red-50 p-4'>
|
||||
<p className='text-red-800 text-sm'>
|
||||
<strong>Error:</strong> {error}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={`${inter.className} mt-8 w-full max-w-[410px] space-y-3`}>
|
||||
<BrandedButton onClick={() => window.history.back()}>Go Back</BrandedButton>
|
||||
</div>
|
||||
<div className='space-y-3'>
|
||||
<p className='text-muted-foreground text-sm'>This could happen if:</p>
|
||||
<ul className='ml-4 list-inside list-disc space-y-1 text-muted-foreground text-sm'>
|
||||
<li>The link is missing required parameters</li>
|
||||
<li>The link has expired or been used already</li>
|
||||
<li>The link was copied incorrectly</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<SupportFooter position='absolute' />
|
||||
</InviteLayout>
|
||||
<div className='mt-6 flex flex-col gap-3'>
|
||||
<Button
|
||||
onClick={() =>
|
||||
window.open(
|
||||
`mailto:${brand.supportEmail}?subject=Unsubscribe%20Help&body=Hi%2C%20I%20need%20help%20unsubscribing%20from%20emails.%20My%20unsubscribe%20link%20is%20not%20working.`,
|
||||
'_blank'
|
||||
)
|
||||
}
|
||||
className='w-full bg-[var(--brand-primary-hex)] font-medium text-white shadow-sm transition-colors duration-200 hover:bg-[var(--brand-primary-hover-hex)]'
|
||||
>
|
||||
Contact Support
|
||||
</Button>
|
||||
<Button onClick={() => window.history.back()} variant='outline' className='w-full'>
|
||||
Go Back
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className='mt-4 text-center'>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Need immediate help? Email us at{' '}
|
||||
<a
|
||||
href={`mailto:${brand.supportEmail}`}
|
||||
className='text-muted-foreground hover:underline'
|
||||
>
|
||||
{brand.supportEmail}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (data?.isTransactional) {
|
||||
return (
|
||||
<InviteLayout>
|
||||
<div className='space-y-1 text-center'>
|
||||
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
|
||||
Important Account Emails
|
||||
</h1>
|
||||
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
|
||||
Transactional emails like password resets, account confirmations, and security alerts
|
||||
cannot be unsubscribed from as they contain essential information for your account.
|
||||
</p>
|
||||
</div>
|
||||
<div className='before:-z-50 relative flex min-h-screen items-center justify-center p-4 before:pointer-events-none before:fixed before:inset-0 before:bg-white'>
|
||||
<Card className='w-full max-w-md border shadow-sm'>
|
||||
<CardHeader className='text-center'>
|
||||
<Info className='mx-auto mb-2 h-12 w-12 text-blue-500' />
|
||||
<CardTitle className='text-foreground'>Important Account Emails</CardTitle>
|
||||
<CardDescription className='text-muted-foreground'>
|
||||
This email contains important information about your account
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
<div className='rounded-lg border bg-blue-50 p-4'>
|
||||
<p className='text-blue-800 text-sm'>
|
||||
<strong>Transactional emails</strong> like password resets, account confirmations,
|
||||
and security alerts cannot be unsubscribed from as they contain essential
|
||||
information for your account security and functionality.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={`${inter.className} mt-8 w-full max-w-[410px] space-y-3`}>
|
||||
<BrandedButton onClick={() => window.close()}>Close</BrandedButton>
|
||||
</div>
|
||||
<div className='space-y-3'>
|
||||
<p className='text-foreground text-sm'>
|
||||
If you no longer wish to receive these emails, you can:
|
||||
</p>
|
||||
<ul className='ml-4 list-inside list-disc space-y-1 text-muted-foreground text-sm'>
|
||||
<li>Close your account entirely</li>
|
||||
<li>Contact our support team for assistance</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<SupportFooter position='absolute' />
|
||||
</InviteLayout>
|
||||
<div className='mt-6 flex flex-col gap-3'>
|
||||
<Button
|
||||
onClick={() =>
|
||||
window.open(
|
||||
`mailto:${brand.supportEmail}?subject=Account%20Help&body=Hi%2C%20I%20need%20help%20with%20my%20account%20emails.`,
|
||||
'_blank'
|
||||
)
|
||||
}
|
||||
className='w-full bg-blue-600 text-white hover:bg-blue-700'
|
||||
>
|
||||
Contact Support
|
||||
</Button>
|
||||
<Button onClick={() => window.close()} variant='outline' className='w-full'>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (unsubscribed) {
|
||||
return (
|
||||
<InviteLayout>
|
||||
<div className='space-y-1 text-center'>
|
||||
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
|
||||
Successfully Unsubscribed
|
||||
</h1>
|
||||
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
|
||||
You have been unsubscribed from our emails. You will stop receiving emails within 48
|
||||
hours.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={`${inter.className} mt-8 w-full max-w-[410px] space-y-3`}>
|
||||
<BrandedButton onClick={() => window.close()}>Close</BrandedButton>
|
||||
</div>
|
||||
|
||||
<SupportFooter position='absolute' />
|
||||
</InviteLayout>
|
||||
<div className='before:-z-50 relative flex min-h-screen items-center justify-center before:pointer-events-none before:fixed before:inset-0 before:bg-white'>
|
||||
<Card className='w-full max-w-md border shadow-sm'>
|
||||
<CardHeader className='text-center'>
|
||||
<CheckCircle className='mx-auto mb-2 h-12 w-12 text-green-500' />
|
||||
<CardTitle className='text-foreground'>Successfully Unsubscribed</CardTitle>
|
||||
<CardDescription className='text-muted-foreground'>
|
||||
You have been unsubscribed from our emails. You will stop receiving emails within 48
|
||||
hours.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='text-center'>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
If you change your mind, you can always update your email preferences in your account
|
||||
settings or contact us at{' '}
|
||||
<a
|
||||
href={`mailto:${brand.supportEmail}`}
|
||||
className='text-muted-foreground hover:underline'
|
||||
>
|
||||
{brand.supportEmail}
|
||||
</a>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const isAlreadyUnsubscribedFromAll = data?.currentPreferences.unsubscribeAll
|
||||
|
||||
return (
|
||||
<InviteLayout>
|
||||
<div className='space-y-1 text-center'>
|
||||
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
|
||||
Email Preferences
|
||||
</h1>
|
||||
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
|
||||
Choose which emails you'd like to stop receiving.
|
||||
</p>
|
||||
<p className={`${inter.className} mt-2 font-[380] text-[14px] text-muted-foreground`}>
|
||||
{data?.email}
|
||||
</p>
|
||||
</div>
|
||||
<div className='before:-z-50 relative flex min-h-screen items-center justify-center p-4 before:pointer-events-none before:fixed before:inset-0 before:bg-white'>
|
||||
<Card className='w-full max-w-md border shadow-sm'>
|
||||
<CardHeader className='text-center'>
|
||||
<Heart className='mx-auto mb-2 h-12 w-12 text-red-500' />
|
||||
<CardTitle className='text-foreground'>We're sorry to see you go!</CardTitle>
|
||||
<CardDescription className='text-muted-foreground'>
|
||||
We understand email preferences are personal. Choose which emails you'd like to
|
||||
stop receiving from Sim.
|
||||
</CardDescription>
|
||||
<div className='mt-2 rounded-lg border bg-muted/50 p-3'>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Email: <span className='font-medium text-foreground'>{data?.email}</span>
|
||||
</p>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
<div className='space-y-3'>
|
||||
<Button
|
||||
onClick={() => handleUnsubscribe('all')}
|
||||
disabled={processing || data?.currentPreferences.unsubscribeAll}
|
||||
variant='destructive'
|
||||
className='w-full'
|
||||
>
|
||||
{data?.currentPreferences.unsubscribeAll ? (
|
||||
<CheckCircle className='mr-2 h-4 w-4' />
|
||||
) : null}
|
||||
{processing
|
||||
? 'Unsubscribing...'
|
||||
: data?.currentPreferences.unsubscribeAll
|
||||
? 'Unsubscribed from All Emails'
|
||||
: 'Unsubscribe from All Marketing Emails'}
|
||||
</Button>
|
||||
|
||||
<div className={`${inter.className} mt-8 w-full max-w-[410px] space-y-3`}>
|
||||
<BrandedButton
|
||||
onClick={() => handleUnsubscribe('all')}
|
||||
disabled={processing || isAlreadyUnsubscribedFromAll}
|
||||
loading={processing}
|
||||
loadingText='Unsubscribing'
|
||||
>
|
||||
{isAlreadyUnsubscribedFromAll
|
||||
? 'Unsubscribed from All Emails'
|
||||
: 'Unsubscribe from All Marketing Emails'}
|
||||
</BrandedButton>
|
||||
<div className='text-center text-muted-foreground text-sm'>
|
||||
or choose specific types:
|
||||
</div>
|
||||
|
||||
<div className='py-2 text-center'>
|
||||
<span className={`${inter.className} font-[380] text-[14px] text-muted-foreground`}>
|
||||
or choose specific types
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => handleUnsubscribe('marketing')}
|
||||
disabled={
|
||||
processing ||
|
||||
data?.currentPreferences.unsubscribeAll ||
|
||||
data?.currentPreferences.unsubscribeMarketing
|
||||
}
|
||||
variant='outline'
|
||||
className='w-full'
|
||||
>
|
||||
{data?.currentPreferences.unsubscribeMarketing ? (
|
||||
<CheckCircle className='mr-2 h-4 w-4' />
|
||||
) : null}
|
||||
{data?.currentPreferences.unsubscribeMarketing
|
||||
? 'Unsubscribed from Marketing'
|
||||
: 'Unsubscribe from Marketing Emails'}
|
||||
</Button>
|
||||
|
||||
<BrandedButton
|
||||
onClick={() => handleUnsubscribe('marketing')}
|
||||
disabled={
|
||||
processing ||
|
||||
isAlreadyUnsubscribedFromAll ||
|
||||
data?.currentPreferences.unsubscribeMarketing
|
||||
}
|
||||
>
|
||||
{data?.currentPreferences.unsubscribeMarketing
|
||||
? 'Unsubscribed from Marketing'
|
||||
: 'Unsubscribe from Marketing Emails'}
|
||||
</BrandedButton>
|
||||
<Button
|
||||
onClick={() => handleUnsubscribe('updates')}
|
||||
disabled={
|
||||
processing ||
|
||||
data?.currentPreferences.unsubscribeAll ||
|
||||
data?.currentPreferences.unsubscribeUpdates
|
||||
}
|
||||
variant='outline'
|
||||
className='w-full'
|
||||
>
|
||||
{data?.currentPreferences.unsubscribeUpdates ? (
|
||||
<CheckCircle className='mr-2 h-4 w-4' />
|
||||
) : null}
|
||||
{data?.currentPreferences.unsubscribeUpdates
|
||||
? 'Unsubscribed from Updates'
|
||||
: 'Unsubscribe from Product Updates'}
|
||||
</Button>
|
||||
|
||||
<BrandedButton
|
||||
onClick={() => handleUnsubscribe('updates')}
|
||||
disabled={
|
||||
processing ||
|
||||
isAlreadyUnsubscribedFromAll ||
|
||||
data?.currentPreferences.unsubscribeUpdates
|
||||
}
|
||||
>
|
||||
{data?.currentPreferences.unsubscribeUpdates
|
||||
? 'Unsubscribed from Updates'
|
||||
: 'Unsubscribe from Product Updates'}
|
||||
</BrandedButton>
|
||||
<Button
|
||||
onClick={() => handleUnsubscribe('notifications')}
|
||||
disabled={
|
||||
processing ||
|
||||
data?.currentPreferences.unsubscribeAll ||
|
||||
data?.currentPreferences.unsubscribeNotifications
|
||||
}
|
||||
variant='outline'
|
||||
className='w-full'
|
||||
>
|
||||
{data?.currentPreferences.unsubscribeNotifications ? (
|
||||
<CheckCircle className='mr-2 h-4 w-4' />
|
||||
) : null}
|
||||
{data?.currentPreferences.unsubscribeNotifications
|
||||
? 'Unsubscribed from Notifications'
|
||||
: 'Unsubscribe from Notifications'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<BrandedButton
|
||||
onClick={() => handleUnsubscribe('notifications')}
|
||||
disabled={
|
||||
processing ||
|
||||
isAlreadyUnsubscribedFromAll ||
|
||||
data?.currentPreferences.unsubscribeNotifications
|
||||
}
|
||||
>
|
||||
{data?.currentPreferences.unsubscribeNotifications
|
||||
? 'Unsubscribed from Notifications'
|
||||
: 'Unsubscribe from Notifications'}
|
||||
</BrandedButton>
|
||||
</div>
|
||||
<div className='mt-6 space-y-3'>
|
||||
<div className='rounded-lg border bg-muted/50 p-3'>
|
||||
<p className='text-center text-muted-foreground text-xs'>
|
||||
<strong>Note:</strong> You'll continue receiving important account emails like
|
||||
password resets and security alerts.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={`${inter.className} mt-6 max-w-[410px] text-center`}>
|
||||
<p className='font-[380] text-[13px] text-muted-foreground'>
|
||||
You'll continue receiving important account emails like password resets and security
|
||||
alerts.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SupportFooter position='absolute' />
|
||||
</InviteLayout>
|
||||
<p className='text-center text-muted-foreground text-xs'>
|
||||
Questions? Contact us at{' '}
|
||||
<a
|
||||
href={`mailto:${brand.supportEmail}`}
|
||||
className='text-muted-foreground hover:underline'
|
||||
>
|
||||
{brand.supportEmail}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -292,20 +391,13 @@ export default function Unsubscribe() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<InviteLayout>
|
||||
<div className='space-y-1 text-center'>
|
||||
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
|
||||
Loading
|
||||
</h1>
|
||||
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
|
||||
Validating your unsubscribe link...
|
||||
</p>
|
||||
</div>
|
||||
<div className={`${inter.className} mt-8 flex w-full items-center justify-center py-8`}>
|
||||
<Loader2 className='h-8 w-8 animate-spin text-muted-foreground' />
|
||||
</div>
|
||||
<SupportFooter position='absolute' />
|
||||
</InviteLayout>
|
||||
<div className='before:-z-50 relative flex min-h-screen items-center justify-center before:pointer-events-none before:fixed before:inset-0 before:bg-white'>
|
||||
<Card className='w-full max-w-md border shadow-sm'>
|
||||
<CardContent className='flex items-center justify-center p-8'>
|
||||
<Loader2 className='h-8 w-8 animate-spin text-muted-foreground' />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<UnsubscribeContent />
|
||||
|
||||
@@ -4,13 +4,13 @@ import type React from 'react'
|
||||
import { createContext, useContext, useEffect, useMemo, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
||||
import { useUserPermissions, type WorkspaceUserPermissions } from '@/hooks/use-user-permissions'
|
||||
import {
|
||||
useWorkspacePermissions,
|
||||
type WorkspacePermissions,
|
||||
} from '@/hooks/use-workspace-permissions'
|
||||
import { useNotificationStore } from '@/stores/notifications'
|
||||
import { useOperationQueueStore } from '@/stores/operation-queue/store'
|
||||
|
||||
const logger = createLogger('WorkspacePermissionsProvider')
|
||||
|
||||
@@ -64,8 +64,8 @@ export function WorkspacePermissionsProvider({ children }: WorkspacePermissionsP
|
||||
// Track whether we've already surfaced an offline notification to avoid duplicates
|
||||
const [hasShownOfflineNotification, setHasShownOfflineNotification] = useState(false)
|
||||
|
||||
// Get operation error state directly from the store (avoid full useCollaborativeWorkflow subscription)
|
||||
const hasOperationError = useOperationQueueStore((state) => state.hasOperationError)
|
||||
// Get operation error state from collaborative workflow
|
||||
const { hasOperationError } = useCollaborativeWorkflow()
|
||||
|
||||
const addNotification = useNotificationStore((state) => state.addNotification)
|
||||
|
||||
|
||||
@@ -48,17 +48,17 @@ export const ActionBar = memo(
|
||||
collaborativeBatchToggleBlockEnabled,
|
||||
collaborativeBatchToggleBlockHandles,
|
||||
} = useCollaborativeWorkflow()
|
||||
const { activeWorkflowId, setPendingSelection } = useWorkflowRegistry()
|
||||
const { activeWorkflowId } = useWorkflowRegistry()
|
||||
const blocks = useWorkflowStore((state) => state.blocks)
|
||||
const subBlockStore = useSubBlockStore()
|
||||
|
||||
const handleDuplicateBlock = useCallback(() => {
|
||||
const blocks = useWorkflowStore.getState().blocks
|
||||
const sourceBlock = blocks[blockId]
|
||||
if (!sourceBlock) return
|
||||
|
||||
const newId = crypto.randomUUID()
|
||||
const newName = getUniqueBlockName(sourceBlock.name, blocks)
|
||||
const subBlockValues =
|
||||
useSubBlockStore.getState().workflowValues[activeWorkflowId || '']?.[blockId] || {}
|
||||
const subBlockValues = subBlockStore.workflowValues[activeWorkflowId || '']?.[blockId] || {}
|
||||
|
||||
const { block, subBlockValues: filteredValues } = prepareDuplicateBlockState({
|
||||
sourceBlock,
|
||||
@@ -68,10 +68,18 @@ export const ActionBar = memo(
|
||||
subBlockValues,
|
||||
})
|
||||
|
||||
setPendingSelection([newId])
|
||||
collaborativeBatchAddBlocks([block], [], {}, {}, { [newId]: filteredValues })
|
||||
}, [blockId, activeWorkflowId, collaborativeBatchAddBlocks, setPendingSelection])
|
||||
}, [
|
||||
blockId,
|
||||
blocks,
|
||||
activeWorkflowId,
|
||||
subBlockStore.workflowValues,
|
||||
collaborativeBatchAddBlocks,
|
||||
])
|
||||
|
||||
/**
|
||||
* Optimized single store subscription for all block data
|
||||
*/
|
||||
const { isEnabled, horizontalHandles, parentId, parentType } = useWorkflowStore(
|
||||
useCallback(
|
||||
(state) => {
|
||||
|
||||
@@ -3,11 +3,13 @@ import ReactMarkdown from 'react-markdown'
|
||||
import type { NodeProps } from 'reactflow'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { BLOCK_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import { ActionBar } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/action-bar/action-bar'
|
||||
import { useBlockVisual } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
|
||||
import { useBlockDimensions } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-dimensions'
|
||||
import {
|
||||
BLOCK_DIMENSIONS,
|
||||
useBlockDimensions,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-dimensions'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import type { WorkflowBlockProps } from '../workflow-block/types'
|
||||
|
||||
@@ -166,17 +168,12 @@ const NoteMarkdown = memo(function NoteMarkdown({ content }: { content: string }
|
||||
)
|
||||
})
|
||||
|
||||
export const NoteBlock = memo(function NoteBlock({
|
||||
id,
|
||||
data,
|
||||
selected,
|
||||
}: NodeProps<NoteBlockNodeData>) {
|
||||
export const NoteBlock = memo(function NoteBlock({ id, data }: NodeProps<NoteBlockNodeData>) {
|
||||
const { type, config, name } = data
|
||||
|
||||
const { activeWorkflowId, isEnabled, handleClick, hasRing, ringStyles } = useBlockVisual({
|
||||
blockId: id,
|
||||
data,
|
||||
isSelected: selected,
|
||||
})
|
||||
const storedValues = useSubBlockStore(
|
||||
useCallback(
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -6,8 +6,6 @@ import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { Code, Tooltip } from '@/components/emcn'
|
||||
|
||||
const REMARK_PLUGINS = [remarkGfm]
|
||||
|
||||
/**
|
||||
* Recursively extracts text content from React elements
|
||||
* @param element - React node to extract text from
|
||||
@@ -151,12 +149,14 @@ interface CopilotMarkdownRendererProps {
|
||||
* Tighter spacing compared to traditional prose for better chat UX
|
||||
*/
|
||||
const markdownComponents = {
|
||||
// Paragraphs - tight spacing, no margin on last
|
||||
p: ({ children }: React.HTMLAttributes<HTMLParagraphElement>) => (
|
||||
<p className='mb-1.5 font-base font-season text-[var(--text-primary)] text-sm leading-[1.4] last:mb-0 dark:font-[470]'>
|
||||
{children}
|
||||
</p>
|
||||
),
|
||||
|
||||
// Headings - minimal margins for chat context
|
||||
h1: ({ children }: React.HTMLAttributes<HTMLHeadingElement>) => (
|
||||
<h1 className='mt-2 mb-1 font-season font-semibold text-[var(--text-primary)] text-base first:mt-0'>
|
||||
{children}
|
||||
@@ -178,6 +178,7 @@ const markdownComponents = {
|
||||
</h4>
|
||||
),
|
||||
|
||||
// Lists - compact spacing
|
||||
ul: ({ children }: React.HTMLAttributes<HTMLUListElement>) => (
|
||||
<ul
|
||||
className='my-1 space-y-0.5 pl-5 font-base font-season text-[var(--text-primary)] dark:font-[470]'
|
||||
@@ -203,6 +204,7 @@ const markdownComponents = {
|
||||
</li>
|
||||
),
|
||||
|
||||
// Code blocks - handled by CodeBlock component
|
||||
pre: ({ children }: React.HTMLAttributes<HTMLPreElement>) => {
|
||||
let codeContent: React.ReactNode = children
|
||||
let language = 'code'
|
||||
@@ -241,6 +243,7 @@ const markdownComponents = {
|
||||
return <CodeBlock code={actualCodeText} language={language} />
|
||||
},
|
||||
|
||||
// Inline code
|
||||
code: ({
|
||||
className,
|
||||
children,
|
||||
@@ -254,6 +257,7 @@ const markdownComponents = {
|
||||
</code>
|
||||
),
|
||||
|
||||
// Text formatting
|
||||
strong: ({ children }: React.HTMLAttributes<HTMLElement>) => (
|
||||
<strong className='font-semibold text-[var(--text-primary)]'>{children}</strong>
|
||||
),
|
||||
@@ -267,18 +271,22 @@ const markdownComponents = {
|
||||
<i className='text-[var(--text-primary)] italic'>{children}</i>
|
||||
),
|
||||
|
||||
// Blockquote - compact
|
||||
blockquote: ({ children }: React.HTMLAttributes<HTMLQuoteElement>) => (
|
||||
<blockquote className='my-1.5 border-[var(--border-1)] border-l-2 py-0.5 pl-3 font-season text-[var(--text-secondary)] text-sm italic'>
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
|
||||
// Horizontal rule
|
||||
hr: () => <hr className='my-3 border-[var(--divider)] border-t' />,
|
||||
|
||||
// Links
|
||||
a: ({ href, children }: React.AnchorHTMLAttributes<HTMLAnchorElement>) => (
|
||||
<LinkWithPreview href={href || '#'}>{children}</LinkWithPreview>
|
||||
),
|
||||
|
||||
// Tables - compact
|
||||
table: ({ children }: React.TableHTMLAttributes<HTMLTableElement>) => (
|
||||
<div className='my-2 max-w-full overflow-x-auto'>
|
||||
<table className='min-w-full table-auto border border-[var(--border-1)] font-season text-xs'>
|
||||
@@ -306,6 +314,7 @@ const markdownComponents = {
|
||||
</td>
|
||||
),
|
||||
|
||||
// Images
|
||||
img: ({ src, alt, ...props }: React.ImgHTMLAttributes<HTMLImageElement>) => (
|
||||
<img src={src} alt={alt || 'Image'} className='my-2 h-auto max-w-full rounded-md' {...props} />
|
||||
),
|
||||
@@ -321,7 +330,7 @@ const markdownComponents = {
|
||||
function CopilotMarkdownRenderer({ content }: CopilotMarkdownRendererProps) {
|
||||
return (
|
||||
<div className='max-w-full break-words font-base font-season text-[var(--text-primary)] text-sm leading-[1.4] dark:font-[470] [&_*]:max-w-full [&_a]:break-all [&_code:not(pre_code)]:break-words [&_li]:break-words [&_p]:break-words'>
|
||||
<ReactMarkdown remarkPlugins={REMARK_PLUGINS} components={markdownComponents}>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
@@ -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,4 @@
|
||||
export { useCheckpointManagement } from './use-checkpoint-management'
|
||||
export { useMessageContentAnalysis } from './use-message-content-analysis'
|
||||
export { useMessageEditing } from './use-message-editing'
|
||||
export { useMessageFeedback } from './use-message-feedback'
|
||||
export { useSuccessTimers } from './use-success-timers'
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import type { CopilotMessage } from '@/stores/panel'
|
||||
import { useCopilotStore } from '@/stores/panel'
|
||||
|
||||
const logger = createLogger('useMessageFeedback')
|
||||
|
||||
const WORKFLOW_TOOL_NAMES = ['edit_workflow']
|
||||
|
||||
interface UseMessageFeedbackProps {
|
||||
setShowUpvoteSuccess: (show: boolean) => void
|
||||
setShowDownvoteSuccess: (show: boolean) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook to handle message feedback (upvote/downvote)
|
||||
*
|
||||
* @param message - The copilot message
|
||||
* @param messages - Array of all messages in the chat
|
||||
* @param props - Success state setters from useSuccessTimers
|
||||
* @returns Feedback management utilities
|
||||
*/
|
||||
export function useMessageFeedback(
|
||||
message: CopilotMessage,
|
||||
messages: CopilotMessage[],
|
||||
props: UseMessageFeedbackProps
|
||||
) {
|
||||
const { setShowUpvoteSuccess, setShowDownvoteSuccess } = props
|
||||
const { currentChat } = useCopilotStore()
|
||||
|
||||
/**
|
||||
* Gets the full assistant response content from message
|
||||
*/
|
||||
const getFullAssistantContent = useCallback((message: CopilotMessage) => {
|
||||
if (message.content?.trim()) {
|
||||
return message.content
|
||||
}
|
||||
|
||||
if (message.contentBlocks && message.contentBlocks.length > 0) {
|
||||
return message.contentBlocks
|
||||
.filter((block) => block.type === 'text')
|
||||
.map((block) => block.content)
|
||||
.join('')
|
||||
}
|
||||
|
||||
return message.content || ''
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Finds the last user query before this assistant message
|
||||
*/
|
||||
const getLastUserQuery = useCallback(() => {
|
||||
const messageIndex = messages.findIndex((msg) => msg.id === message.id)
|
||||
if (messageIndex === -1) return null
|
||||
|
||||
for (let i = messageIndex - 1; i >= 0; i--) {
|
||||
if (messages[i].role === 'user') {
|
||||
return messages[i].content
|
||||
}
|
||||
}
|
||||
return null
|
||||
}, [messages, message.id])
|
||||
|
||||
/**
|
||||
* Submits feedback to the API
|
||||
*/
|
||||
const submitFeedback = useCallback(
|
||||
async (isPositive: boolean) => {
|
||||
if (!currentChat?.id) {
|
||||
logger.error('No current chat ID available for feedback submission')
|
||||
return
|
||||
}
|
||||
|
||||
const userQuery = getLastUserQuery()
|
||||
if (!userQuery) {
|
||||
logger.error('No user query found for feedback submission')
|
||||
return
|
||||
}
|
||||
|
||||
const agentResponse = getFullAssistantContent(message)
|
||||
if (!agentResponse.trim()) {
|
||||
logger.error('No agent response content available for feedback submission')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const requestBody = {
|
||||
chatId: currentChat.id,
|
||||
userQuery,
|
||||
agentResponse,
|
||||
isPositiveFeedback: isPositive,
|
||||
}
|
||||
|
||||
const response = await fetch('/api/copilot/feedback', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to submit feedback: ${response.statusText}`)
|
||||
}
|
||||
|
||||
await response.json()
|
||||
} catch (error) {
|
||||
logger.error('Error submitting feedback:', error)
|
||||
}
|
||||
},
|
||||
[currentChat, getLastUserQuery, getFullAssistantContent, message]
|
||||
)
|
||||
|
||||
/**
|
||||
* Handles upvote action
|
||||
*/
|
||||
const handleUpvote = useCallback(async () => {
|
||||
setShowDownvoteSuccess(false)
|
||||
setShowUpvoteSuccess(true)
|
||||
await submitFeedback(true)
|
||||
}, [submitFeedback])
|
||||
|
||||
/**
|
||||
* Handles downvote action
|
||||
*/
|
||||
const handleDownvote = useCallback(async () => {
|
||||
setShowUpvoteSuccess(false)
|
||||
setShowDownvoteSuccess(true)
|
||||
await submitFeedback(false)
|
||||
}, [submitFeedback])
|
||||
|
||||
return {
|
||||
handleUpvote,
|
||||
handleDownvote,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
/**
|
||||
* Duration to show success indicators (in milliseconds)
|
||||
*/
|
||||
const SUCCESS_DISPLAY_DURATION = 2000
|
||||
|
||||
/**
|
||||
* Custom hook to manage auto-hiding success states
|
||||
* Automatically hides success indicators after a set duration
|
||||
*
|
||||
* @returns Success state management utilities
|
||||
*/
|
||||
export function useSuccessTimers() {
|
||||
const [showCopySuccess, setShowCopySuccess] = useState(false)
|
||||
const [showUpvoteSuccess, setShowUpvoteSuccess] = useState(false)
|
||||
const [showDownvoteSuccess, setShowDownvoteSuccess] = useState(false)
|
||||
|
||||
/**
|
||||
* Auto-hide copy success indicator after duration
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (showCopySuccess) {
|
||||
const timer = setTimeout(() => {
|
||||
setShowCopySuccess(false)
|
||||
}, SUCCESS_DISPLAY_DURATION)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [showCopySuccess])
|
||||
|
||||
/**
|
||||
* Auto-hide upvote success indicator after duration
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (showUpvoteSuccess) {
|
||||
const timer = setTimeout(() => {
|
||||
setShowUpvoteSuccess(false)
|
||||
}, SUCCESS_DISPLAY_DURATION)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [showUpvoteSuccess])
|
||||
|
||||
/**
|
||||
* Auto-hide downvote success indicator after duration
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (showDownvoteSuccess) {
|
||||
const timer = setTimeout(() => {
|
||||
setShowDownvoteSuccess(false)
|
||||
}, SUCCESS_DISPLAY_DURATION)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [showDownvoteSuccess])
|
||||
|
||||
/**
|
||||
* Handles copy to clipboard action
|
||||
* @param content - Content to copy to clipboard
|
||||
*/
|
||||
const handleCopy = useCallback((content: string) => {
|
||||
navigator.clipboard.writeText(content)
|
||||
setShowCopySuccess(true)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
// State
|
||||
showCopySuccess,
|
||||
showUpvoteSuccess,
|
||||
showDownvoteSuccess,
|
||||
|
||||
// Operations
|
||||
handleCopy,
|
||||
setShowUpvoteSuccess,
|
||||
setShowDownvoteSuccess,
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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(() => {
|
||||
|
||||
@@ -151,7 +151,7 @@ export function useMentionData(props: UseMentionDataProps): MentionDataReturn {
|
||||
useShallow(useCallback((state) => Object.keys(state.blocks), []))
|
||||
)
|
||||
|
||||
const registryWorkflows = useWorkflowRegistry(useShallow((state) => state.workflows))
|
||||
const registryWorkflows = useWorkflowRegistry((state) => state.workflows)
|
||||
const hydrationPhase = useWorkflowRegistry((state) => state.hydration.phase)
|
||||
const isLoadingWorkflows =
|
||||
hydrationPhase === 'idle' ||
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { useCallback } from 'react'
|
||||
import type { useMentionMenu } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-menu'
|
||||
import type { ChatContext } from '@/stores/panel'
|
||||
|
||||
@@ -39,11 +39,11 @@ export function useMentionTokens({
|
||||
setSelectedContexts,
|
||||
}: UseMentionTokensProps) {
|
||||
/**
|
||||
* Memoized mention ranges - computed once when message or selectedContexts change.
|
||||
* This prevents expensive O(n×m) string searches from running on every keystroke
|
||||
* when other callbacks access the ranges.
|
||||
* Computes all mention ranges in the message (both @mentions and /commands)
|
||||
*
|
||||
* @returns Array of mention ranges sorted by start position
|
||||
*/
|
||||
const memoizedMentionRanges = useMemo((): MentionRange[] => {
|
||||
const computeMentionRanges = useCallback((): MentionRange[] => {
|
||||
const ranges: MentionRange[] = []
|
||||
if (!message || selectedContexts.length === 0) return ranges
|
||||
|
||||
@@ -93,45 +93,35 @@ export function useMentionTokens({
|
||||
|
||||
/**
|
||||
* Finds a mention range containing the given position
|
||||
*/
|
||||
const computeMentionRanges = useCallback(
|
||||
(): MentionRange[] => memoizedMentionRanges,
|
||||
[memoizedMentionRanges]
|
||||
)
|
||||
|
||||
/**
|
||||
* Finds a mention range containing the given position.
|
||||
* Uses memoized ranges directly for better performance.
|
||||
*
|
||||
* @param pos - Position to check
|
||||
* @returns Mention range if found, undefined otherwise
|
||||
*/
|
||||
const findRangeContaining = useCallback(
|
||||
(pos: number): MentionRange | undefined => {
|
||||
return memoizedMentionRanges.find((r) => pos > r.start && pos < r.end)
|
||||
const ranges = computeMentionRanges()
|
||||
return ranges.find((r) => pos > r.start && pos < r.end)
|
||||
},
|
||||
[memoizedMentionRanges]
|
||||
[computeMentionRanges]
|
||||
)
|
||||
|
||||
/**
|
||||
* Removes contexts for mention tokens that overlap with a text selection.
|
||||
* Uses memoized ranges directly for better performance.
|
||||
* Removes contexts for mention tokens that overlap with a text selection
|
||||
*
|
||||
* @param selStart - Selection start position
|
||||
* @param selEnd - Selection end position
|
||||
*/
|
||||
const removeContextsInSelection = useCallback(
|
||||
(selStart: number, selEnd: number) => {
|
||||
const overlappingRanges = memoizedMentionRanges.filter(
|
||||
(r) => !(selEnd <= r.start || selStart >= r.end)
|
||||
)
|
||||
const ranges = computeMentionRanges()
|
||||
const overlappingRanges = ranges.filter((r) => !(selEnd <= r.start || selStart >= r.end))
|
||||
|
||||
if (overlappingRanges.length > 0) {
|
||||
const labelsToRemove = new Set(overlappingRanges.map((r) => r.label))
|
||||
setSelectedContexts((prev) => prev.filter((c) => !c.label || !labelsToRemove.has(c.label)))
|
||||
}
|
||||
},
|
||||
[memoizedMentionRanges, setSelectedContexts]
|
||||
[computeMentionRanges, setSelectedContexts]
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -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) {
|
||||
@@ -659,13 +655,6 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
[insertTriggerAndOpenMenu]
|
||||
)
|
||||
|
||||
const handleModelSelect = useCallback(
|
||||
(model: string) => {
|
||||
setSelectedModel(model as CopilotModelId)
|
||||
},
|
||||
[setSelectedModel]
|
||||
)
|
||||
|
||||
const canSubmit = message.trim().length > 0 && !disabled && !isLoading
|
||||
const showAbortButton = isLoading && onAbort
|
||||
|
||||
@@ -681,17 +670,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 +687,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 +706,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 +848,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={(model: string) => setSelectedModel(model as any)}
|
||||
/>
|
||||
</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 &&
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
ButtonGroupItem,
|
||||
Checkbox,
|
||||
Code,
|
||||
Combobox,
|
||||
type ComboboxOption,
|
||||
Input,
|
||||
Label,
|
||||
TagInput,
|
||||
@@ -269,6 +271,14 @@ export function A2aDeploy({
|
||||
onNeedsRepublishChange?.(!!needsRepublish)
|
||||
}, [needsRepublish, onNeedsRepublishChange])
|
||||
|
||||
const authSchemeOptions: ComboboxOption[] = useMemo(
|
||||
() => [
|
||||
{ label: 'API Key', value: 'apiKey' },
|
||||
{ label: 'None (Public)', value: 'none' },
|
||||
],
|
||||
[]
|
||||
)
|
||||
|
||||
const canSave = name.trim().length > 0 && description.trim().length > 0
|
||||
useEffect(() => {
|
||||
onCanSaveChange?.(canSave)
|
||||
@@ -748,18 +758,17 @@ console.log(data);`
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Access */}
|
||||
{/* Authentication */}
|
||||
<div>
|
||||
<Label className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
|
||||
Access
|
||||
Authentication
|
||||
</Label>
|
||||
<ButtonGroup
|
||||
<Combobox
|
||||
options={authSchemeOptions}
|
||||
value={authScheme}
|
||||
onValueChange={(value) => setAuthScheme(value as AuthScheme)}
|
||||
>
|
||||
<ButtonGroupItem value='apiKey'>API Key</ButtonGroupItem>
|
||||
<ButtonGroupItem value='none'>Public</ButtonGroupItem>
|
||||
</ButtonGroup>
|
||||
onChange={(v) => setAuthScheme(v as AuthScheme)}
|
||||
placeholder='Select authentication...'
|
||||
/>
|
||||
<p className='mt-[6.5px] text-[11px] text-[var(--text-secondary)]'>
|
||||
{authScheme === 'none'
|
||||
? 'Anyone can call this agent without authentication'
|
||||
|
||||
@@ -409,11 +409,7 @@ export function ChatDeploy({
|
||||
<ModalHeader>Delete Chat</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className='text-[12px] text-[var(--text-secondary)]'>
|
||||
Are you sure you want to delete{' '}
|
||||
<span className='font-medium text-[var(--text-primary)]'>
|
||||
{existingChat?.title || 'this chat'}
|
||||
</span>
|
||||
?{' '}
|
||||
Are you sure you want to delete this chat?{' '}
|
||||
<span className='text-[var(--text-error)]'>
|
||||
This will remove the chat at "{getEmailDomain()}/chat/{existingChat?.identifier}"
|
||||
and make it unavailable to all users.
|
||||
@@ -428,7 +424,7 @@ export function ChatDeploy({
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant='destructive' onClick={handleDelete} disabled={isDeleting}>
|
||||
<Button variant='default' onClick={handleDelete} disabled={isDeleting}>
|
||||
{isDeleting ? 'Deleting...' : 'Delete'}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
|
||||
@@ -375,11 +375,8 @@ export function TemplateDeploy({
|
||||
<ModalHeader>Delete Template</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className='text-[12px] text-[var(--text-secondary)]'>
|
||||
Are you sure you want to delete{' '}
|
||||
<span className='font-medium text-[var(--text-primary)]'>
|
||||
{existingTemplate?.name || formData.name || 'this template'}
|
||||
</span>
|
||||
? <span className='text-[var(--text-error)]'>This action cannot be undone.</span>
|
||||
Are you sure you want to delete this template?{' '}
|
||||
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
|
||||
</p>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
|
||||
@@ -846,11 +846,7 @@ export function DeployModal({
|
||||
<ModalHeader>Delete A2A Agent</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className='text-[12px] text-[var(--text-secondary)]'>
|
||||
Are you sure you want to delete{' '}
|
||||
<span className='font-medium text-[var(--text-primary)]'>
|
||||
{existingA2aAgent?.name || 'this agent'}
|
||||
</span>
|
||||
?{' '}
|
||||
Are you sure you want to delete this agent?{' '}
|
||||
<span className='text-[var(--text-error)]'>
|
||||
This will permanently remove the agent configuration.
|
||||
</span>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ReactElement } from 'react'
|
||||
import { memo, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
|
||||
import { useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
|
||||
import { Check, Copy, Wand2 } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import 'prismjs/components/prism-python'
|
||||
@@ -170,7 +170,7 @@ interface CodeProps {
|
||||
hideInternalWand?: boolean
|
||||
}
|
||||
|
||||
export const Code = memo(function Code({
|
||||
export function Code({
|
||||
blockId,
|
||||
subBlockId,
|
||||
placeholder = 'Write JavaScript...',
|
||||
@@ -206,8 +206,6 @@ export const Code = memo(function Code({
|
||||
const handleGeneratedContentRef = useRef<(generatedCode: string) => void>(() => {})
|
||||
const handleStreamChunkRef = useRef<(chunk: string) => void>(() => {})
|
||||
const hasEditedSinceFocusRef = useRef(false)
|
||||
const codeRef = useRef(code)
|
||||
codeRef.current = code
|
||||
|
||||
const accessiblePrefixes = useAccessibleReferencePrefixes(blockId)
|
||||
const emitTagSelection = useTagSelection(blockId, subBlockId)
|
||||
@@ -309,18 +307,25 @@ export const Code = memo(function Code({
|
||||
? getDefaultValueString()
|
||||
: storeValue
|
||||
|
||||
const lastValidationStatus = useRef<boolean>(true)
|
||||
|
||||
useEffect(() => {
|
||||
if (!onValidationChange) return
|
||||
|
||||
const isValid = !shouldValidateJson || isValidJson
|
||||
const nextStatus = shouldValidateJson ? isValidJson : true
|
||||
if (lastValidationStatus.current === nextStatus) {
|
||||
return
|
||||
}
|
||||
|
||||
if (isValid) {
|
||||
onValidationChange(true)
|
||||
lastValidationStatus.current = nextStatus
|
||||
|
||||
if (!shouldValidateJson) {
|
||||
onValidationChange(nextStatus)
|
||||
return
|
||||
}
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
onValidationChange(false)
|
||||
onValidationChange(nextStatus)
|
||||
}, 150)
|
||||
|
||||
return () => clearTimeout(timeoutId)
|
||||
@@ -332,7 +337,7 @@ export const Code = memo(function Code({
|
||||
}
|
||||
|
||||
handleStreamChunkRef.current = (chunk: string) => {
|
||||
setCode((prev: string) => prev + chunk)
|
||||
setCode((prev) => prev + chunk)
|
||||
}
|
||||
|
||||
handleGeneratedContentRef.current = (generatedCode: string) => {
|
||||
@@ -429,12 +434,12 @@ export const Code = memo(function Code({
|
||||
`
|
||||
document.body.appendChild(tempContainer)
|
||||
|
||||
lines.forEach((line: string) => {
|
||||
lines.forEach((line) => {
|
||||
const lineDiv = document.createElement('div')
|
||||
|
||||
if (line.includes('<') && line.includes('>')) {
|
||||
const parts = line.split(/(<[^>]+>)/g)
|
||||
parts.forEach((part: string) => {
|
||||
parts.forEach((part) => {
|
||||
const span = document.createElement('span')
|
||||
span.textContent = part
|
||||
lineDiv.appendChild(span)
|
||||
@@ -467,6 +472,7 @@ export const Code = memo(function Code({
|
||||
}
|
||||
}, [code])
|
||||
|
||||
// Event Handlers
|
||||
/**
|
||||
* Handles drag-and-drop events for inserting reference tags into the code editor.
|
||||
* @param e - The drag event
|
||||
@@ -494,6 +500,7 @@ export const Code = memo(function Code({
|
||||
textarea.selectionStart = newCursorPosition
|
||||
textarea.selectionEnd = newCursorPosition
|
||||
|
||||
// Show tag dropdown after cursor is positioned
|
||||
setShowTags(true)
|
||||
if (data.connectionData?.sourceBlockId) {
|
||||
setActiveSourceBlockId(data.connectionData.sourceBlockId)
|
||||
@@ -552,45 +559,44 @@ export const Code = memo(function Code({
|
||||
}
|
||||
}
|
||||
|
||||
// Helper Functions
|
||||
/**
|
||||
* Determines whether a `<...>` segment should be highlighted as a reference.
|
||||
* @param part - The code segment to check
|
||||
* @returns True if the segment should be highlighted as a reference
|
||||
*/
|
||||
const shouldHighlightReference = useCallback(
|
||||
(part: string): boolean => {
|
||||
if (!part.startsWith('<') || !part.endsWith('>')) {
|
||||
return false
|
||||
}
|
||||
const shouldHighlightReference = (part: string): boolean => {
|
||||
if (!part.startsWith('<') || !part.endsWith('>')) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!isLikelyReferenceSegment(part)) {
|
||||
return false
|
||||
}
|
||||
if (!isLikelyReferenceSegment(part)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const split = splitReferenceSegment(part)
|
||||
if (!split) {
|
||||
return false
|
||||
}
|
||||
const split = splitReferenceSegment(part)
|
||||
if (!split) {
|
||||
return false
|
||||
}
|
||||
|
||||
const reference = split.reference
|
||||
const reference = split.reference
|
||||
|
||||
if (!accessiblePrefixes) {
|
||||
return true
|
||||
}
|
||||
if (!accessiblePrefixes) {
|
||||
return true
|
||||
}
|
||||
|
||||
const inner = reference.slice(1, -1)
|
||||
const [prefix] = inner.split('.')
|
||||
const normalizedPrefix = normalizeName(prefix)
|
||||
const inner = reference.slice(1, -1)
|
||||
const [prefix] = inner.split('.')
|
||||
const normalizedPrefix = normalizeName(prefix)
|
||||
|
||||
if (SYSTEM_REFERENCE_PREFIXES.has(normalizedPrefix)) {
|
||||
return true
|
||||
}
|
||||
if (SYSTEM_REFERENCE_PREFIXES.has(normalizedPrefix)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return accessiblePrefixes.has(normalizedPrefix)
|
||||
},
|
||||
[accessiblePrefixes]
|
||||
)
|
||||
return accessiblePrefixes.has(normalizedPrefix)
|
||||
}
|
||||
|
||||
// Expose wand control handlers to parent via ref
|
||||
useImperativeHandle(
|
||||
wandControlRef,
|
||||
() => ({
|
||||
@@ -603,62 +609,6 @@ export const Code = memo(function Code({
|
||||
[generateCodeStream, isPromptVisible, isAiStreaming]
|
||||
)
|
||||
|
||||
const highlightCode = useMemo(
|
||||
() => createHighlightFunction(effectiveLanguage, shouldHighlightReference),
|
||||
[effectiveLanguage, shouldHighlightReference]
|
||||
)
|
||||
|
||||
const handleValueChange = useCallback(
|
||||
(newCode: string) => {
|
||||
if (!isAiStreaming && !isPreview && !disabled && !readOnly) {
|
||||
hasEditedSinceFocusRef.current = true
|
||||
setCode(newCode)
|
||||
setStoreValue(newCode)
|
||||
|
||||
const textarea = editorRef.current?.querySelector('textarea')
|
||||
if (textarea) {
|
||||
const pos = textarea.selectionStart
|
||||
setCursorPosition(pos)
|
||||
|
||||
const tagTrigger = checkTagTrigger(newCode, pos)
|
||||
setShowTags(tagTrigger.show)
|
||||
if (!tagTrigger.show) {
|
||||
setActiveSourceBlockId(null)
|
||||
}
|
||||
|
||||
const envVarTrigger = checkEnvVarTrigger(newCode, pos)
|
||||
setShowEnvVars(envVarTrigger.show)
|
||||
setSearchTerm(envVarTrigger.show ? envVarTrigger.searchTerm : '')
|
||||
}
|
||||
}
|
||||
},
|
||||
[isAiStreaming, isPreview, disabled, readOnly, setStoreValue]
|
||||
)
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLDivElement | HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Escape') {
|
||||
setShowTags(false)
|
||||
setShowEnvVars(false)
|
||||
}
|
||||
if (isAiStreaming) {
|
||||
e.preventDefault()
|
||||
}
|
||||
if (e.key === 'z' && (e.metaKey || e.ctrlKey) && !hasEditedSinceFocusRef.current) {
|
||||
e.preventDefault()
|
||||
}
|
||||
},
|
||||
[isAiStreaming]
|
||||
)
|
||||
|
||||
const handleEditorFocus = useCallback(() => {
|
||||
hasEditedSinceFocusRef.current = false
|
||||
if (!isPreview && !disabled && !readOnly && codeRef.current.trim() === '') {
|
||||
setShowTags(true)
|
||||
setCursorPosition(0)
|
||||
}
|
||||
}, [isPreview, disabled, readOnly])
|
||||
|
||||
/**
|
||||
* Renders the line numbers, aligned with wrapped visual lines and highlighting the active line.
|
||||
* @returns Array of React elements representing the line numbers
|
||||
@@ -667,7 +617,7 @@ export const Code = memo(function Code({
|
||||
const numbers: ReactElement[] = []
|
||||
let lineNumber = 1
|
||||
|
||||
visualLineHeights.forEach((height: number) => {
|
||||
visualLineHeights.forEach((height) => {
|
||||
const isActive = lineNumber === activeLineNumber
|
||||
numbers.push(
|
||||
<div
|
||||
@@ -774,10 +724,50 @@ export const Code = memo(function Code({
|
||||
|
||||
<Editor
|
||||
value={code}
|
||||
onValueChange={handleValueChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={handleEditorFocus}
|
||||
highlight={highlightCode}
|
||||
onValueChange={(newCode) => {
|
||||
if (!isAiStreaming && !isPreview && !disabled && !readOnly) {
|
||||
hasEditedSinceFocusRef.current = true
|
||||
setCode(newCode)
|
||||
setStoreValue(newCode)
|
||||
|
||||
const textarea = editorRef.current?.querySelector('textarea')
|
||||
if (textarea) {
|
||||
const pos = textarea.selectionStart
|
||||
setCursorPosition(pos)
|
||||
|
||||
const tagTrigger = checkTagTrigger(newCode, pos)
|
||||
setShowTags(tagTrigger.show)
|
||||
if (!tagTrigger.show) {
|
||||
setActiveSourceBlockId(null)
|
||||
}
|
||||
|
||||
const envVarTrigger = checkEnvVarTrigger(newCode, pos)
|
||||
setShowEnvVars(envVarTrigger.show)
|
||||
setSearchTerm(envVarTrigger.show ? envVarTrigger.searchTerm : '')
|
||||
}
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
setShowTags(false)
|
||||
setShowEnvVars(false)
|
||||
}
|
||||
if (isAiStreaming) {
|
||||
e.preventDefault()
|
||||
}
|
||||
if (e.key === 'z' && (e.metaKey || e.ctrlKey) && !hasEditedSinceFocusRef.current) {
|
||||
e.preventDefault()
|
||||
}
|
||||
}}
|
||||
onFocus={() => {
|
||||
hasEditedSinceFocusRef.current = false
|
||||
// Show tag dropdown on focus when code is empty
|
||||
if (!isPreview && !disabled && !readOnly && code.trim() === '') {
|
||||
setShowTags(true)
|
||||
setCursorPosition(0)
|
||||
}
|
||||
}}
|
||||
highlight={createHighlightFunction(effectiveLanguage, shouldHighlightReference)}
|
||||
{...getCodeEditorProps({ isStreaming: isAiStreaming, isPreview, disabled })}
|
||||
/>
|
||||
|
||||
@@ -820,4 +810,4 @@ export const Code = memo(function Code({
|
||||
</CodeEditor.Container>
|
||||
</>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { isEqual } from 'lodash'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useReactFlow } from 'reactflow'
|
||||
import { Combobox, type ComboboxOption } from '@/components/emcn/components'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
@@ -72,7 +71,7 @@ interface ComboBoxProps {
|
||||
dependsOn?: SubBlockConfig['dependsOn']
|
||||
}
|
||||
|
||||
export const ComboBox = memo(function ComboBox({
|
||||
export function ComboBox({
|
||||
options,
|
||||
defaultValue,
|
||||
blockId,
|
||||
@@ -113,8 +112,7 @@ export const ComboBox = memo(function ComboBox({
|
||||
)
|
||||
},
|
||||
[dependsOnFields, activeWorkflowId, blockId, canonicalIndex, canonicalModeOverrides]
|
||||
),
|
||||
isEqual
|
||||
)
|
||||
)
|
||||
|
||||
// State management
|
||||
@@ -283,17 +281,34 @@ export const ComboBox = memo(function ComboBox({
|
||||
setStoreInitialized(true)
|
||||
}, [])
|
||||
|
||||
// Check if current value is valid (exists in allowed options)
|
||||
const isValueValid = useMemo(() => {
|
||||
if (value === null || value === undefined) return false
|
||||
return evaluatedOptions.some((opt) => getOptionValue(opt) === value)
|
||||
}, [value, evaluatedOptions, getOptionValue])
|
||||
|
||||
// Set default value once store is initialized and permissions are loaded
|
||||
// Also reset if current value becomes invalid (e.g., provider was blocked)
|
||||
useEffect(() => {
|
||||
if (isPermissionLoading) return
|
||||
if (!storeInitialized) return
|
||||
if (defaultOptionValue === undefined) return
|
||||
|
||||
// Only set default when no value exists (initial block add)
|
||||
if (value === null || value === undefined) {
|
||||
const needsDefault = value === null || value === undefined
|
||||
const needsReset = subBlockId === 'model' && value && !isValueValid
|
||||
|
||||
if (needsDefault || needsReset) {
|
||||
setStoreValue(defaultOptionValue)
|
||||
}
|
||||
}, [storeInitialized, value, defaultOptionValue, setStoreValue, isPermissionLoading])
|
||||
}, [
|
||||
storeInitialized,
|
||||
value,
|
||||
defaultOptionValue,
|
||||
setStoreValue,
|
||||
isPermissionLoading,
|
||||
subBlockId,
|
||||
isValueValid,
|
||||
])
|
||||
|
||||
// Clear fetched options and hydrated option when dependencies change
|
||||
useEffect(() => {
|
||||
@@ -422,18 +437,6 @@ export const ComboBox = memo(function ComboBox({
|
||||
[reactFlowInstance]
|
||||
)
|
||||
|
||||
/**
|
||||
* Handles combobox open state changes to trigger option fetching
|
||||
*/
|
||||
const handleOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
if (open) {
|
||||
void fetchOptionsIfNeeded()
|
||||
}
|
||||
},
|
||||
[fetchOptionsIfNeeded]
|
||||
)
|
||||
|
||||
/**
|
||||
* Gets the icon for the currently selected option
|
||||
*/
|
||||
@@ -463,75 +466,6 @@ export const ComboBox = memo(function ComboBox({
|
||||
)
|
||||
}, [inputValue, accessiblePrefixes, selectedOption, selectedOptionIcon])
|
||||
|
||||
const ctrlOnChangeRef = useRef<
|
||||
((e: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) => void) | null
|
||||
>(null)
|
||||
const onDropRef = useRef<
|
||||
((e: React.DragEvent<HTMLTextAreaElement | HTMLInputElement>) => void) | null
|
||||
>(null)
|
||||
const onDragOverRef = useRef<
|
||||
((e: React.DragEvent<HTMLTextAreaElement | HTMLInputElement>) => void) | null
|
||||
>(null)
|
||||
const inputRefFromController = useRef<HTMLInputElement | null>(null)
|
||||
|
||||
const comboboxOnChange = useCallback(
|
||||
(newValue: string) => {
|
||||
const matchedComboboxOption = comboboxOptions.find((option) => option.value === newValue)
|
||||
if (matchedComboboxOption) {
|
||||
setInputValue(matchedComboboxOption.label)
|
||||
} else {
|
||||
setInputValue(newValue)
|
||||
}
|
||||
|
||||
// Use controller's handler so env vars, tags, and DnD still work
|
||||
const syntheticEvent = {
|
||||
target: { value: newValue, selectionStart: newValue.length },
|
||||
} as React.ChangeEvent<HTMLInputElement>
|
||||
ctrlOnChangeRef.current?.(syntheticEvent)
|
||||
},
|
||||
[comboboxOptions, setInputValue]
|
||||
)
|
||||
|
||||
const comboboxInputProps = useMemo(
|
||||
() => ({
|
||||
onDrop: ((e: React.DragEvent<HTMLInputElement>) => {
|
||||
onDropRef.current?.(e)
|
||||
}) as (e: React.DragEvent<HTMLInputElement>) => void,
|
||||
onDragOver: ((e: React.DragEvent<HTMLInputElement>) => {
|
||||
onDragOverRef.current?.(e)
|
||||
}) as (e: React.DragEvent<HTMLInputElement>) => void,
|
||||
onWheel: handleWheel,
|
||||
autoComplete: 'off' as const,
|
||||
}),
|
||||
[handleWheel]
|
||||
)
|
||||
|
||||
// Stable onChange for SubBlockInputController
|
||||
const controllerOnChange = useCallback(
|
||||
(newValue: string) => {
|
||||
if (isPreview) {
|
||||
return
|
||||
}
|
||||
|
||||
const matchedOption = evaluatedOptions.find((option) => {
|
||||
if (typeof option === 'string') {
|
||||
return option === newValue
|
||||
}
|
||||
return option.id === newValue
|
||||
})
|
||||
|
||||
// If a matching option is found, store its ID; otherwise store the raw value
|
||||
// (allows expressions like <block.output> to be entered directly)
|
||||
const nextValue = matchedOption
|
||||
? typeof matchedOption === 'string'
|
||||
? matchedOption
|
||||
: matchedOption.id
|
||||
: newValue
|
||||
setStoreValue(nextValue)
|
||||
},
|
||||
[isPreview, evaluatedOptions, setStoreValue]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className='relative w-full'>
|
||||
<SubBlockInputController
|
||||
@@ -539,43 +473,76 @@ export const ComboBox = memo(function ComboBox({
|
||||
subBlockId={subBlockId}
|
||||
config={config}
|
||||
value={propValue}
|
||||
onChange={controllerOnChange}
|
||||
onChange={(newValue) => {
|
||||
if (isPreview) {
|
||||
return
|
||||
}
|
||||
|
||||
const matchedOption = evaluatedOptions.find((option) => {
|
||||
if (typeof option === 'string') {
|
||||
return option === newValue
|
||||
}
|
||||
return option.id === newValue
|
||||
})
|
||||
|
||||
// If a matching option is found, store its ID; otherwise store the raw value
|
||||
// (allows expressions like <block.output> to be entered directly)
|
||||
const nextValue = matchedOption
|
||||
? typeof matchedOption === 'string'
|
||||
? matchedOption
|
||||
: matchedOption.id
|
||||
: newValue
|
||||
setStoreValue(nextValue)
|
||||
}}
|
||||
isPreview={isPreview}
|
||||
disabled={disabled}
|
||||
previewValue={previewValue}
|
||||
>
|
||||
{({ ref, onChange: ctrlOnChange, onDrop, onDragOver }) => {
|
||||
// Update refs with latest handlers from render prop
|
||||
ctrlOnChangeRef.current = ctrlOnChange
|
||||
onDropRef.current = onDrop
|
||||
onDragOverRef.current = onDragOver
|
||||
// Store the input ref for passing to Combobox
|
||||
if (ref.current) {
|
||||
inputRefFromController.current = ref.current as HTMLInputElement
|
||||
}
|
||||
{({ ref, onChange: ctrlOnChange, onDrop, onDragOver }) => (
|
||||
<Combobox
|
||||
options={comboboxOptions}
|
||||
value={inputValue}
|
||||
selectedValue={value ?? ''}
|
||||
onChange={(newValue) => {
|
||||
const matchedComboboxOption = comboboxOptions.find(
|
||||
(option) => option.value === newValue
|
||||
)
|
||||
if (matchedComboboxOption) {
|
||||
setInputValue(matchedComboboxOption.label)
|
||||
} else {
|
||||
setInputValue(newValue)
|
||||
}
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
options={comboboxOptions}
|
||||
value={inputValue}
|
||||
selectedValue={value ?? ''}
|
||||
onChange={comboboxOnChange}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
editable
|
||||
overlayContent={overlayContent}
|
||||
inputRef={ref as React.RefObject<HTMLInputElement>}
|
||||
filterOptions
|
||||
searchable={config.searchable}
|
||||
className={cn('allow-scroll overflow-x-auto', selectedOptionIcon && 'pl-[28px]')}
|
||||
inputProps={comboboxInputProps}
|
||||
isLoading={isLoadingOptions}
|
||||
error={fetchError}
|
||||
onOpenChange={handleOpenChange}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
// Use controller's handler so env vars, tags, and DnD still work
|
||||
const syntheticEvent = {
|
||||
target: { value: newValue, selectionStart: newValue.length },
|
||||
} as React.ChangeEvent<HTMLInputElement>
|
||||
ctrlOnChange(syntheticEvent)
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
editable
|
||||
overlayContent={overlayContent}
|
||||
inputRef={ref as React.RefObject<HTMLInputElement>}
|
||||
filterOptions
|
||||
searchable={config.searchable}
|
||||
className={cn('allow-scroll overflow-x-auto', selectedOptionIcon && 'pl-[28px]')}
|
||||
inputProps={{
|
||||
onDrop: onDrop as (e: React.DragEvent<HTMLInputElement>) => void,
|
||||
onDragOver: onDragOver as (e: React.DragEvent<HTMLInputElement>) => void,
|
||||
onWheel: handleWheel,
|
||||
autoComplete: 'off',
|
||||
}}
|
||||
isLoading={isLoadingOptions}
|
||||
error={fetchError}
|
||||
onOpenChange={(open) => {
|
||||
if (open) {
|
||||
void fetchOptionsIfNeeded()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</SubBlockInputController>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -41,7 +41,6 @@ const SCOPE_DESCRIPTIONS: Record<string, string> = {
|
||||
'https://www.googleapis.com/auth/calendar': 'View and manage calendar',
|
||||
'https://www.googleapis.com/auth/userinfo.email': 'View email address',
|
||||
'https://www.googleapis.com/auth/userinfo.profile': 'View basic profile info',
|
||||
'https://www.googleapis.com/auth/forms.body': 'View and manage Google Forms',
|
||||
'https://www.googleapis.com/auth/forms.responses.readonly': 'View responses to Google Forms',
|
||||
'https://www.googleapis.com/auth/ediscovery': 'Access Google Vault for eDiscovery',
|
||||
'https://www.googleapis.com/auth/devstorage.read_only': 'Read files from Google Cloud Storage',
|
||||
|
||||
@@ -15,7 +15,6 @@ interface DocumentSelectorProps {
|
||||
onDocumentSelect?: (documentId: string) => void
|
||||
isPreview?: boolean
|
||||
previewValue?: string | null
|
||||
previewContextValues?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export function DocumentSelector({
|
||||
@@ -25,15 +24,9 @@ export function DocumentSelector({
|
||||
onDocumentSelect,
|
||||
isPreview = false,
|
||||
previewValue,
|
||||
previewContextValues,
|
||||
}: DocumentSelectorProps) {
|
||||
const { finalDisabled } = useDependsOnGate(blockId, subBlock, {
|
||||
disabled,
|
||||
isPreview,
|
||||
previewContextValues,
|
||||
})
|
||||
const [knowledgeBaseIdFromStore] = useSubBlockValue(blockId, 'knowledgeBaseId')
|
||||
const knowledgeBaseIdValue = previewContextValues?.knowledgeBaseId ?? knowledgeBaseIdFromStore
|
||||
const { finalDisabled } = useDependsOnGate(blockId, subBlock, { disabled, isPreview })
|
||||
const [knowledgeBaseIdValue] = useSubBlockValue(blockId, 'knowledgeBaseId')
|
||||
const normalizedKnowledgeBaseId =
|
||||
typeof knowledgeBaseIdValue === 'string' && knowledgeBaseIdValue.trim().length > 0
|
||||
? knowledgeBaseIdValue
|
||||
|
||||
@@ -37,7 +37,6 @@ interface DocumentTagEntryProps {
|
||||
disabled?: boolean
|
||||
isPreview?: boolean
|
||||
previewValue?: any
|
||||
previewContextValues?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -57,7 +56,6 @@ export function DocumentTagEntry({
|
||||
disabled = false,
|
||||
isPreview = false,
|
||||
previewValue,
|
||||
previewContextValues,
|
||||
}: DocumentTagEntryProps) {
|
||||
const [storeValue, setStoreValue] = useSubBlockValue<string>(blockId, subBlock.id)
|
||||
const accessiblePrefixes = useAccessibleReferencePrefixes(blockId)
|
||||
@@ -76,12 +74,8 @@ export function DocumentTagEntry({
|
||||
disabled,
|
||||
})
|
||||
|
||||
const [knowledgeBaseIdFromStore] = useSubBlockValue(blockId, 'knowledgeBaseId')
|
||||
const knowledgeBaseIdValue = previewContextValues?.knowledgeBaseId ?? knowledgeBaseIdFromStore
|
||||
const knowledgeBaseId =
|
||||
typeof knowledgeBaseIdValue === 'string' && knowledgeBaseIdValue.trim().length > 0
|
||||
? knowledgeBaseIdValue
|
||||
: null
|
||||
const [knowledgeBaseIdValue] = useSubBlockValue(blockId, 'knowledgeBaseId')
|
||||
const knowledgeBaseId = knowledgeBaseIdValue || null
|
||||
|
||||
const { tagDefinitions, isLoading } = useKnowledgeBaseTagDefinitions(knowledgeBaseId)
|
||||
const emitTagSelection = useTagSelection(blockId, subBlock.id)
|
||||
@@ -137,16 +131,11 @@ export function DocumentTagEntry({
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a tag by ID, or resets it if it's the last one
|
||||
* Removes a tag by ID (prevents removing the last tag)
|
||||
*/
|
||||
const removeTag = (id: string) => {
|
||||
if (isReadOnly) return
|
||||
if (tags.length === 1) {
|
||||
// Reset the last tag instead of removing it
|
||||
updateTags([createDefaultTag()])
|
||||
} else {
|
||||
updateTags(tags.filter((t) => t.id !== id))
|
||||
}
|
||||
if (isReadOnly || tags.length === 1) return
|
||||
updateTags(tags.filter((t) => t.id !== id))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -233,7 +222,6 @@ export function DocumentTagEntry({
|
||||
|
||||
/**
|
||||
* Renders the tag header with name, badge, and action buttons
|
||||
* Shows tag name only when collapsed (as summary), generic label when expanded
|
||||
*/
|
||||
const renderTagHeader = (tag: DocumentTag, index: number) => (
|
||||
<div
|
||||
@@ -242,11 +230,9 @@ export function DocumentTagEntry({
|
||||
>
|
||||
<div className='flex min-w-0 flex-1 items-center gap-[8px]'>
|
||||
<span className='block truncate font-medium text-[14px] text-[var(--text-tertiary)]'>
|
||||
{tag.collapsed ? tag.tagName || `Tag ${index + 1}` : `Tag ${index + 1}`}
|
||||
{tag.tagName || `Tag ${index + 1}`}
|
||||
</span>
|
||||
{tag.collapsed && tag.tagName && (
|
||||
<Badge size='sm'>{FIELD_TYPE_LABELS[tag.fieldType] || 'Text'}</Badge>
|
||||
)}
|
||||
{tag.tagName && <Badge size='sm'>{FIELD_TYPE_LABELS[tag.fieldType] || 'Text'}</Badge>}
|
||||
</div>
|
||||
<div className='flex items-center gap-[8px] pl-[8px]' onClick={(e) => e.stopPropagation()}>
|
||||
<Button
|
||||
@@ -261,7 +247,7 @@ export function DocumentTagEntry({
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={() => removeTag(tag.id)}
|
||||
disabled={isReadOnly}
|
||||
disabled={isReadOnly || tags.length === 1}
|
||||
className='h-auto p-0 text-[var(--text-error)] hover:text-[var(--text-error)]'
|
||||
>
|
||||
<Trash className='h-[14px] w-[14px]' />
|
||||
@@ -355,7 +341,7 @@ export function DocumentTagEntry({
|
||||
|
||||
const tagOptions: ComboboxOption[] = selectableTags.map((t) => ({
|
||||
value: t.displayName,
|
||||
label: t.displayName,
|
||||
label: `${t.displayName} (${FIELD_TYPE_LABELS[t.fieldType] || 'Text'})`,
|
||||
}))
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { isEqual } from 'lodash'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Badge } from '@/components/emcn'
|
||||
import { Combobox, type ComboboxOption } from '@/components/emcn/components'
|
||||
import { buildCanonicalIndex, resolveDependencyValue } from '@/lib/workflows/subblocks/visibility'
|
||||
@@ -69,7 +68,7 @@ interface DropdownProps {
|
||||
* - Special handling for dataMode subblock to convert between JSON and structured formats
|
||||
* - Integrates with the workflow state management system
|
||||
*/
|
||||
export const Dropdown = memo(function Dropdown({
|
||||
export function Dropdown({
|
||||
options,
|
||||
defaultValue,
|
||||
blockId,
|
||||
@@ -111,8 +110,7 @@ export const Dropdown = memo(function Dropdown({
|
||||
)
|
||||
},
|
||||
[dependsOnFields, activeWorkflowId, blockId, canonicalIndex, canonicalModeOverrides]
|
||||
),
|
||||
isEqual
|
||||
)
|
||||
)
|
||||
|
||||
const [storeInitialized, setStoreInitialized] = useState(false)
|
||||
@@ -163,18 +161,6 @@ export const Dropdown = memo(function Dropdown({
|
||||
}
|
||||
}, [fetchOptions, blockId, subBlockId, isPreview, disabled])
|
||||
|
||||
/**
|
||||
* Handles combobox open state changes to trigger option fetching
|
||||
*/
|
||||
const handleOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
if (open) {
|
||||
void fetchOptionsIfNeeded()
|
||||
}
|
||||
},
|
||||
[fetchOptionsIfNeeded]
|
||||
)
|
||||
|
||||
const evaluatedOptions = useMemo(() => {
|
||||
return typeof options === 'function' ? options() : options
|
||||
}, [options])
|
||||
@@ -485,7 +471,11 @@ export const Dropdown = memo(function Dropdown({
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
editable={false}
|
||||
onOpenChange={handleOpenChange}
|
||||
onOpenChange={(open) => {
|
||||
if (open) {
|
||||
void fetchOptionsIfNeeded()
|
||||
}
|
||||
}}
|
||||
overlayContent={multiSelectOverlay}
|
||||
multiSelect={multiSelect}
|
||||
isLoading={isLoadingOptions}
|
||||
@@ -494,4 +484,4 @@ export const Dropdown = memo(function Dropdown({
|
||||
searchPlaceholder='Search...'
|
||||
/>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -40,7 +40,6 @@ interface KnowledgeTagFiltersProps {
|
||||
disabled?: boolean
|
||||
isPreview?: boolean
|
||||
previewValue?: string | null
|
||||
previewContextValues?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -61,19 +60,14 @@ export function KnowledgeTagFilters({
|
||||
disabled = false,
|
||||
isPreview = false,
|
||||
previewValue,
|
||||
previewContextValues,
|
||||
}: KnowledgeTagFiltersProps) {
|
||||
const [storeValue, setStoreValue] = useSubBlockValue<string | null>(blockId, subBlock.id)
|
||||
const emitTagSelection = useTagSelection(blockId, subBlock.id)
|
||||
const valueInputRefs = useRef<Record<string, HTMLInputElement>>({})
|
||||
const overlayRefs = useRef<Record<string, HTMLDivElement>>({})
|
||||
|
||||
const [knowledgeBaseIdFromStore] = useSubBlockValue(blockId, 'knowledgeBaseId')
|
||||
const knowledgeBaseIdValue = previewContextValues?.knowledgeBaseId ?? knowledgeBaseIdFromStore
|
||||
const knowledgeBaseId =
|
||||
typeof knowledgeBaseIdValue === 'string' && knowledgeBaseIdValue.trim().length > 0
|
||||
? knowledgeBaseIdValue
|
||||
: null
|
||||
const [knowledgeBaseIdValue] = useSubBlockValue(blockId, 'knowledgeBaseId')
|
||||
const knowledgeBaseId = knowledgeBaseIdValue || null
|
||||
|
||||
const { tagDefinitions, isLoading } = useKnowledgeBaseTagDefinitions(knowledgeBaseId)
|
||||
const accessiblePrefixes = useAccessibleReferencePrefixes(blockId)
|
||||
@@ -129,16 +123,11 @@ export function KnowledgeTagFilters({
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a filter by ID, or resets it if it's the last one
|
||||
* Removes a filter by ID (prevents removing the last filter)
|
||||
*/
|
||||
const removeFilter = (id: string) => {
|
||||
if (isReadOnly) return
|
||||
if (filters.length === 1) {
|
||||
// Reset the last filter instead of removing it
|
||||
updateFilters([createDefaultFilter()])
|
||||
} else {
|
||||
updateFilters(filters.filter((f) => f.id !== id))
|
||||
}
|
||||
if (isReadOnly || filters.length === 1) return
|
||||
updateFilters(filters.filter((f) => f.id !== id))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -226,7 +215,6 @@ export function KnowledgeTagFilters({
|
||||
|
||||
/**
|
||||
* Renders the filter header with name, badge, and action buttons
|
||||
* Shows tag name only when collapsed (as summary), generic label when expanded
|
||||
*/
|
||||
const renderFilterHeader = (filter: TagFilter, index: number) => (
|
||||
<div
|
||||
@@ -235,11 +223,9 @@ export function KnowledgeTagFilters({
|
||||
>
|
||||
<div className='flex min-w-0 flex-1 items-center gap-[8px]'>
|
||||
<span className='block truncate font-medium text-[14px] text-[var(--text-tertiary)]'>
|
||||
{filter.collapsed ? filter.tagName || `Filter ${index + 1}` : `Filter ${index + 1}`}
|
||||
{filter.tagName || `Filter ${index + 1}`}
|
||||
</span>
|
||||
{filter.collapsed && filter.tagName && (
|
||||
<Badge size='sm'>{FIELD_TYPE_LABELS[filter.fieldType] || 'Text'}</Badge>
|
||||
)}
|
||||
{filter.tagName && <Badge size='sm'>{FIELD_TYPE_LABELS[filter.fieldType] || 'Text'}</Badge>}
|
||||
</div>
|
||||
<div className='flex items-center gap-[8px] pl-[8px]' onClick={(e) => e.stopPropagation()}>
|
||||
<Button variant='ghost' onClick={addFilter} disabled={isReadOnly} className='h-auto p-0'>
|
||||
@@ -249,7 +235,7 @@ export function KnowledgeTagFilters({
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={() => removeFilter(filter.id)}
|
||||
disabled={isReadOnly}
|
||||
disabled={isReadOnly || filters.length === 1}
|
||||
className='h-auto p-0 text-[var(--text-error)] hover:text-[var(--text-error)]'
|
||||
>
|
||||
<Trash className='h-[14px] w-[14px]' />
|
||||
@@ -338,7 +324,7 @@ export function KnowledgeTagFilters({
|
||||
const renderFilterContent = (filter: TagFilter) => {
|
||||
const tagOptions: ComboboxOption[] = tagDefinitions.map((tag) => ({
|
||||
value: tag.displayName,
|
||||
label: tag.displayName,
|
||||
label: `${tag.displayName} (${FIELD_TYPE_LABELS[tag.fieldType] || 'Text'})`,
|
||||
}))
|
||||
|
||||
const operators = getOperatorsForFieldType(filter.fieldType)
|
||||
|
||||
@@ -1,17 +1,281 @@
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import type { RefObject } from 'react'
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { Combobox, Label, Slider, Switch } from '@/components/emcn/components'
|
||||
import { Combobox, Input, Label, Slider, Switch, Textarea } from '@/components/emcn/components'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { LongInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/long-input/long-input'
|
||||
import { ShortInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/short-input/short-input'
|
||||
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text'
|
||||
import {
|
||||
checkTagTrigger,
|
||||
TagDropdown,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown'
|
||||
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
|
||||
import { useMcpTools } from '@/hooks/mcp/use-mcp-tools'
|
||||
import { formatParameterLabel } from '@/tools/params'
|
||||
|
||||
const logger = createLogger('McpDynamicArgs')
|
||||
|
||||
interface McpInputWithTagsProps {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
isPassword?: boolean
|
||||
blockId: string
|
||||
accessiblePrefixes?: Set<string>
|
||||
}
|
||||
|
||||
function McpInputWithTags({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
disabled,
|
||||
isPassword,
|
||||
blockId,
|
||||
accessiblePrefixes,
|
||||
}: McpInputWithTagsProps) {
|
||||
const [showTags, setShowTags] = useState(false)
|
||||
const [cursorPosition, setCursorPosition] = useState(0)
|
||||
const [activeSourceBlockId, setActiveSourceBlockId] = useState<string | null>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const inputNameRef = useRef(`mcp_input_${Math.random()}`)
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = e.target.value
|
||||
const newCursorPosition = e.target.selectionStart ?? 0
|
||||
|
||||
onChange(newValue)
|
||||
setCursorPosition(newCursorPosition)
|
||||
|
||||
const tagTrigger = checkTagTrigger(newValue, newCursorPosition)
|
||||
setShowTags(tagTrigger.show)
|
||||
}
|
||||
|
||||
const handleDrop = (e: React.DragEvent<HTMLInputElement>) => {
|
||||
e.preventDefault()
|
||||
|
||||
try {
|
||||
const data = JSON.parse(e.dataTransfer.getData('application/json'))
|
||||
if (data.type !== 'connectionBlock') return
|
||||
|
||||
const dropPosition = inputRef.current?.selectionStart ?? value.length ?? 0
|
||||
const currentValue = value ?? ''
|
||||
const newValue = `${currentValue.slice(0, dropPosition)}<${currentValue.slice(dropPosition)}`
|
||||
|
||||
onChange(newValue)
|
||||
setCursorPosition(dropPosition + 1)
|
||||
setShowTags(true)
|
||||
|
||||
if (data.connectionData?.sourceBlockId) {
|
||||
setActiveSourceBlockId(data.connectionData.sourceBlockId)
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.selectionStart = dropPosition + 1
|
||||
inputRef.current.selectionEnd = dropPosition + 1
|
||||
}
|
||||
}, 0)
|
||||
} catch (error) {
|
||||
logger.error('Failed to parse drop data:', { error })
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragOver = (e: React.DragEvent<HTMLInputElement>) => {
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
const handleTagSelect = (newValue: string) => {
|
||||
onChange(newValue)
|
||||
setShowTags(false)
|
||||
setActiveSourceBlockId(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='relative'>
|
||||
<div className='relative'>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
type={isPassword ? 'password' : 'text'}
|
||||
value={value || ''}
|
||||
onChange={handleChange}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
name={inputNameRef.current}
|
||||
autoComplete='off'
|
||||
autoCapitalize='off'
|
||||
spellCheck='false'
|
||||
data-form-type='other'
|
||||
data-lpignore='true'
|
||||
data-1p-ignore
|
||||
readOnly
|
||||
onFocus={(e) => {
|
||||
e.currentTarget.removeAttribute('readOnly')
|
||||
// Show tag dropdown on focus when input is empty
|
||||
if (!disabled && (value?.trim() === '' || !value)) {
|
||||
setShowTags(true)
|
||||
setCursorPosition(0)
|
||||
}
|
||||
}}
|
||||
className={cn(!isPassword && 'text-transparent caret-foreground')}
|
||||
/>
|
||||
{!isPassword && (
|
||||
<div className='pointer-events-none absolute inset-0 flex items-center overflow-hidden bg-transparent px-[8px] py-[6px] font-medium font-sans text-sm'>
|
||||
<div className='whitespace-pre'>
|
||||
{formatDisplayText(value?.toString() || '', {
|
||||
accessiblePrefixes,
|
||||
highlightAll: !accessiblePrefixes,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<TagDropdown
|
||||
visible={showTags}
|
||||
onSelect={handleTagSelect}
|
||||
blockId={blockId}
|
||||
activeSourceBlockId={activeSourceBlockId}
|
||||
inputValue={value?.toString() ?? ''}
|
||||
cursorPosition={cursorPosition}
|
||||
onClose={() => {
|
||||
setShowTags(false)
|
||||
setActiveSourceBlockId(null)
|
||||
}}
|
||||
inputRef={inputRef as RefObject<HTMLInputElement>}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface McpTextareaWithTagsProps {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
blockId: string
|
||||
accessiblePrefixes?: Set<string>
|
||||
rows?: number
|
||||
}
|
||||
|
||||
function McpTextareaWithTags({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
disabled,
|
||||
blockId,
|
||||
accessiblePrefixes,
|
||||
rows = 4,
|
||||
}: McpTextareaWithTagsProps) {
|
||||
const [showTags, setShowTags] = useState(false)
|
||||
const [cursorPosition, setCursorPosition] = useState(0)
|
||||
const [activeSourceBlockId, setActiveSourceBlockId] = useState<string | null>(null)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const textareaNameRef = useRef(`mcp_textarea_${Math.random()}`)
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const newValue = e.target.value
|
||||
const newCursorPosition = e.target.selectionStart ?? 0
|
||||
|
||||
onChange(newValue)
|
||||
setCursorPosition(newCursorPosition)
|
||||
|
||||
const tagTrigger = checkTagTrigger(newValue, newCursorPosition)
|
||||
setShowTags(tagTrigger.show)
|
||||
}
|
||||
|
||||
const handleDrop = (e: React.DragEvent<HTMLTextAreaElement>) => {
|
||||
e.preventDefault()
|
||||
|
||||
try {
|
||||
const data = JSON.parse(e.dataTransfer.getData('application/json'))
|
||||
if (data.type !== 'connectionBlock') return
|
||||
|
||||
const dropPosition = textareaRef.current?.selectionStart ?? value.length ?? 0
|
||||
const currentValue = value ?? ''
|
||||
const newValue = `${currentValue.slice(0, dropPosition)}<${currentValue.slice(dropPosition)}`
|
||||
|
||||
onChange(newValue)
|
||||
setCursorPosition(dropPosition + 1)
|
||||
setShowTags(true)
|
||||
|
||||
if (data.connectionData?.sourceBlockId) {
|
||||
setActiveSourceBlockId(data.connectionData.sourceBlockId)
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.selectionStart = dropPosition + 1
|
||||
textareaRef.current.selectionEnd = dropPosition + 1
|
||||
}
|
||||
}, 0)
|
||||
} catch (error) {
|
||||
logger.error('Failed to parse drop data:', { error })
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragOver = (e: React.DragEvent<HTMLTextAreaElement>) => {
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
const handleTagSelect = (newValue: string) => {
|
||||
onChange(newValue)
|
||||
setShowTags(false)
|
||||
setActiveSourceBlockId(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='relative'>
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={value || ''}
|
||||
onChange={handleChange}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onFocus={() => {
|
||||
// Show tag dropdown on focus when input is empty
|
||||
if (!disabled && (value?.trim() === '' || !value)) {
|
||||
setShowTags(true)
|
||||
setCursorPosition(0)
|
||||
}
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
rows={rows}
|
||||
name={textareaNameRef.current}
|
||||
autoComplete='off'
|
||||
autoCapitalize='off'
|
||||
spellCheck='false'
|
||||
data-form-type='other'
|
||||
data-lpignore='true'
|
||||
data-1p-ignore
|
||||
className={cn('min-h-[80px] resize-none text-transparent caret-foreground')}
|
||||
/>
|
||||
<div className='pointer-events-none absolute inset-0 overflow-auto whitespace-pre-wrap break-words px-[8px] py-[8px] font-medium font-sans text-sm'>
|
||||
{formatDisplayText(value || '', {
|
||||
accessiblePrefixes,
|
||||
highlightAll: !accessiblePrefixes,
|
||||
})}
|
||||
</div>
|
||||
<TagDropdown
|
||||
visible={showTags}
|
||||
onSelect={handleTagSelect}
|
||||
blockId={blockId}
|
||||
activeSourceBlockId={activeSourceBlockId}
|
||||
inputValue={value?.toString() ?? ''}
|
||||
cursorPosition={cursorPosition}
|
||||
onClose={() => {
|
||||
setShowTags(false)
|
||||
setActiveSourceBlockId(null)
|
||||
}}
|
||||
inputRef={textareaRef as RefObject<HTMLTextAreaElement>}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface McpDynamicArgsProps {
|
||||
blockId: string
|
||||
subBlockId: string
|
||||
@@ -20,27 +284,6 @@ interface McpDynamicArgsProps {
|
||||
previewValue?: any
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a minimal SubBlockConfig for MCP tool parameters
|
||||
*/
|
||||
function createParamConfig(
|
||||
paramName: string,
|
||||
paramSchema: any,
|
||||
inputType: 'long-input' | 'short-input'
|
||||
): SubBlockConfig {
|
||||
const placeholder =
|
||||
paramSchema.type === 'array'
|
||||
? `Enter JSON array, e.g. ["item1", "item2"] or comma-separated values`
|
||||
: paramSchema.description || `Enter ${formatParameterLabel(paramName).toLowerCase()}`
|
||||
|
||||
return {
|
||||
id: paramName,
|
||||
type: inputType,
|
||||
title: formatParameterLabel(paramName),
|
||||
placeholder,
|
||||
}
|
||||
}
|
||||
|
||||
export function McpDynamicArgs({
|
||||
blockId,
|
||||
subBlockId,
|
||||
@@ -54,6 +297,7 @@ export function McpDynamicArgs({
|
||||
const [selectedTool] = useSubBlockValue(blockId, 'tool')
|
||||
const [cachedSchema] = useSubBlockValue(blockId, '_toolSchema')
|
||||
const [toolArgs, setToolArgs] = useSubBlockValue(blockId, subBlockId)
|
||||
const accessiblePrefixes = useAccessibleReferencePrefixes(blockId)
|
||||
|
||||
const selectedToolConfig = mcpTools.find((tool) => tool.id === selectedTool)
|
||||
const toolSchema = cachedSchema || selectedToolConfig?.inputSchema
|
||||
@@ -64,7 +308,7 @@ export function McpDynamicArgs({
|
||||
try {
|
||||
return JSON.parse(previewValue)
|
||||
} catch (error) {
|
||||
logger.warn('Failed to parse preview value as JSON:', { error })
|
||||
console.warn('Failed to parse preview value as JSON:', error)
|
||||
return previewValue
|
||||
}
|
||||
}
|
||||
@@ -74,7 +318,7 @@ export function McpDynamicArgs({
|
||||
try {
|
||||
return JSON.parse(toolArgs)
|
||||
} catch (error) {
|
||||
logger.warn('Failed to parse toolArgs as JSON:', { error })
|
||||
console.warn('Failed to parse toolArgs as JSON:', error)
|
||||
return {}
|
||||
}
|
||||
}
|
||||
@@ -216,23 +460,24 @@ export function McpDynamicArgs({
|
||||
)
|
||||
}
|
||||
|
||||
case 'long-input': {
|
||||
const config = createParamConfig(paramName, paramSchema, 'long-input')
|
||||
case 'long-input':
|
||||
return (
|
||||
<LongInput
|
||||
<McpTextareaWithTags
|
||||
key={`${paramName}-long`}
|
||||
blockId={blockId}
|
||||
subBlockId={`_mcp_${paramName}`}
|
||||
config={config}
|
||||
placeholder={config.placeholder}
|
||||
rows={4}
|
||||
value={value || ''}
|
||||
onChange={(newValue) => updateParameter(paramName, newValue)}
|
||||
isPreview={isPreview}
|
||||
placeholder={
|
||||
paramSchema.type === 'array'
|
||||
? `Enter JSON array, e.g. ["item1", "item2"] or comma-separated values`
|
||||
: paramSchema.description ||
|
||||
`Enter ${formatParameterLabel(paramName).toLowerCase()}`
|
||||
}
|
||||
disabled={disabled}
|
||||
blockId={blockId}
|
||||
accessiblePrefixes={accessiblePrefixes}
|
||||
rows={4}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
default: {
|
||||
const isPassword =
|
||||
@@ -240,16 +485,10 @@ export function McpDynamicArgs({
|
||||
paramName.toLowerCase().includes('password') ||
|
||||
paramName.toLowerCase().includes('token')
|
||||
const isNumeric = paramSchema.type === 'number' || paramSchema.type === 'integer'
|
||||
const config = createParamConfig(paramName, paramSchema, 'short-input')
|
||||
|
||||
return (
|
||||
<ShortInput
|
||||
<McpInputWithTags
|
||||
key={`${paramName}-short`}
|
||||
blockId={blockId}
|
||||
subBlockId={`_mcp_${paramName}`}
|
||||
config={config}
|
||||
placeholder={config.placeholder}
|
||||
password={isPassword}
|
||||
value={value?.toString() || ''}
|
||||
onChange={(newValue) => {
|
||||
let processedValue: any = newValue
|
||||
@@ -267,8 +506,16 @@ export function McpDynamicArgs({
|
||||
}
|
||||
updateParameter(paramName, processedValue)
|
||||
}}
|
||||
isPreview={isPreview}
|
||||
placeholder={
|
||||
paramSchema.type === 'array'
|
||||
? `Enter JSON array, e.g. ["item1", "item2"] or comma-separated values`
|
||||
: paramSchema.description ||
|
||||
`Enter ${formatParameterLabel(paramName).toLowerCase()}`
|
||||
}
|
||||
disabled={disabled}
|
||||
isPassword={isPassword}
|
||||
blockId={blockId}
|
||||
accessiblePrefixes={accessiblePrefixes}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -331,40 +578,26 @@ export function McpDynamicArgs({
|
||||
tabIndex={-1}
|
||||
readOnly
|
||||
/>
|
||||
<div>
|
||||
<div className='space-y-4'>
|
||||
{toolSchema.properties &&
|
||||
Object.entries(toolSchema.properties).map(([paramName, paramSchema], index, entries) => {
|
||||
Object.entries(toolSchema.properties).map(([paramName, paramSchema]) => {
|
||||
const inputType = getInputType(paramSchema as any)
|
||||
const showLabel = inputType !== 'switch'
|
||||
const showDivider = index < entries.length - 1
|
||||
|
||||
return (
|
||||
<div key={paramName} className='subblock-row'>
|
||||
<div className='subblock-content flex flex-col gap-[10px]'>
|
||||
{showLabel && (
|
||||
<Label
|
||||
className={cn(
|
||||
'font-medium text-sm',
|
||||
toolSchema.required?.includes(paramName) &&
|
||||
'after:ml-1 after:text-red-500 after:content-["*"]'
|
||||
)}
|
||||
>
|
||||
{formatParameterLabel(paramName)}
|
||||
</Label>
|
||||
)}
|
||||
{renderParameterInput(paramName, paramSchema as any)}
|
||||
</div>
|
||||
{showDivider && (
|
||||
<div className='subblock-divider px-[2px] pt-[16px] pb-[13px]'>
|
||||
<div
|
||||
className='h-[1.25px]'
|
||||
style={{
|
||||
backgroundImage:
|
||||
'repeating-linear-gradient(to right, var(--border) 0px, var(--border) 6px, transparent 6px, transparent 12px)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div key={paramName} className='space-y-2'>
|
||||
{showLabel && (
|
||||
<Label
|
||||
className={cn(
|
||||
'font-medium text-sm',
|
||||
toolSchema.required?.includes(paramName) &&
|
||||
'after:ml-1 after:text-red-500 after:content-["*"]'
|
||||
)}
|
||||
>
|
||||
{formatParameterLabel(paramName)}
|
||||
</Label>
|
||||
)}
|
||||
{renderParameterInput(paramName, paramSchema as any)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
|
||||
import { isEqual } from 'lodash'
|
||||
import { ChevronDown, ChevronsUpDown, ChevronUp, Plus } from 'lucide-react'
|
||||
import { Button, Popover, PopoverContent, PopoverItem, PopoverTrigger } from '@/components/emcn'
|
||||
import { Trash } from '@/components/emcn/icons/trash'
|
||||
@@ -226,18 +225,14 @@ export function MessagesInput({
|
||||
[wandHook]
|
||||
)
|
||||
|
||||
const localMessagesRef = useRef(localMessages)
|
||||
localMessagesRef.current = localMessages
|
||||
|
||||
/**
|
||||
* Initialize local state from stored or preview value
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (isPreview && previewValue && Array.isArray(previewValue)) {
|
||||
if (!isEqual(localMessagesRef.current, previewValue)) {
|
||||
setLocalMessages(previewValue)
|
||||
}
|
||||
setLocalMessages(previewValue)
|
||||
} else if (messages && Array.isArray(messages) && messages.length > 0) {
|
||||
if (!isEqual(localMessagesRef.current, messages)) {
|
||||
setLocalMessages(messages)
|
||||
}
|
||||
setLocalMessages(messages)
|
||||
}
|
||||
}, [isPreview, previewValue, messages])
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
|
||||
import { Check, Copy, Wand2 } from 'lucide-react'
|
||||
import { useReactFlow } from 'reactflow'
|
||||
import { Input } from '@/components/emcn'
|
||||
@@ -62,7 +62,7 @@ interface ShortInputProps {
|
||||
* - Copy to clipboard functionality
|
||||
* - Integrates with ReactFlow for zoom control
|
||||
*/
|
||||
export const ShortInput = memo(function ShortInput({
|
||||
export function ShortInput({
|
||||
blockId,
|
||||
subBlockId,
|
||||
placeholder,
|
||||
@@ -445,4 +445,4 @@ export const ShortInput = memo(function ShortInput({
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useRef } from 'react'
|
||||
import { useRef } from 'react'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { Trash } from '@/components/emcn/icons/trash'
|
||||
import 'prismjs/components/prism-json'
|
||||
@@ -81,8 +81,6 @@ const createDefaultField = (): Field => ({
|
||||
*/
|
||||
const validateFieldName = (name: string): string => name.replace(/[\x00-\x1F"\\]/g, '').trim()
|
||||
|
||||
const jsonHighlight = (code: string): string => highlight(code, languages.json, 'json')
|
||||
|
||||
export function FieldFormat({
|
||||
blockId,
|
||||
subBlockId,
|
||||
@@ -140,50 +138,17 @@ export function FieldFormat({
|
||||
setStoreValue(fields.filter((field) => field.id !== id))
|
||||
}
|
||||
|
||||
const storeValueRef = useRef(storeValue)
|
||||
storeValueRef.current = storeValue
|
||||
/**
|
||||
* Updates a specific field property
|
||||
*/
|
||||
const updateField = (id: string, field: keyof Field, value: any) => {
|
||||
if (isReadOnly) return
|
||||
|
||||
const isReadOnlyRef = useRef(isReadOnly)
|
||||
isReadOnlyRef.current = isReadOnly
|
||||
const updatedValue =
|
||||
field === 'name' && typeof value === 'string' ? validateFieldName(value) : value
|
||||
|
||||
const setStoreValueRef = useRef(setStoreValue)
|
||||
setStoreValueRef.current = setStoreValue
|
||||
|
||||
const updateField = useCallback(
|
||||
(id: string, fieldKey: keyof Field, fieldValue: Field[keyof Field]) => {
|
||||
if (isReadOnlyRef.current) return
|
||||
|
||||
const updatedValue =
|
||||
fieldKey === 'name' && typeof fieldValue === 'string'
|
||||
? validateFieldName(fieldValue)
|
||||
: fieldValue
|
||||
|
||||
const currentStoreValue = storeValueRef.current
|
||||
const currentFields: Field[] =
|
||||
Array.isArray(currentStoreValue) && currentStoreValue.length > 0
|
||||
? currentStoreValue
|
||||
: [createDefaultField()]
|
||||
|
||||
setStoreValueRef.current(
|
||||
currentFields.map((f) => (f.id === id ? { ...f, [fieldKey]: updatedValue } : f))
|
||||
)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const editorValueChangeHandlersRef = useRef<Record<string, (newValue: string) => void>>({})
|
||||
|
||||
const getEditorValueChangeHandler = useCallback(
|
||||
(fieldId: string): ((newValue: string) => void) => {
|
||||
if (!editorValueChangeHandlersRef.current[fieldId]) {
|
||||
editorValueChangeHandlersRef.current[fieldId] = (newValue: string) => {
|
||||
updateField(fieldId, 'value', newValue)
|
||||
}
|
||||
}
|
||||
return editorValueChangeHandlersRef.current[fieldId]
|
||||
},
|
||||
[updateField]
|
||||
)
|
||||
setStoreValue(fields.map((f) => (f.id === id ? { ...f, [field]: updatedValue } : f)))
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles the collapsed state of a field
|
||||
@@ -257,14 +222,15 @@ export function FieldFormat({
|
||||
placeholder={placeholder}
|
||||
disabled={isReadOnly}
|
||||
autoComplete='off'
|
||||
className={cn('allow-scroll w-full overflow-x-auto overflow-y-hidden', inputClassName)}
|
||||
className={cn('allow-scroll w-full overflow-auto', inputClassName)}
|
||||
style={{ overflowX: 'auto' }}
|
||||
/>
|
||||
<div
|
||||
ref={(el) => {
|
||||
if (el) nameOverlayRefs.current[field.id] = el
|
||||
}}
|
||||
className='pointer-events-none absolute inset-0 flex items-center overflow-x-auto bg-transparent px-[8px] py-[6px] font-medium font-sans text-sm'
|
||||
style={{ scrollbarWidth: 'none' }}
|
||||
style={{ overflowX: 'auto' }}
|
||||
>
|
||||
<div
|
||||
className='w-full whitespace-pre'
|
||||
@@ -393,8 +359,12 @@ export function FieldFormat({
|
||||
</Code.Placeholder>
|
||||
<Editor
|
||||
value={fieldValue}
|
||||
onValueChange={getEditorValueChangeHandler(field.id)}
|
||||
highlight={jsonHighlight}
|
||||
onValueChange={(newValue) => {
|
||||
if (!isReadOnly) {
|
||||
updateField(field.id, 'value', newValue)
|
||||
}
|
||||
}}
|
||||
highlight={(code) => highlight(code, languages.json, 'json')}
|
||||
disabled={isReadOnly}
|
||||
{...getCodeEditorProps({ disabled: isReadOnly })}
|
||||
/>
|
||||
@@ -428,8 +398,12 @@ export function FieldFormat({
|
||||
</Code.Placeholder>
|
||||
<Editor
|
||||
value={fieldValue}
|
||||
onValueChange={getEditorValueChangeHandler(field.id)}
|
||||
highlight={jsonHighlight}
|
||||
onValueChange={(newValue) => {
|
||||
if (!isReadOnly) {
|
||||
updateField(field.id, 'value', newValue)
|
||||
}
|
||||
}}
|
||||
highlight={(code) => highlight(code, languages.json, 'json')}
|
||||
disabled={isReadOnly}
|
||||
{...getCodeEditorProps({ disabled: isReadOnly })}
|
||||
/>
|
||||
@@ -465,8 +439,12 @@ export function FieldFormat({
|
||||
</Code.Placeholder>
|
||||
<Editor
|
||||
value={fieldValue}
|
||||
onValueChange={getEditorValueChangeHandler(field.id)}
|
||||
highlight={jsonHighlight}
|
||||
onValueChange={(newValue) => {
|
||||
if (!isReadOnly) {
|
||||
updateField(field.id, 'value', newValue)
|
||||
}
|
||||
}}
|
||||
highlight={(code) => highlight(code, languages.json, 'json')}
|
||||
disabled={isReadOnly}
|
||||
{...getCodeEditorProps({ disabled: isReadOnly })}
|
||||
/>
|
||||
@@ -498,14 +476,15 @@ export function FieldFormat({
|
||||
placeholder={valuePlaceholder}
|
||||
disabled={isReadOnly}
|
||||
autoComplete='off'
|
||||
className={cn('allow-scroll w-full overflow-x-auto overflow-y-hidden', inputClassName)}
|
||||
className={cn('allow-scroll w-full overflow-auto', inputClassName)}
|
||||
style={{ overflowX: 'auto' }}
|
||||
/>
|
||||
<div
|
||||
ref={(el) => {
|
||||
if (el) overlayRefs.current[field.id] = el
|
||||
}}
|
||||
className='pointer-events-none absolute inset-0 flex items-center overflow-x-auto bg-transparent px-[8px] py-[6px] font-medium font-sans text-sm'
|
||||
style={{ scrollbarWidth: 'none' }}
|
||||
style={{ overflowX: 'auto' }}
|
||||
>
|
||||
<div
|
||||
className='w-full whitespace-pre'
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { usePopoverContext } from '@/components/emcn'
|
||||
import { useNestedNavigation } from '../tag-dropdown'
|
||||
import type { BlockTagGroup, NestedBlockTagGroup, NestedTag } from '../types'
|
||||
import type { BlockTagGroup, NestedBlockTagGroup } from '../types'
|
||||
|
||||
/**
|
||||
* Keyboard navigation handler component that uses popover context
|
||||
@@ -16,90 +15,6 @@ interface KeyboardNavigationHandlerProps {
|
||||
handleTagSelect: (tag: string, group?: BlockTagGroup) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively finds a folder in nested tags by its ID
|
||||
*/
|
||||
const findFolderInNested = (
|
||||
nestedTags: NestedTag[],
|
||||
blockId: string,
|
||||
targetFolderId: string
|
||||
): NestedTag | null => {
|
||||
for (const nestedTag of nestedTags) {
|
||||
const folderId = `${blockId}-${nestedTag.key}`
|
||||
if (folderId === targetFolderId) {
|
||||
return nestedTag
|
||||
}
|
||||
if (nestedTag.nestedChildren) {
|
||||
const found = findFolderInNested(nestedTag.nestedChildren, blockId, targetFolderId)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively finds folder info for a tag that can be expanded.
|
||||
* Returns both the folder metadata and the NestedTag object for navigation.
|
||||
*/
|
||||
const findFolderInfoForTag = (
|
||||
nestedTags: NestedTag[],
|
||||
targetTag: string,
|
||||
group: NestedBlockTagGroup
|
||||
): {
|
||||
id: string
|
||||
title: string
|
||||
parentTag: string
|
||||
group: NestedBlockTagGroup
|
||||
nestedTag: NestedTag
|
||||
} | null => {
|
||||
for (const nestedTag of nestedTags) {
|
||||
if (
|
||||
nestedTag.parentTag === targetTag &&
|
||||
(nestedTag.children?.length || nestedTag.nestedChildren?.length)
|
||||
) {
|
||||
return {
|
||||
id: `${group.blockId}-${nestedTag.key}`,
|
||||
title: nestedTag.display,
|
||||
parentTag: nestedTag.parentTag,
|
||||
group,
|
||||
nestedTag,
|
||||
}
|
||||
}
|
||||
if (nestedTag.nestedChildren) {
|
||||
const found = findFolderInfoForTag(nestedTag.nestedChildren, targetTag, group)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively checks if a tag is a child of any folder.
|
||||
* This includes both leaf children and nested folder parent tags.
|
||||
*/
|
||||
const isChildOfAnyFolder = (nestedTags: NestedTag[], tag: string): boolean => {
|
||||
for (const nestedTag of nestedTags) {
|
||||
if (nestedTag.children) {
|
||||
for (const child of nestedTag.children) {
|
||||
if (child.fullTag === tag) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
if (nestedTag.nestedChildren) {
|
||||
for (const nestedChild of nestedTag.nestedChildren) {
|
||||
if (nestedChild.parentTag === tag) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if (isChildOfAnyFolder(nestedTag.nestedChildren, tag)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export const KeyboardNavigationHandler: React.FC<KeyboardNavigationHandlerProps> = ({
|
||||
visible,
|
||||
selectedIndex,
|
||||
@@ -108,66 +23,55 @@ export const KeyboardNavigationHandler: React.FC<KeyboardNavigationHandlerProps>
|
||||
nestedBlockTagGroups,
|
||||
handleTagSelect,
|
||||
}) => {
|
||||
const { openFolder, closeFolder, isInFolder, currentFolder, setKeyboardNav } = usePopoverContext()
|
||||
const nestedNav = useNestedNavigation()
|
||||
const { openFolder, closeFolder, isInFolder, currentFolder } = usePopoverContext()
|
||||
|
||||
const visibleIndices = useMemo(() => {
|
||||
const indices: number[] = []
|
||||
const nestedPath = nestedNav?.nestedPath ?? []
|
||||
|
||||
if (isInFolder && currentFolder) {
|
||||
let currentNestedTag: NestedTag | null = null
|
||||
|
||||
if (nestedPath.length > 0) {
|
||||
currentNestedTag = nestedPath[nestedPath.length - 1]
|
||||
} else {
|
||||
for (const group of nestedBlockTagGroups) {
|
||||
const folder = findFolderInNested(group.nestedTags, group.blockId, currentFolder)
|
||||
if (folder) {
|
||||
currentNestedTag = folder
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (currentNestedTag) {
|
||||
if (currentNestedTag.parentTag) {
|
||||
const parentIdx = flatTagList.findIndex(
|
||||
(item) => item.tag === currentNestedTag!.parentTag
|
||||
)
|
||||
if (parentIdx >= 0) {
|
||||
indices.push(parentIdx)
|
||||
}
|
||||
}
|
||||
if (currentNestedTag.children) {
|
||||
for (const child of currentNestedTag.children) {
|
||||
const idx = flatTagList.findIndex((item) => item.tag === child.fullTag)
|
||||
if (idx >= 0) {
|
||||
indices.push(idx)
|
||||
for (const group of nestedBlockTagGroups) {
|
||||
for (const nestedTag of group.nestedTags) {
|
||||
const folderId = `${group.blockId}-${nestedTag.key}`
|
||||
if (folderId === currentFolder && nestedTag.children) {
|
||||
// First, add the parent tag itself (so it's navigable as the first item)
|
||||
if (nestedTag.parentTag) {
|
||||
const parentIdx = flatTagList.findIndex((item) => item.tag === nestedTag.parentTag)
|
||||
if (parentIdx >= 0) {
|
||||
indices.push(parentIdx)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (currentNestedTag.nestedChildren) {
|
||||
for (const nestedChild of currentNestedTag.nestedChildren) {
|
||||
if (nestedChild.parentTag) {
|
||||
const idx = flatTagList.findIndex((item) => item.tag === nestedChild.parentTag)
|
||||
// Then add all children
|
||||
for (const child of nestedTag.children) {
|
||||
const idx = flatTagList.findIndex((item) => item.tag === child.fullTag)
|
||||
if (idx >= 0) {
|
||||
indices.push(idx)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// We're at root level, show all non-child items
|
||||
// (variables and parent tags, but not their children)
|
||||
for (let i = 0; i < flatTagList.length; i++) {
|
||||
const tag = flatTagList[i].tag
|
||||
|
||||
// Check if this is a child of a parent folder
|
||||
let isChild = false
|
||||
for (const group of nestedBlockTagGroups) {
|
||||
if (isChildOfAnyFolder(group.nestedTags, tag)) {
|
||||
isChild = true
|
||||
break
|
||||
for (const nestedTag of group.nestedTags) {
|
||||
if (nestedTag.children) {
|
||||
for (const child of nestedTag.children) {
|
||||
if (child.fullTag === tag) {
|
||||
isChild = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isChild) break
|
||||
}
|
||||
if (isChild) break
|
||||
}
|
||||
|
||||
if (!isChild) {
|
||||
@@ -177,16 +81,16 @@ export const KeyboardNavigationHandler: React.FC<KeyboardNavigationHandlerProps>
|
||||
}
|
||||
|
||||
return indices
|
||||
}, [isInFolder, currentFolder, flatTagList, nestedBlockTagGroups, nestedNav])
|
||||
|
||||
const nestedPathLength = nestedNav?.nestedPath.length ?? 0
|
||||
}, [isInFolder, currentFolder, flatTagList, nestedBlockTagGroups])
|
||||
|
||||
// Auto-select first visible item when entering/exiting folders
|
||||
useEffect(() => {
|
||||
if (!visible || visibleIndices.length === 0) return
|
||||
|
||||
setSelectedIndex(visibleIndices[0])
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [visible, isInFolder, currentFolder, nestedPathLength])
|
||||
if (!visibleIndices.includes(selectedIndex)) {
|
||||
setSelectedIndex(visibleIndices[0])
|
||||
}
|
||||
}, [visible, isInFolder, currentFolder, visibleIndices, selectedIndex, setSelectedIndex])
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible || !flatTagList.length) return
|
||||
@@ -213,98 +117,89 @@ export const KeyboardNavigationHandler: React.FC<KeyboardNavigationHandlerProps>
|
||||
id: string
|
||||
title: string
|
||||
parentTag: string
|
||||
group: NestedBlockTagGroup
|
||||
nestedTag: NestedTag
|
||||
group: BlockTagGroup
|
||||
} | null = null
|
||||
|
||||
if (selected) {
|
||||
for (const group of nestedBlockTagGroups) {
|
||||
const folderInfo = findFolderInfoForTag(group.nestedTags, selected.tag, group)
|
||||
if (folderInfo) {
|
||||
currentFolderInfo = folderInfo
|
||||
break
|
||||
for (const nestedTag of group.nestedTags) {
|
||||
if (
|
||||
nestedTag.parentTag === selected.tag &&
|
||||
nestedTag.children &&
|
||||
nestedTag.children.length > 0
|
||||
) {
|
||||
currentFolderInfo = {
|
||||
id: `${selected.group?.blockId}-${nestedTag.key}`,
|
||||
title: nestedTag.display,
|
||||
parentTag: nestedTag.parentTag,
|
||||
group,
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if (currentFolderInfo) break
|
||||
}
|
||||
}
|
||||
|
||||
const scrollIntoView = () => {
|
||||
setTimeout(() => {
|
||||
const selectedItem = document.querySelector<HTMLElement>(
|
||||
'[data-radix-popper-content-wrapper] [aria-selected="true"]'
|
||||
)
|
||||
if (selectedItem) {
|
||||
selectedItem.scrollIntoView({ behavior: 'auto', block: 'nearest' })
|
||||
}
|
||||
}, 0)
|
||||
}
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setKeyboardNav(true)
|
||||
if (visibleIndices.length > 0) {
|
||||
const currentVisibleIndex = visibleIndices.indexOf(selectedIndex)
|
||||
let newIndex: number
|
||||
if (currentVisibleIndex === -1) {
|
||||
newIndex = visibleIndices[0]
|
||||
setSelectedIndex(visibleIndices[0])
|
||||
} else if (currentVisibleIndex < visibleIndices.length - 1) {
|
||||
newIndex = visibleIndices[currentVisibleIndex + 1]
|
||||
} else {
|
||||
newIndex = visibleIndices[0]
|
||||
setSelectedIndex(visibleIndices[currentVisibleIndex + 1])
|
||||
}
|
||||
setSelectedIndex(newIndex)
|
||||
scrollIntoView()
|
||||
}
|
||||
break
|
||||
case 'ArrowUp':
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setKeyboardNav(true)
|
||||
if (visibleIndices.length > 0) {
|
||||
const currentVisibleIndex = visibleIndices.indexOf(selectedIndex)
|
||||
let newIndex: number
|
||||
if (currentVisibleIndex === -1) {
|
||||
newIndex = visibleIndices[visibleIndices.length - 1]
|
||||
setSelectedIndex(visibleIndices[0])
|
||||
} else if (currentVisibleIndex > 0) {
|
||||
newIndex = visibleIndices[currentVisibleIndex - 1]
|
||||
} else {
|
||||
newIndex = visibleIndices[visibleIndices.length - 1]
|
||||
setSelectedIndex(visibleIndices[currentVisibleIndex - 1])
|
||||
}
|
||||
setSelectedIndex(newIndex)
|
||||
scrollIntoView()
|
||||
}
|
||||
break
|
||||
case 'Enter':
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (selected && selectedIndex >= 0 && selectedIndex < flatTagList.length) {
|
||||
handleTagSelect(selected.tag, selected.group)
|
||||
}
|
||||
break
|
||||
case 'ArrowRight':
|
||||
if (currentFolderInfo) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (isInFolder && nestedNav) {
|
||||
nestedNav.navigateIn(currentFolderInfo.nestedTag, currentFolderInfo.group)
|
||||
} else {
|
||||
if (currentFolderInfo && !isInFolder) {
|
||||
// It's a folder, open it
|
||||
openFolderWithSelection(
|
||||
currentFolderInfo.id,
|
||||
currentFolderInfo.title,
|
||||
currentFolderInfo.parentTag,
|
||||
currentFolderInfo.group
|
||||
)
|
||||
} else {
|
||||
// Not a folder, select it
|
||||
handleTagSelect(selected.tag, selected.group)
|
||||
}
|
||||
}
|
||||
break
|
||||
case 'ArrowRight':
|
||||
if (currentFolderInfo && !isInFolder) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
openFolderWithSelection(
|
||||
currentFolderInfo.id,
|
||||
currentFolderInfo.title,
|
||||
currentFolderInfo.parentTag,
|
||||
currentFolderInfo.group
|
||||
)
|
||||
}
|
||||
break
|
||||
case 'ArrowLeft':
|
||||
if (isInFolder) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (nestedNav?.navigateBack()) {
|
||||
return
|
||||
}
|
||||
closeFolder()
|
||||
let firstRootIndex = 0
|
||||
for (let i = 0; i < flatTagList.length; i++) {
|
||||
@@ -344,8 +239,6 @@ export const KeyboardNavigationHandler: React.FC<KeyboardNavigationHandlerProps>
|
||||
isInFolder,
|
||||
setSelectedIndex,
|
||||
handleTagSelect,
|
||||
nestedNav,
|
||||
setKeyboardNav,
|
||||
])
|
||||
|
||||
return null
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,27 +10,14 @@ export interface BlockTagGroup {
|
||||
}
|
||||
|
||||
/**
|
||||
* Child tag within a nested structure
|
||||
*/
|
||||
export interface NestedTagChild {
|
||||
key: string
|
||||
display: string
|
||||
fullTag: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Nested tag structure for hierarchical display.
|
||||
* Supports recursive nesting for deeply nested object structures.
|
||||
* Nested tag structure for hierarchical display
|
||||
*/
|
||||
export interface NestedTag {
|
||||
key: string
|
||||
display: string
|
||||
fullTag?: string
|
||||
parentTag?: string
|
||||
/** Leaf children (no further nesting) */
|
||||
children?: NestedTagChild[]
|
||||
/** Recursively nested folders */
|
||||
nestedChildren?: NestedTag[]
|
||||
parentTag?: string // Tag for the parent object when it has children
|
||||
children?: Array<{ key: string; display: string; fullTag: string }>
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type React from 'react'
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { Loader2, WrenchIcon, XIcon } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
@@ -35,7 +35,6 @@ import {
|
||||
Code,
|
||||
FileSelectorInput,
|
||||
FileUpload,
|
||||
FolderSelectorInput,
|
||||
LongInput,
|
||||
ProjectSelectorInput,
|
||||
SheetSelectorInput,
|
||||
@@ -46,9 +45,7 @@ import {
|
||||
TimeInput,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components'
|
||||
import { DocumentSelector } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/document-selector/document-selector'
|
||||
import { DocumentTagEntry } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/document-tag-entry/document-tag-entry'
|
||||
import { KnowledgeBaseSelector } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/knowledge-base-selector/knowledge-base-selector'
|
||||
import { KnowledgeTagFilters } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/knowledge-tag-filters/knowledge-tag-filters'
|
||||
import {
|
||||
type CustomTool,
|
||||
CustomToolModal,
|
||||
@@ -78,13 +75,6 @@ import {
|
||||
isPasswordParameter,
|
||||
type ToolParameterConfig,
|
||||
} from '@/tools/params'
|
||||
import {
|
||||
buildCanonicalIndex,
|
||||
buildPreviewContextValues,
|
||||
type CanonicalIndex,
|
||||
evaluateSubBlockCondition,
|
||||
type SubBlockCondition,
|
||||
} from '@/tools/params-resolver'
|
||||
|
||||
const logger = createLogger('ToolInput')
|
||||
|
||||
@@ -314,42 +304,6 @@ function SheetSelectorSyncWrapper({
|
||||
)
|
||||
}
|
||||
|
||||
function FolderSelectorSyncWrapper({
|
||||
blockId,
|
||||
paramId,
|
||||
value,
|
||||
onChange,
|
||||
uiComponent,
|
||||
disabled,
|
||||
previewContextValues,
|
||||
}: {
|
||||
blockId: string
|
||||
paramId: string
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
uiComponent: any
|
||||
disabled: boolean
|
||||
previewContextValues?: Record<string, any>
|
||||
}) {
|
||||
return (
|
||||
<GenericSyncWrapper blockId={blockId} paramId={paramId} value={value} onChange={onChange}>
|
||||
<FolderSelectorInput
|
||||
blockId={blockId}
|
||||
subBlock={{
|
||||
id: paramId,
|
||||
type: 'folder-selector' as const,
|
||||
title: paramId,
|
||||
serviceId: uiComponent.serviceId,
|
||||
requiredScopes: uiComponent.requiredScopes || [],
|
||||
placeholder: uiComponent.placeholder,
|
||||
dependsOn: uiComponent.dependsOn,
|
||||
}}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</GenericSyncWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
function KnowledgeBaseSelectorSyncWrapper({
|
||||
blockId,
|
||||
paramId,
|
||||
@@ -388,7 +342,6 @@ function DocumentSelectorSyncWrapper({
|
||||
onChange,
|
||||
uiComponent,
|
||||
disabled,
|
||||
previewContextValues,
|
||||
}: {
|
||||
blockId: string
|
||||
paramId: string
|
||||
@@ -396,7 +349,6 @@ function DocumentSelectorSyncWrapper({
|
||||
onChange: (value: string) => void
|
||||
uiComponent: any
|
||||
disabled: boolean
|
||||
previewContextValues?: Record<string, any>
|
||||
}) {
|
||||
return (
|
||||
<GenericSyncWrapper blockId={blockId} paramId={paramId} value={value} onChange={onChange}>
|
||||
@@ -409,67 +361,6 @@ function DocumentSelectorSyncWrapper({
|
||||
dependsOn: ['knowledgeBaseId'],
|
||||
}}
|
||||
disabled={disabled}
|
||||
previewContextValues={previewContextValues}
|
||||
/>
|
||||
</GenericSyncWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
function DocumentTagEntrySyncWrapper({
|
||||
blockId,
|
||||
paramId,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
previewContextValues,
|
||||
}: {
|
||||
blockId: string
|
||||
paramId: string
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
disabled: boolean
|
||||
previewContextValues?: Record<string, any>
|
||||
}) {
|
||||
return (
|
||||
<GenericSyncWrapper blockId={blockId} paramId={paramId} value={value} onChange={onChange}>
|
||||
<DocumentTagEntry
|
||||
blockId={blockId}
|
||||
subBlock={{
|
||||
id: paramId,
|
||||
type: 'document-tag-entry',
|
||||
}}
|
||||
disabled={disabled}
|
||||
previewContextValues={previewContextValues}
|
||||
/>
|
||||
</GenericSyncWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
function KnowledgeTagFiltersSyncWrapper({
|
||||
blockId,
|
||||
paramId,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
previewContextValues,
|
||||
}: {
|
||||
blockId: string
|
||||
paramId: string
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
disabled: boolean
|
||||
previewContextValues?: Record<string, any>
|
||||
}) {
|
||||
return (
|
||||
<GenericSyncWrapper blockId={blockId} paramId={paramId} value={value} onChange={onChange}>
|
||||
<KnowledgeTagFilters
|
||||
blockId={blockId}
|
||||
subBlock={{
|
||||
id: paramId,
|
||||
type: 'knowledge-tag-filters',
|
||||
}}
|
||||
disabled={disabled}
|
||||
previewContextValues={previewContextValues}
|
||||
/>
|
||||
</GenericSyncWrapper>
|
||||
)
|
||||
@@ -606,15 +497,11 @@ function CheckboxListSyncWrapper({
|
||||
}
|
||||
|
||||
function ComboboxSyncWrapper({
|
||||
blockId,
|
||||
paramId,
|
||||
value,
|
||||
onChange,
|
||||
uiComponent,
|
||||
disabled,
|
||||
}: {
|
||||
blockId: string
|
||||
paramId: string
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
uiComponent: any
|
||||
@@ -625,15 +512,13 @@ function ComboboxSyncWrapper({
|
||||
)
|
||||
|
||||
return (
|
||||
<GenericSyncWrapper blockId={blockId} paramId={paramId} value={value} onChange={onChange}>
|
||||
<Combobox
|
||||
options={options}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={uiComponent.placeholder || 'Select option'}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</GenericSyncWrapper>
|
||||
<Combobox
|
||||
options={options}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={uiComponent.placeholder || 'Select option'}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -712,8 +597,6 @@ function SlackSelectorSyncWrapper({
|
||||
}
|
||||
|
||||
function WorkflowSelectorSyncWrapper({
|
||||
blockId,
|
||||
paramId,
|
||||
value,
|
||||
onChange,
|
||||
uiComponent,
|
||||
@@ -721,8 +604,6 @@ function WorkflowSelectorSyncWrapper({
|
||||
workspaceId,
|
||||
currentWorkflowId,
|
||||
}: {
|
||||
blockId: string
|
||||
paramId: string
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
uiComponent: any
|
||||
@@ -742,17 +623,15 @@ function WorkflowSelectorSyncWrapper({
|
||||
}))
|
||||
|
||||
return (
|
||||
<GenericSyncWrapper blockId={blockId} paramId={paramId} value={value} onChange={onChange}>
|
||||
<Combobox
|
||||
options={options}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={uiComponent.placeholder || 'Select workflow'}
|
||||
disabled={disabled || isLoading}
|
||||
searchable
|
||||
searchPlaceholder='Search workflows...'
|
||||
/>
|
||||
</GenericSyncWrapper>
|
||||
<Combobox
|
||||
options={options}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={uiComponent.placeholder || 'Select workflow'}
|
||||
disabled={disabled || isLoading}
|
||||
searchable
|
||||
searchPlaceholder='Search workflows...'
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -998,7 +877,7 @@ function createToolIcon(bgColor: string, IconComponent: any) {
|
||||
* - Allows drag-and-drop reordering of selected tools
|
||||
* - Supports tool usage control (auto/force/none) for compatible LLM providers
|
||||
*/
|
||||
export const ToolInput = memo(function ToolInput({
|
||||
export function ToolInput({
|
||||
blockId,
|
||||
subBlockId,
|
||||
isPreview = false,
|
||||
@@ -1913,13 +1792,57 @@ export const ToolInput = memo(function ToolInput({
|
||||
return toolParams?.toolConfig?.oauth
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluates parameter conditions to determine if a parameter should be visible.
|
||||
*
|
||||
* @remarks
|
||||
* Supports field value matching with arrays, negation via `not`, and
|
||||
* compound conditions via `and`. Used for conditional parameter visibility.
|
||||
*
|
||||
* @param param - The parameter configuration with optional condition
|
||||
* @param tool - The current tool instance with its parameter values
|
||||
* @returns `true` if the parameter should be shown based on its condition
|
||||
*/
|
||||
const evaluateParameterCondition = (param: any, tool: StoredTool): boolean => {
|
||||
if (!('uiComponent' in param) || !param.uiComponent?.condition) return true
|
||||
const currentValues: Record<string, any> = { operation: tool.operation, ...tool.params }
|
||||
return evaluateSubBlockCondition(
|
||||
param.uiComponent.condition as SubBlockCondition,
|
||||
currentValues
|
||||
)
|
||||
|
||||
const condition = param.uiComponent.condition
|
||||
const currentValues: Record<string, any> = {
|
||||
operation: tool.operation,
|
||||
...tool.params,
|
||||
}
|
||||
|
||||
const fieldValue = currentValues[condition.field]
|
||||
let result = false
|
||||
|
||||
if (Array.isArray(condition.value)) {
|
||||
result = condition.value.includes(fieldValue)
|
||||
} else {
|
||||
result = fieldValue === condition.value
|
||||
}
|
||||
|
||||
if (condition.not) {
|
||||
result = !result
|
||||
}
|
||||
|
||||
if (condition.and) {
|
||||
const andFieldValue = currentValues[condition.and.field]
|
||||
let andResult = false
|
||||
|
||||
if (Array.isArray(condition.and.value)) {
|
||||
andResult = condition.and.value.includes(andFieldValue)
|
||||
} else {
|
||||
andResult = andFieldValue === condition.and.value
|
||||
}
|
||||
|
||||
if (condition.and.not) {
|
||||
andResult = !andResult
|
||||
}
|
||||
|
||||
result = result && andResult
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2038,7 +1961,7 @@ export const ToolInput = memo(function ToolInput({
|
||||
onChange={onChange}
|
||||
uiComponent={uiComponent}
|
||||
disabled={disabled}
|
||||
previewContextValues={currentToolParams}
|
||||
previewContextValues={currentToolParams as any}
|
||||
selectorType='channel-selector'
|
||||
/>
|
||||
)
|
||||
@@ -2052,7 +1975,7 @@ export const ToolInput = memo(function ToolInput({
|
||||
onChange={onChange}
|
||||
uiComponent={uiComponent}
|
||||
disabled={disabled}
|
||||
previewContextValues={currentToolParams}
|
||||
previewContextValues={currentToolParams as any}
|
||||
selectorType='user-selector'
|
||||
/>
|
||||
)
|
||||
@@ -2072,7 +1995,7 @@ export const ToolInput = memo(function ToolInput({
|
||||
}}
|
||||
onProjectSelect={onChange}
|
||||
disabled={disabled}
|
||||
previewContextValues={currentToolParams}
|
||||
previewContextValues={currentToolParams as any}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -2097,7 +2020,7 @@ export const ToolInput = memo(function ToolInput({
|
||||
onChange={onChange}
|
||||
uiComponent={uiComponent}
|
||||
disabled={disabled}
|
||||
previewContextValues={currentToolParams}
|
||||
previewContextValues={currentToolParams as any}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -2110,20 +2033,7 @@ export const ToolInput = memo(function ToolInput({
|
||||
onChange={onChange}
|
||||
uiComponent={uiComponent}
|
||||
disabled={disabled}
|
||||
previewContextValues={currentToolParams}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'folder-selector':
|
||||
return (
|
||||
<FolderSelectorSyncWrapper
|
||||
blockId={blockId}
|
||||
paramId={param.id}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
uiComponent={uiComponent}
|
||||
disabled={disabled}
|
||||
previewContextValues={currentToolParams}
|
||||
previewContextValues={currentToolParams as any}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -2142,8 +2052,6 @@ export const ToolInput = memo(function ToolInput({
|
||||
case 'combobox':
|
||||
return (
|
||||
<ComboboxSyncWrapper
|
||||
blockId={blockId}
|
||||
paramId={param.id}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
uiComponent={uiComponent}
|
||||
@@ -2202,8 +2110,6 @@ export const ToolInput = memo(function ToolInput({
|
||||
case 'workflow-selector':
|
||||
return (
|
||||
<WorkflowSelectorSyncWrapper
|
||||
blockId={blockId}
|
||||
paramId={param.id}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
uiComponent={uiComponent}
|
||||
@@ -2261,31 +2167,6 @@ export const ToolInput = memo(function ToolInput({
|
||||
onChange={onChange}
|
||||
uiComponent={uiComponent}
|
||||
disabled={disabled}
|
||||
previewContextValues={currentToolParams}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'document-tag-entry':
|
||||
return (
|
||||
<DocumentTagEntrySyncWrapper
|
||||
blockId={blockId}
|
||||
paramId={param.id}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
previewContextValues={currentToolParams}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'knowledge-tag-filters':
|
||||
return (
|
||||
<KnowledgeTagFiltersSyncWrapper
|
||||
blockId={blockId}
|
||||
paramId={param.id}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
previewContextValues={currentToolParams}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -2344,27 +2225,9 @@ export const ToolInput = memo(function ToolInput({
|
||||
// Get tool parameters using the new utility with block type for UI components
|
||||
const toolParams =
|
||||
!isCustomTool && !isMcpTool && currentToolId
|
||||
? getToolParametersConfig(currentToolId, tool.type, {
|
||||
operation: tool.operation,
|
||||
...tool.params,
|
||||
})
|
||||
? getToolParametersConfig(currentToolId, tool.type)
|
||||
: null
|
||||
|
||||
// Build canonical index for proper dependency resolution
|
||||
const toolCanonicalIndex: CanonicalIndex | null = toolBlock?.subBlocks
|
||||
? buildCanonicalIndex(toolBlock.subBlocks)
|
||||
: null
|
||||
|
||||
// Build preview context with canonical resolution
|
||||
const toolContextValues = toolCanonicalIndex
|
||||
? buildPreviewContextValues(tool.params || {}, {
|
||||
blockType: tool.type,
|
||||
subBlocks: toolBlock!.subBlocks,
|
||||
canonicalIndex: toolCanonicalIndex,
|
||||
values: { operation: tool.operation, ...tool.params },
|
||||
})
|
||||
: tool.params || {}
|
||||
|
||||
// For custom tools, resolve from reference (new format) or use inline (legacy)
|
||||
const resolvedCustomTool = isCustomTool
|
||||
? resolveCustomToolFromReference(tool, customTools)
|
||||
@@ -2727,7 +2590,7 @@ export const ToolInput = memo(function ToolInput({
|
||||
{param.required && param.visibility === 'user-only' && (
|
||||
<span className='ml-1'>*</span>
|
||||
)}
|
||||
{param.visibility === 'user-or-llm' && (
|
||||
{(!param.required || param.visibility !== 'user-only') && (
|
||||
<span className='ml-[6px] text-[12px] text-[var(--text-tertiary)]'>
|
||||
(optional)
|
||||
</span>
|
||||
@@ -2740,7 +2603,7 @@ export const ToolInput = memo(function ToolInput({
|
||||
tool.params?.[param.id] || '',
|
||||
(value) => handleParamChange(toolIndex, param.id, value),
|
||||
toolIndex,
|
||||
toolContextValues as Record<string, string>
|
||||
tool.params || {}
|
||||
)
|
||||
) : (
|
||||
<ShortInput
|
||||
@@ -2819,4 +2682,4 @@ export const ToolInput = memo(function ToolInput({
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user