mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-12 00:18:09 -05:00
Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0dd7735251 | ||
|
|
8ddc1d8eda | ||
|
|
e90138a651 | ||
|
|
d783ba6fb7 | ||
|
|
a599d3ae0a | ||
|
|
ca468dcbcd | ||
|
|
fe82166ebc | ||
|
|
4433100a47 | ||
|
|
92a998df0e | ||
|
|
a7c8f5dfe9 | ||
|
|
e392ca43aa | ||
|
|
a217daca3c | ||
|
|
21ffa5048b | ||
|
|
78f45a587a | ||
|
|
d8a7aaaf88 | ||
|
|
5158a00b54 | ||
|
|
31d909bb82 | ||
|
|
8417a98be0 | ||
|
|
f5a64f400e | ||
|
|
2ae9cbc17f | ||
|
|
919cf0b58e | ||
|
|
560d184c31 | ||
|
|
e4fbb67833 | ||
|
|
7f0f902204 | ||
|
|
7739917941 | ||
|
|
d94bfd9a91 | ||
|
|
d919073bea | ||
|
|
80076012c6 |
39
README.md
39
README.md
@@ -91,6 +91,12 @@ docker compose -f docker-compose.prod.yml up -d
|
||||
|
||||
### Option 4: Manual Setup
|
||||
|
||||
**Requirements:**
|
||||
- [Bun](https://bun.sh/) runtime
|
||||
- PostgreSQL 12+ with [pgvector extension](https://github.com/pgvector/pgvector) (required for AI embeddings)
|
||||
|
||||
**Note:** Sim Studio uses vector embeddings for AI features like knowledge bases and semantic search, which requires the `pgvector` PostgreSQL extension.
|
||||
|
||||
1. Clone and install dependencies:
|
||||
|
||||
```bash
|
||||
@@ -99,20 +105,43 @@ cd sim
|
||||
bun install
|
||||
```
|
||||
|
||||
2. Set up environment:
|
||||
2. Set up PostgreSQL with pgvector:
|
||||
|
||||
You need PostgreSQL with the `vector` extension for embedding support. Choose one option:
|
||||
|
||||
**Option A: Using Docker (Recommended)**
|
||||
```bash
|
||||
# Start PostgreSQL with pgvector extension
|
||||
docker run --name simstudio-db \
|
||||
-e POSTGRES_PASSWORD=your_password \
|
||||
-e POSTGRES_DB=simstudio \
|
||||
-p 5432:5432 -d \
|
||||
pgvector/pgvector:pg17
|
||||
```
|
||||
|
||||
**Option B: Manual Installation**
|
||||
- Install PostgreSQL 12+ and the pgvector extension
|
||||
- See [pgvector installation guide](https://github.com/pgvector/pgvector#installation)
|
||||
|
||||
3. Set up environment:
|
||||
|
||||
```bash
|
||||
cd apps/sim
|
||||
cp .env.example .env # Configure with required variables (DATABASE_URL, BETTER_AUTH_SECRET, BETTER_AUTH_URL)
|
||||
```
|
||||
|
||||
3. Set up the database:
|
||||
|
||||
Update your `.env` file with the database URL:
|
||||
```bash
|
||||
bunx drizzle-kit push
|
||||
DATABASE_URL="postgresql://postgres:your_password@localhost:5432/simstudio"
|
||||
```
|
||||
|
||||
4. Start the development servers:
|
||||
4. Set up the database:
|
||||
|
||||
```bash
|
||||
bunx drizzle-kit migrate
|
||||
```
|
||||
|
||||
5. Start the development servers:
|
||||
|
||||
**Recommended approach - run both servers together (from project root):**
|
||||
|
||||
|
||||
@@ -172,4 +172,4 @@ After a loop completes, you can access aggregated results:
|
||||
|
||||
- **Set reasonable limits**: Keep iteration counts reasonable to avoid long execution times
|
||||
- **Use ForEach for collections**: When processing arrays or objects, use ForEach instead of For loops
|
||||
- **Handle errors gracefully**: Consider adding error handling inside loops for robust workflows
|
||||
- **Handle errors gracefully**: Consider adding error handling inside loops for robust workflows
|
||||
|
||||
@@ -207,4 +207,4 @@ Understanding when to use each:
|
||||
|
||||
- **Independent operations only**: Ensure operations don't depend on each other
|
||||
- **Handle rate limits**: Add delays or throttling for API-heavy workflows
|
||||
- **Error handling**: Each instance should handle its own errors gracefully
|
||||
- **Error handling**: Each instance should handle its own errors gracefully
|
||||
|
||||
@@ -182,4 +182,5 @@ headers:
|
||||
- **Structure your responses consistently**: Maintain a consistent JSON structure across all your API endpoints for better developer experience
|
||||
- **Include relevant metadata**: Add timestamps and version information to help with debugging and monitoring
|
||||
- **Handle errors gracefully**: Use conditional logic in your workflow to set appropriate error responses with descriptive messages
|
||||
- **Validate variable references**: Ensure all referenced variables exist and contain the expected data types before the Response block executes
|
||||
- **Validate variable references**: Ensure all referenced variables exist and contain the expected data types before the Response block executes
|
||||
|
||||
|
||||
@@ -256,4 +256,4 @@ return {
|
||||
- **Document dependencies**: Clearly document which workflows depend on others and maintain dependency maps
|
||||
- **Test independently**: Ensure child workflows can be tested and validated independently from parent workflows
|
||||
- **Monitor performance**: Be aware that nested workflows can impact overall execution time and resource usage
|
||||
- **Use semantic naming**: Give workflows descriptive names that clearly indicate their purpose and functionality
|
||||
- **Use semantic naming**: Give workflows descriptive names that clearly indicate their purpose and functionality
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"execution",
|
||||
"---Advanced---",
|
||||
"./variables/index",
|
||||
"yaml",
|
||||
"---SDKs---",
|
||||
"./sdks/python",
|
||||
"./sdks/typescript"
|
||||
|
||||
@@ -64,3 +64,14 @@ Tools typically return structured data that can be processed by subsequent block
|
||||
- Status information
|
||||
|
||||
Refer to each tool's specific documentation to understand its exact output format.
|
||||
|
||||
## YAML Configuration
|
||||
|
||||
For detailed YAML workflow configuration and syntax, see the [YAML Workflow Reference](/yaml) documentation. This includes comprehensive guides for:
|
||||
|
||||
- **Block Reference Syntax**: How to connect and reference data between blocks
|
||||
- **Tool Configuration**: Using tools in both standalone blocks and agent configurations
|
||||
- **Environment Variables**: Secure handling of API keys and credentials
|
||||
- **Complete Examples**: Real-world workflow patterns and configurations
|
||||
|
||||
For specific tool parameters and configuration options, refer to each tool's individual documentation page.
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
"outlook",
|
||||
"perplexity",
|
||||
"pinecone",
|
||||
"qdrant",
|
||||
"reddit",
|
||||
"s3",
|
||||
"serper",
|
||||
|
||||
176
apps/docs/content/docs/tools/qdrant.mdx
Normal file
176
apps/docs/content/docs/tools/qdrant.mdx
Normal file
@@ -0,0 +1,176 @@
|
||||
---
|
||||
title: Qdrant
|
||||
description: Use Qdrant vector database
|
||||
---
|
||||
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="qdrant"
|
||||
color="#1A223F"
|
||||
icon={true}
|
||||
iconSvg={`<svg className="block-icon" fill='none' viewBox='0 0 49 56' xmlns='http://www.w3.org/2000/svg'>
|
||||
<g clip-path='url(#b)'>
|
||||
<path
|
||||
d='m38.489 51.477-1.1167-30.787-2.0223-8.1167 13.498 1.429v37.242l-8.2456 4.7589-2.1138-4.5259z'
|
||||
clipRule='evenodd'
|
||||
fill='#24386C'
|
||||
fillRule='evenodd'
|
||||
/>
|
||||
<path
|
||||
d='m48.847 14-8.2457 4.7622-17.016-3.7326-19.917 8.1094-3.3183-9.139 12.122-7 12.126-7 12.123 7 12.126 7z'
|
||||
clipRule='evenodd'
|
||||
fill='#7589BE'
|
||||
fillRule='evenodd'
|
||||
/>
|
||||
<path
|
||||
d='m0.34961 13.999 8.2457 4.7622 4.7798 14.215 16.139 12.913-4.9158 10.109-12.126-7.0004-12.123-7v-28z'
|
||||
clipRule='evenodd'
|
||||
fill='#B2BFE8'
|
||||
fillRule='evenodd'
|
||||
/>
|
||||
<path
|
||||
d='m30.066 38.421-5.4666 8.059v9.5207l7.757-4.4756 3.9968-5.9681'
|
||||
clipRule='evenodd'
|
||||
fill='#24386C'
|
||||
fillRule='evenodd'
|
||||
/>
|
||||
<path
|
||||
d='m24.602 36.962-7.7603-13.436 1.6715-4.4531 6.3544-3.0809 7.488 7.5343-7.7536 13.436z'
|
||||
clipRule='evenodd'
|
||||
fill='#7589BE'
|
||||
fillRule='evenodd'
|
||||
/>
|
||||
<path
|
||||
d='m16.843 23.525 7.7569 4.4756v8.9585l-7.1741 0.3087-4.3397-5.5412 3.7569-8.2016z'
|
||||
clipRule='evenodd'
|
||||
fill='#B2BFE8'
|
||||
fillRule='evenodd'
|
||||
/>
|
||||
<path
|
||||
d='m24.6 28 7.757-4.4752 5.2792 8.7903-6.3886 5.2784-6.6476-0.6346v-8.9589z'
|
||||
clipRule='evenodd'
|
||||
fill='#24386C'
|
||||
fillRule='evenodd'
|
||||
/>
|
||||
<path
|
||||
d='m32.355 51.524 8.2457 4.476v-37.238l-8.0032-4.6189-7.9995-4.6189-8.0031 4.6189-7.9995 4.6189v18.479l7.9995 4.6189 8.0031 4.6193 7.757-4.4797v9.5244zm0-19.045-7.757 4.4793-7.7569-4.4793v-8.9549l7.7569-4.4792 7.757 4.4792v8.9549z'
|
||||
clipRule='evenodd'
|
||||
fill='#DC244C'
|
||||
fillRule='evenodd'
|
||||
/>
|
||||
<path d='m24.603 46.483v-9.5222l-7.7166-4.4411v9.5064l7.7166 4.4569z' fill='url(#a)' />
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id='a'
|
||||
x1='23.18'
|
||||
x2='15.491'
|
||||
y1='38.781'
|
||||
y2='38.781'
|
||||
gradientUnits='userSpaceOnUse'
|
||||
>
|
||||
<stop stopColor='#FF3364' offset='0' />
|
||||
<stop stopColor='#C91540' stopOpacity='0' offset='1' />
|
||||
</linearGradient>
|
||||
<clipPath id='b'>
|
||||
<rect transform='translate(.34961)' fill='#fff' />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>`}
|
||||
/>
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Store, search, and retrieve vector embeddings using Qdrant. Perform semantic similarity searches and manage your vector collections.
|
||||
|
||||
|
||||
|
||||
## Tools
|
||||
|
||||
### `qdrant_upsert_points`
|
||||
|
||||
Insert or update points in a Qdrant collection
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `url` | string | Yes | Qdrant base URL |
|
||||
| `apiKey` | string | No | Qdrant API key \(optional\) |
|
||||
| `collection` | string | Yes | Collection name |
|
||||
| `points` | array | Yes | Array of points to upsert |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type |
|
||||
| --------- | ---- |
|
||||
| `status` | string |
|
||||
| `data` | string |
|
||||
|
||||
### `qdrant_search_vector`
|
||||
|
||||
Search for similar vectors in a Qdrant collection
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `url` | string | Yes | Qdrant base URL |
|
||||
| `apiKey` | string | No | Qdrant API key \(optional\) |
|
||||
| `collection` | string | Yes | Collection name |
|
||||
| `vector` | array | Yes | Vector to search for |
|
||||
| `limit` | number | No | Number of results to return |
|
||||
| `filter` | object | No | Filter to apply to the search |
|
||||
| `with_payload` | boolean | No | Include payload in response |
|
||||
| `with_vector` | boolean | No | Include vector in response |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type |
|
||||
| --------- | ---- |
|
||||
| `data` | string |
|
||||
| `status` | string |
|
||||
|
||||
### `qdrant_fetch_points`
|
||||
|
||||
Fetch points by ID from a Qdrant collection
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `url` | string | Yes | Qdrant base URL |
|
||||
| `apiKey` | string | No | Qdrant API key \(optional\) |
|
||||
| `collection` | string | Yes | Collection name |
|
||||
| `ids` | array | Yes | Array of point IDs to fetch |
|
||||
| `with_payload` | boolean | No | Include payload in response |
|
||||
| `with_vector` | boolean | No | Include vector in response |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type |
|
||||
| --------- | ---- |
|
||||
| `data` | string |
|
||||
| `status` | string |
|
||||
|
||||
|
||||
|
||||
## Block Configuration
|
||||
|
||||
### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `operation` | string | Yes | Operation |
|
||||
|
||||
|
||||
|
||||
### Outputs
|
||||
|
||||
This block does not produce any outputs.
|
||||
|
||||
## Notes
|
||||
|
||||
- Category: `tools`
|
||||
- Type: `qdrant`
|
||||
238
apps/docs/content/docs/yaml/block-reference.mdx
Normal file
238
apps/docs/content/docs/yaml/block-reference.mdx
Normal file
@@ -0,0 +1,238 @@
|
||||
---
|
||||
title: Block Reference Syntax
|
||||
description: How to reference data between blocks in YAML workflows
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout'
|
||||
import { Tab, Tabs } from 'fumadocs-ui/components/tabs'
|
||||
|
||||
Block references are the foundation of data flow in Sim Studio workflows. Understanding how to correctly reference outputs from one block as inputs to another is essential for building functional workflows.
|
||||
|
||||
## Basic Reference Rules
|
||||
|
||||
### 1. Use Block Names, Not Block IDs
|
||||
|
||||
<Tabs items={['Correct', 'Incorrect']}>
|
||||
<Tab>
|
||||
```yaml
|
||||
# Block definition
|
||||
email-sender:
|
||||
type: agent
|
||||
name: "Email Generator"
|
||||
# ... configuration
|
||||
|
||||
# Reference the block
|
||||
next-block:
|
||||
inputs:
|
||||
userPrompt: "Process this: <emailgenerator.content>"
|
||||
```
|
||||
</Tab>
|
||||
<Tab>
|
||||
```yaml
|
||||
# Block definition
|
||||
email-sender:
|
||||
type: agent
|
||||
name: "Email Generator"
|
||||
# ... configuration
|
||||
|
||||
# ❌ Don't reference by block ID
|
||||
next-block:
|
||||
inputs:
|
||||
userPrompt: "Process this: <email-sender.content>"
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
### 2. Convert Names to Reference Format
|
||||
|
||||
To create a block reference:
|
||||
|
||||
1. **Take the block name**: "Email Generator"
|
||||
2. **Convert to lowercase**: "email generator"
|
||||
3. **Remove spaces and special characters**: "emailgenerator"
|
||||
4. **Add property**: `<emailgenerator.content>`
|
||||
|
||||
### 3. Use Correct Properties
|
||||
|
||||
Different block types expose different properties:
|
||||
|
||||
- **Agent blocks**: `.content` (the AI response)
|
||||
- **Function blocks**: `.output` (the return value)
|
||||
- **API blocks**: `.output` (the response data)
|
||||
- **Tool blocks**: `.output` (the tool result)
|
||||
|
||||
## Reference Examples
|
||||
|
||||
### Common Block References
|
||||
|
||||
```yaml
|
||||
# Agent block outputs
|
||||
<agentname.content> # Primary AI response
|
||||
<agentname.tokens> # Token usage information
|
||||
<agentname.cost> # Estimated cost
|
||||
<agentname.tool_calls> # Tool execution details
|
||||
|
||||
# Function block outputs
|
||||
<functionname.output> # Function return value
|
||||
<functionname.error> # Error information (if any)
|
||||
|
||||
# API block outputs
|
||||
<apiname.output> # Response data
|
||||
<apiname.status> # HTTP status code
|
||||
<apiname.headers> # Response headers
|
||||
|
||||
# Tool block outputs
|
||||
<toolname.output> # Tool execution result
|
||||
```
|
||||
|
||||
### Multi-Word Block Names
|
||||
|
||||
```yaml
|
||||
# Block name: "Data Processor 2"
|
||||
<dataprocessor2.output>
|
||||
|
||||
# Block name: "Email Validation Service"
|
||||
<emailvalidationservice.output>
|
||||
|
||||
# Block name: "Customer Info Agent"
|
||||
<customerinfoagent.content>
|
||||
```
|
||||
|
||||
## Special Reference Cases
|
||||
|
||||
### Starter Block
|
||||
|
||||
<Callout type="warning">
|
||||
The starter block is always referenced as `<start.input>` regardless of its actual name.
|
||||
</Callout>
|
||||
|
||||
```yaml
|
||||
# Starter block definition
|
||||
my-custom-start:
|
||||
type: starter
|
||||
name: "Custom Workflow Start"
|
||||
# ... configuration
|
||||
|
||||
# Always reference as 'start'
|
||||
agent-1:
|
||||
inputs:
|
||||
userPrompt: <start.input> # ✅ Correct
|
||||
# userPrompt: <customworkflowstart.input> # ❌ Wrong
|
||||
```
|
||||
|
||||
### Loop Variables
|
||||
|
||||
Inside loop blocks, special variables are available:
|
||||
|
||||
```yaml
|
||||
# Available in loop child blocks
|
||||
<loop.index> # Current iteration (0-based)
|
||||
<loop.currentItem> # Current item being processed (forEach loops)
|
||||
<loop.items> # Full collection (forEach loops)
|
||||
```
|
||||
|
||||
### Parallel Variables
|
||||
|
||||
Inside parallel blocks, special variables are available:
|
||||
|
||||
```yaml
|
||||
# Available in parallel child blocks
|
||||
<parallel.index> # Instance number (0-based)
|
||||
<parallel.currentItem> # Item for this instance
|
||||
<parallel.items> # Full collection
|
||||
```
|
||||
|
||||
## Complex Reference Examples
|
||||
|
||||
### Nested Data Access
|
||||
|
||||
When referencing complex objects, use dot notation:
|
||||
|
||||
```yaml
|
||||
# If an agent returns structured data
|
||||
data-analyzer:
|
||||
type: agent
|
||||
name: "Data Analyzer"
|
||||
inputs:
|
||||
responseFormat: |
|
||||
{
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"analysis": {"type": "object"},
|
||||
"summary": {"type": "string"},
|
||||
"metrics": {"type": "object"}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Reference nested properties
|
||||
next-step:
|
||||
inputs:
|
||||
userPrompt: |
|
||||
Summary: <dataanalyzer.analysis.summary>
|
||||
Score: <dataanalyzer.metrics.score>
|
||||
Full data: <dataanalyzer.content>
|
||||
```
|
||||
|
||||
### Multiple References in Text
|
||||
|
||||
```yaml
|
||||
email-composer:
|
||||
type: agent
|
||||
inputs:
|
||||
userPrompt: |
|
||||
Create an email with the following information:
|
||||
|
||||
Customer: <customeragent.content>
|
||||
Order Details: <orderprocessor.output>
|
||||
Support Ticket: <ticketanalyzer.content>
|
||||
|
||||
Original request: <start.input>
|
||||
```
|
||||
|
||||
### References in Code Blocks
|
||||
|
||||
When using references in function blocks, they're replaced as JavaScript values:
|
||||
|
||||
```yaml
|
||||
data-processor:
|
||||
type: function
|
||||
inputs:
|
||||
code: |
|
||||
// References are replaced with actual values
|
||||
const customerData = <customeragent.content>;
|
||||
const orderInfo = <orderprocessor.output>;
|
||||
const originalInput = <start.input>;
|
||||
|
||||
// Process the data
|
||||
return {
|
||||
customer: customerData.name,
|
||||
orderId: orderInfo.id,
|
||||
processed: true
|
||||
};
|
||||
```
|
||||
|
||||
## Reference Validation
|
||||
|
||||
Sim Studio validates all references when importing YAML:
|
||||
|
||||
### Valid References
|
||||
- Block exists in the workflow
|
||||
- Property is appropriate for block type
|
||||
- No circular dependencies
|
||||
- Proper syntax formatting
|
||||
|
||||
### Common Errors
|
||||
- **Block not found**: Referenced block doesn't exist
|
||||
- **Wrong property**: Using `.content` on a function block
|
||||
- **Typos**: Misspelled block names or properties
|
||||
- **Circular references**: Block references itself directly or indirectly
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Use descriptive block names**: Makes references more readable
|
||||
2. **Be consistent**: Use the same naming convention throughout
|
||||
3. **Check references**: Ensure all referenced blocks exist
|
||||
4. **Avoid deep nesting**: Keep reference chains manageable
|
||||
5. **Document complex flows**: Add comments to explain reference relationships
|
||||
218
apps/docs/content/docs/yaml/blocks/agent.mdx
Normal file
218
apps/docs/content/docs/yaml/blocks/agent.mdx
Normal file
@@ -0,0 +1,218 @@
|
||||
---
|
||||
title: Agent Block YAML Schema
|
||||
description: YAML configuration reference for Agent blocks
|
||||
---
|
||||
|
||||
## Schema Definition
|
||||
|
||||
```yaml
|
||||
type: object
|
||||
required:
|
||||
- type
|
||||
- name
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum: [agent]
|
||||
description: Block type identifier
|
||||
name:
|
||||
type: string
|
||||
description: Display name for this agent block
|
||||
inputs:
|
||||
type: object
|
||||
properties:
|
||||
systemPrompt:
|
||||
type: string
|
||||
description: Instructions that define the agent's role and behavior
|
||||
userPrompt:
|
||||
type: string
|
||||
description: Input content to process (can reference other blocks)
|
||||
model:
|
||||
type: string
|
||||
description: AI model identifier (e.g., gpt-4o, gemini-2.5-pro, deepseek-chat)
|
||||
temperature:
|
||||
type: number
|
||||
minimum: 0
|
||||
maximum: 2
|
||||
description: Response creativity level (varies by model)
|
||||
apiKey:
|
||||
type: string
|
||||
description: API key for the model provider (use {{ENV_VAR}} format)
|
||||
azureEndpoint:
|
||||
type: string
|
||||
description: Azure OpenAI endpoint URL (required for Azure models)
|
||||
azureApiVersion:
|
||||
type: string
|
||||
description: Azure API version (required for Azure models)
|
||||
memories:
|
||||
type: string
|
||||
description: Memory context from memory blocks
|
||||
tools:
|
||||
type: array
|
||||
description: List of external tools the agent can use
|
||||
items:
|
||||
type: object
|
||||
required: [type, title, toolId, operation, usageControl]
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
description: Tool type identifier
|
||||
title:
|
||||
type: string
|
||||
description: Human-readable display name
|
||||
toolId:
|
||||
type: string
|
||||
description: Internal tool identifier
|
||||
operation:
|
||||
type: string
|
||||
description: Tool operation/method name
|
||||
usageControl:
|
||||
type: string
|
||||
enum: [auto, required, none]
|
||||
description: When AI can use the tool
|
||||
params:
|
||||
type: object
|
||||
description: Tool-specific configuration parameters
|
||||
isExpanded:
|
||||
type: boolean
|
||||
description: UI state
|
||||
default: false
|
||||
responseFormat:
|
||||
type: object
|
||||
description: JSON Schema to enforce structured output
|
||||
required:
|
||||
- model
|
||||
- apiKey
|
||||
connections:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: string
|
||||
description: Target block ID for successful execution
|
||||
error:
|
||||
type: string
|
||||
description: Target block ID for error handling
|
||||
```
|
||||
|
||||
## Tool Configuration
|
||||
|
||||
Tools are defined as an array where each tool has this structure:
|
||||
|
||||
```yaml
|
||||
tools:
|
||||
- type: <string> # Tool type identifier (exa, gmail, slack, etc.)
|
||||
title: <string> # Human-readable display name
|
||||
toolId: <string> # Internal tool identifier
|
||||
operation: <string> # Tool operation/method name
|
||||
usageControl: <string> # When AI can use it (auto | required | none)
|
||||
params: <object> # Tool-specific configuration parameters
|
||||
isExpanded: <boolean> # UI state (optional, default: false)
|
||||
```
|
||||
|
||||
## Connection Configuration
|
||||
|
||||
Connections define where the workflow goes based on execution results:
|
||||
|
||||
```yaml
|
||||
connections:
|
||||
success: <string> # Target block ID for successful execution
|
||||
error: <string> # Target block ID for error handling (optional)
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### Basic Agent
|
||||
|
||||
```yaml
|
||||
content-agent:
|
||||
type: agent
|
||||
name: "Content Analyzer 1"
|
||||
inputs:
|
||||
systemPrompt: "You are a helpful content analyzer. Be concise and clear."
|
||||
userPrompt: <start.input>
|
||||
model: gpt-4o
|
||||
temperature: 0.3
|
||||
apiKey: '{{OPENAI_API_KEY}}'
|
||||
connections:
|
||||
success: summary-block
|
||||
|
||||
summary-block:
|
||||
type: agent
|
||||
name: "Summary Generator"
|
||||
inputs:
|
||||
systemPrompt: "Create a brief summary of the analysis."
|
||||
userPrompt: "Analyze this: <contentanalyzer1.content>"
|
||||
model: gpt-4o
|
||||
apiKey: '{{OPENAI_API_KEY}}'
|
||||
connections:
|
||||
success: final-step
|
||||
```
|
||||
|
||||
### Agent with Tools
|
||||
|
||||
```yaml
|
||||
research-agent:
|
||||
type: agent
|
||||
name: "Research Assistant"
|
||||
inputs:
|
||||
systemPrompt: "Research the topic and provide detailed information."
|
||||
userPrompt: <start.input>
|
||||
model: gpt-4o
|
||||
apiKey: '{{OPENAI_API_KEY}}'
|
||||
tools:
|
||||
- type: exa
|
||||
title: "Web Search"
|
||||
toolId: exa_search
|
||||
operation: exa_search
|
||||
usageControl: auto
|
||||
params:
|
||||
apiKey: '{{EXA_API_KEY}}'
|
||||
connections:
|
||||
success: summary-block
|
||||
```
|
||||
|
||||
### Structured Output
|
||||
|
||||
```yaml
|
||||
data-extractor:
|
||||
type: agent
|
||||
name: "Extract Contact Info"
|
||||
inputs:
|
||||
systemPrompt: "Extract contact information from the text."
|
||||
userPrompt: <start.input>
|
||||
model: gpt-4o
|
||||
apiKey: '{{OPENAI_API_KEY}}'
|
||||
responseFormat: |
|
||||
{
|
||||
"name": "contact_extraction",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
"email": {"type": "string"},
|
||||
"phone": {"type": "string"}
|
||||
},
|
||||
"required": ["name"]
|
||||
},
|
||||
"strict": true
|
||||
}
|
||||
connections:
|
||||
success: save-contact
|
||||
```
|
||||
|
||||
### Azure OpenAI
|
||||
|
||||
```yaml
|
||||
azure-agent:
|
||||
type: agent
|
||||
name: "Azure AI Assistant"
|
||||
inputs:
|
||||
systemPrompt: "You are a helpful assistant."
|
||||
userPrompt: <start.input>
|
||||
model: gpt-4o
|
||||
apiKey: '{{AZURE_OPENAI_API_KEY}}'
|
||||
azureEndpoint: '{{AZURE_OPENAI_ENDPOINT}}'
|
||||
azureApiVersion: "2024-07-01-preview"
|
||||
connections:
|
||||
success: response-block
|
||||
```
|
||||
179
apps/docs/content/docs/yaml/blocks/api.mdx
Normal file
179
apps/docs/content/docs/yaml/blocks/api.mdx
Normal file
@@ -0,0 +1,179 @@
|
||||
---
|
||||
title: API Block YAML Schema
|
||||
description: YAML configuration reference for API blocks
|
||||
---
|
||||
|
||||
## Schema Definition
|
||||
|
||||
```yaml
|
||||
type: object
|
||||
required:
|
||||
- type
|
||||
- name
|
||||
- inputs
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum: [api]
|
||||
description: Block type identifier
|
||||
name:
|
||||
type: string
|
||||
description: Display name for this API block
|
||||
inputs:
|
||||
type: object
|
||||
required:
|
||||
- url
|
||||
- method
|
||||
properties:
|
||||
url:
|
||||
type: string
|
||||
description: The endpoint URL to send the request to
|
||||
method:
|
||||
type: string
|
||||
enum: [GET, POST, PUT, DELETE, PATCH]
|
||||
description: HTTP method for the request
|
||||
default: GET
|
||||
queryParams:
|
||||
type: array
|
||||
description: Query parameters as key-value pairs
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
key:
|
||||
type: string
|
||||
description: Parameter name
|
||||
value:
|
||||
type: string
|
||||
description: Parameter value
|
||||
headers:
|
||||
type: array
|
||||
description: HTTP headers as key-value pairs
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
key:
|
||||
type: string
|
||||
description: Header name
|
||||
value:
|
||||
type: string
|
||||
description: Header value
|
||||
body:
|
||||
type: string
|
||||
description: Request body for POST/PUT/PATCH methods
|
||||
timeout:
|
||||
type: number
|
||||
description: Request timeout in milliseconds
|
||||
default: 30000
|
||||
minimum: 1000
|
||||
maximum: 300000
|
||||
connections:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: string
|
||||
description: Target block ID for successful requests
|
||||
error:
|
||||
type: string
|
||||
description: Target block ID for error handling
|
||||
```
|
||||
|
||||
## Connection Configuration
|
||||
|
||||
Connections define where the workflow goes based on request results:
|
||||
|
||||
```yaml
|
||||
connections:
|
||||
success: <string> # Target block ID for successful requests
|
||||
error: <string> # Target block ID for error handling (optional)
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### Simple GET Request
|
||||
|
||||
```yaml
|
||||
user-api:
|
||||
type: api
|
||||
name: "Fetch User Data"
|
||||
inputs:
|
||||
url: "https://api.example.com/users/123"
|
||||
method: GET
|
||||
headers:
|
||||
- key: "Authorization"
|
||||
value: "Bearer {{API_TOKEN}}"
|
||||
- key: "Content-Type"
|
||||
value: "application/json"
|
||||
connections:
|
||||
success: process-user-data
|
||||
error: handle-api-error
|
||||
```
|
||||
|
||||
### POST Request with Body
|
||||
|
||||
```yaml
|
||||
create-ticket:
|
||||
type: api
|
||||
name: "Create Support Ticket"
|
||||
inputs:
|
||||
url: "https://api.support.com/tickets"
|
||||
method: POST
|
||||
headers:
|
||||
- key: "Authorization"
|
||||
value: "Bearer {{SUPPORT_API_KEY}}"
|
||||
- key: "Content-Type"
|
||||
value: "application/json"
|
||||
body: |
|
||||
{
|
||||
"title": "<agent.title>",
|
||||
"description": "<agent.description>",
|
||||
"priority": "high"
|
||||
}
|
||||
connections:
|
||||
success: ticket-created
|
||||
error: ticket-error
|
||||
```
|
||||
|
||||
### Dynamic URL with Query Parameters
|
||||
|
||||
```yaml
|
||||
search-api:
|
||||
type: api
|
||||
name: "Search Products"
|
||||
inputs:
|
||||
url: "https://api.store.com/products"
|
||||
method: GET
|
||||
queryParams:
|
||||
- key: "q"
|
||||
value: <start.searchTerm>
|
||||
- key: "limit"
|
||||
value: "10"
|
||||
- key: "category"
|
||||
value: <filter.category>
|
||||
headers:
|
||||
- key: "Authorization"
|
||||
value: "Bearer {{STORE_API_KEY}}"
|
||||
connections:
|
||||
success: display-results
|
||||
```
|
||||
|
||||
## Output References
|
||||
|
||||
After an API block executes, you can reference its outputs:
|
||||
|
||||
```yaml
|
||||
# In subsequent blocks
|
||||
next-block:
|
||||
inputs:
|
||||
data: <api-block-name.output> # Response data
|
||||
status: <api-block-name.status> # HTTP status code
|
||||
headers: <api-block-name.headers> # Response headers
|
||||
error: <api-block-name.error> # Error details (if any)
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
- Use environment variables for API keys: `{{API_KEY_NAME}}`
|
||||
- Include error handling with error connections
|
||||
- Set appropriate timeouts for your use case
|
||||
- Validate response status codes in subsequent blocks
|
||||
- Use meaningful block names for easier reference
|
||||
165
apps/docs/content/docs/yaml/blocks/condition.mdx
Normal file
165
apps/docs/content/docs/yaml/blocks/condition.mdx
Normal file
@@ -0,0 +1,165 @@
|
||||
---
|
||||
title: Condition Block YAML Schema
|
||||
description: YAML configuration reference for Condition blocks
|
||||
---
|
||||
|
||||
## Schema Definition
|
||||
|
||||
```yaml
|
||||
type: object
|
||||
required:
|
||||
- type
|
||||
- name
|
||||
- inputs
|
||||
- connections
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum: [condition]
|
||||
description: Block type identifier
|
||||
name:
|
||||
type: string
|
||||
description: Display name for this condition block
|
||||
inputs:
|
||||
type: object
|
||||
required:
|
||||
- conditions
|
||||
properties:
|
||||
conditions:
|
||||
type: object
|
||||
description: Conditional expressions and their logic
|
||||
properties:
|
||||
if:
|
||||
type: string
|
||||
description: Primary condition expression (boolean)
|
||||
else-if:
|
||||
type: string
|
||||
description: Secondary condition expression (optional)
|
||||
else-if-2:
|
||||
type: string
|
||||
description: Third condition expression (optional)
|
||||
else-if-3:
|
||||
type: string
|
||||
description: Fourth condition expression (optional)
|
||||
# Additional else-if-N conditions can be added as needed
|
||||
else:
|
||||
type: boolean
|
||||
description: Default fallback condition (optional)
|
||||
default: true
|
||||
connections:
|
||||
type: object
|
||||
required:
|
||||
- conditions
|
||||
properties:
|
||||
conditions:
|
||||
type: object
|
||||
description: Target blocks for each condition outcome
|
||||
properties:
|
||||
if:
|
||||
type: string
|
||||
description: Target block ID when 'if' condition is true
|
||||
else-if:
|
||||
type: string
|
||||
description: Target block ID when 'else-if' condition is true
|
||||
else-if-2:
|
||||
type: string
|
||||
description: Target block ID when 'else-if-2' condition is true
|
||||
else-if-3:
|
||||
type: string
|
||||
description: Target block ID when 'else-if-3' condition is true
|
||||
# Additional else-if-N connections can be added as needed
|
||||
else:
|
||||
type: string
|
||||
description: Target block ID when no conditions match
|
||||
```
|
||||
|
||||
## Connection Configuration
|
||||
|
||||
Unlike other blocks, conditions use branching connections based on condition outcomes:
|
||||
|
||||
```yaml
|
||||
connections:
|
||||
conditions:
|
||||
if: <string> # Target block ID when primary condition is true
|
||||
else-if: <string> # Target block ID when secondary condition is true (optional)
|
||||
else-if-2: <string> # Target block ID when third condition is true (optional)
|
||||
else-if-3: <string> # Target block ID when fourth condition is true (optional)
|
||||
# Additional else-if-N connections can be added as needed
|
||||
else: <string> # Target block ID when no conditions match (optional)
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### Simple If-Else
|
||||
|
||||
```yaml
|
||||
status-check:
|
||||
type: condition
|
||||
name: "Status Check"
|
||||
inputs:
|
||||
conditions:
|
||||
if: <start.status> === "approved"
|
||||
else: true
|
||||
connections:
|
||||
conditions:
|
||||
if: send-approval-email
|
||||
else: send-rejection-email
|
||||
```
|
||||
|
||||
### Multiple Conditions
|
||||
|
||||
```yaml
|
||||
user-routing:
|
||||
type: condition
|
||||
name: "User Type Router"
|
||||
inputs:
|
||||
conditions:
|
||||
if: <start.user_type> === "admin"
|
||||
else-if: <start.user_type> === "premium"
|
||||
else-if-2: <start.user_type> === "basic"
|
||||
else: true
|
||||
connections:
|
||||
conditions:
|
||||
if: admin-dashboard
|
||||
else-if: premium-features
|
||||
else-if-2: basic-features
|
||||
else: registration-flow
|
||||
```
|
||||
|
||||
### Numeric Comparisons
|
||||
|
||||
```yaml
|
||||
score-evaluation:
|
||||
type: condition
|
||||
name: "Score Evaluation"
|
||||
inputs:
|
||||
conditions:
|
||||
if: <agent.score> >= 90
|
||||
else-if: <agent.score> >= 70
|
||||
else-if-2: <agent.score> >= 50
|
||||
else: true
|
||||
connections:
|
||||
conditions:
|
||||
if: excellent-response
|
||||
else-if: good-response
|
||||
else-if-2: average-response
|
||||
else: poor-response
|
||||
```
|
||||
|
||||
### Complex Logic
|
||||
|
||||
```yaml
|
||||
eligibility-check:
|
||||
type: condition
|
||||
name: "Eligibility Check"
|
||||
inputs:
|
||||
conditions:
|
||||
if: <start.age> >= 18 && <start.verified> === true
|
||||
else-if: <start.age> >= 16 && <start.parent_consent> === true
|
||||
else: true
|
||||
connections:
|
||||
conditions:
|
||||
if: full-access
|
||||
else-if: limited-access
|
||||
else: access-denied
|
||||
```
|
||||
255
apps/docs/content/docs/yaml/blocks/evaluator.mdx
Normal file
255
apps/docs/content/docs/yaml/blocks/evaluator.mdx
Normal file
@@ -0,0 +1,255 @@
|
||||
---
|
||||
title: Evaluator Block YAML Schema
|
||||
description: YAML configuration reference for Evaluator blocks
|
||||
---
|
||||
|
||||
## Schema Definition
|
||||
|
||||
```yaml
|
||||
type: object
|
||||
required:
|
||||
- type
|
||||
- name
|
||||
- inputs
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum: [evaluator]
|
||||
description: Block type identifier
|
||||
name:
|
||||
type: string
|
||||
description: Display name for this evaluator block
|
||||
inputs:
|
||||
type: object
|
||||
required:
|
||||
- content
|
||||
- metrics
|
||||
- model
|
||||
- apiKey
|
||||
properties:
|
||||
content:
|
||||
type: string
|
||||
description: Content to evaluate (can reference other blocks)
|
||||
metrics:
|
||||
type: array
|
||||
description: Evaluation criteria and scoring ranges
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
description: Metric identifier
|
||||
description:
|
||||
type: string
|
||||
description: Detailed explanation of what the metric measures
|
||||
range:
|
||||
type: object
|
||||
properties:
|
||||
min:
|
||||
type: number
|
||||
description: Minimum score value
|
||||
max:
|
||||
type: number
|
||||
description: Maximum score value
|
||||
required: [min, max]
|
||||
description: Scoring range with numeric bounds
|
||||
model:
|
||||
type: string
|
||||
description: AI model identifier (e.g., gpt-4o, claude-3-5-sonnet-20241022)
|
||||
apiKey:
|
||||
type: string
|
||||
description: API key for the model provider (use {{ENV_VAR}} format)
|
||||
temperature:
|
||||
type: number
|
||||
minimum: 0
|
||||
maximum: 2
|
||||
description: Model temperature for evaluation
|
||||
default: 0.3
|
||||
azureEndpoint:
|
||||
type: string
|
||||
description: Azure OpenAI endpoint URL (required for Azure models)
|
||||
azureApiVersion:
|
||||
type: string
|
||||
description: Azure API version (required for Azure models)
|
||||
connections:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: string
|
||||
description: Target block ID for successful evaluation
|
||||
error:
|
||||
type: string
|
||||
description: Target block ID for error handling
|
||||
```
|
||||
|
||||
## Connection Configuration
|
||||
|
||||
Connections define where the workflow goes based on evaluation results:
|
||||
|
||||
```yaml
|
||||
connections:
|
||||
success: <string> # Target block ID for successful evaluation
|
||||
error: <string> # Target block ID for error handling (optional)
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### Content Quality Evaluation
|
||||
|
||||
```yaml
|
||||
content-evaluator:
|
||||
type: evaluator
|
||||
name: "Content Quality Evaluator"
|
||||
inputs:
|
||||
content: <content-generator.content>
|
||||
metrics:
|
||||
- name: "accuracy"
|
||||
description: "How factually accurate is the content?"
|
||||
range:
|
||||
min: 1
|
||||
max: 5
|
||||
- name: "clarity"
|
||||
description: "How clear and understandable is the content?"
|
||||
range:
|
||||
min: 1
|
||||
max: 5
|
||||
- name: "relevance"
|
||||
description: "How relevant is the content to the original query?"
|
||||
range:
|
||||
min: 1
|
||||
max: 5
|
||||
- name: "completeness"
|
||||
description: "How complete and comprehensive is the content?"
|
||||
range:
|
||||
min: 1
|
||||
max: 5
|
||||
model: gpt-4o
|
||||
temperature: 0.2
|
||||
apiKey: '{{OPENAI_API_KEY}}'
|
||||
connections:
|
||||
success: quality-report
|
||||
error: evaluation-error
|
||||
```
|
||||
|
||||
### Customer Response Evaluation
|
||||
|
||||
```yaml
|
||||
response-evaluator:
|
||||
type: evaluator
|
||||
name: "Customer Response Evaluator"
|
||||
inputs:
|
||||
content: <customer-agent.content>
|
||||
metrics:
|
||||
- name: "helpfulness"
|
||||
description: "How helpful is the response in addressing the customer's needs?"
|
||||
range:
|
||||
min: 1
|
||||
max: 10
|
||||
- name: "tone"
|
||||
description: "How appropriate and professional is the tone?"
|
||||
range:
|
||||
min: 1
|
||||
max: 10
|
||||
- name: "completeness"
|
||||
description: "Does the response fully address all aspects of the inquiry?"
|
||||
range:
|
||||
min: 1
|
||||
max: 10
|
||||
model: claude-3-5-sonnet-20241022
|
||||
apiKey: '{{ANTHROPIC_API_KEY}}'
|
||||
connections:
|
||||
success: response-processor
|
||||
```
|
||||
|
||||
### A/B Testing Evaluation
|
||||
|
||||
```yaml
|
||||
ab-test-evaluator:
|
||||
type: evaluator
|
||||
name: "A/B Test Evaluator"
|
||||
inputs:
|
||||
content: |
|
||||
Version A: <version-a.content>
|
||||
Version B: <version-b.content>
|
||||
|
||||
Compare these two versions for the following criteria.
|
||||
metrics:
|
||||
- name: "engagement"
|
||||
description: "Which version is more likely to engage users?"
|
||||
range: "A, B, or Tie"
|
||||
- name: "clarity"
|
||||
description: "Which version communicates more clearly?"
|
||||
range: "A, B, or Tie"
|
||||
- name: "persuasiveness"
|
||||
description: "Which version is more persuasive?"
|
||||
range: "A, B, or Tie"
|
||||
model: gpt-4o
|
||||
temperature: 0.1
|
||||
apiKey: '{{OPENAI_API_KEY}}'
|
||||
connections:
|
||||
success: test-results
|
||||
```
|
||||
|
||||
### Multi-Dimensional Content Scoring
|
||||
|
||||
```yaml
|
||||
comprehensive-evaluator:
|
||||
type: evaluator
|
||||
name: "Comprehensive Content Evaluator"
|
||||
inputs:
|
||||
content: <ai-writer.content>
|
||||
metrics:
|
||||
- name: "technical_accuracy"
|
||||
description: "How technically accurate and correct is the information?"
|
||||
range:
|
||||
min: 0
|
||||
max: 100
|
||||
- name: "readability"
|
||||
description: "How easy is the content to read and understand?"
|
||||
range:
|
||||
min: 0
|
||||
max: 100
|
||||
- name: "seo_optimization"
|
||||
description: "How well optimized is the content for search engines?"
|
||||
range:
|
||||
min: 0
|
||||
max: 100
|
||||
- name: "user_engagement"
|
||||
description: "How likely is this content to engage and retain readers?"
|
||||
range:
|
||||
min: 0
|
||||
max: 100
|
||||
- name: "brand_alignment"
|
||||
description: "How well does the content align with brand voice and values?"
|
||||
range:
|
||||
min: 0
|
||||
max: 100
|
||||
model: gpt-4o
|
||||
temperature: 0.3
|
||||
apiKey: '{{OPENAI_API_KEY}}'
|
||||
connections:
|
||||
success: content-optimization
|
||||
```
|
||||
|
||||
## Output References
|
||||
|
||||
After an evaluator block executes, you can reference its outputs:
|
||||
|
||||
```yaml
|
||||
# In subsequent blocks
|
||||
next-block:
|
||||
inputs:
|
||||
evaluation: <evaluator-name.content> # Evaluation summary
|
||||
scores: <evaluator-name.scores> # Individual metric scores
|
||||
overall: <evaluator-name.overall> # Overall assessment
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
- Define clear, specific evaluation criteria
|
||||
- Use appropriate scoring ranges for your use case
|
||||
- Choose models with strong reasoning capabilities
|
||||
- Use lower temperature for consistent scoring
|
||||
- Include detailed metric descriptions
|
||||
- Test with diverse content types
|
||||
- Consider multiple evaluators for complex assessments
|
||||
162
apps/docs/content/docs/yaml/blocks/function.mdx
Normal file
162
apps/docs/content/docs/yaml/blocks/function.mdx
Normal file
@@ -0,0 +1,162 @@
|
||||
---
|
||||
title: Function Block YAML Schema
|
||||
description: YAML configuration reference for Function blocks
|
||||
---
|
||||
|
||||
## Schema Definition
|
||||
|
||||
```yaml
|
||||
type: object
|
||||
required:
|
||||
- type
|
||||
- name
|
||||
- inputs
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum: [function]
|
||||
description: Block type identifier
|
||||
name:
|
||||
type: string
|
||||
description: Display name for this function block
|
||||
inputs:
|
||||
type: object
|
||||
required:
|
||||
- code
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
description: JavaScript/TypeScript code to execute (multiline string)
|
||||
timeout:
|
||||
type: number
|
||||
description: Maximum execution time in milliseconds
|
||||
default: 30000
|
||||
minimum: 1000
|
||||
maximum: 300000
|
||||
connections:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: string
|
||||
description: Target block ID for successful execution
|
||||
error:
|
||||
type: string
|
||||
description: Target block ID for error handling
|
||||
```
|
||||
|
||||
## Connection Configuration
|
||||
|
||||
Connections define where the workflow goes based on execution results:
|
||||
|
||||
```yaml
|
||||
connections:
|
||||
success: <string> # Target block ID for successful execution
|
||||
error: <string> # Target block ID for error handling (optional)
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### Simple Validation
|
||||
|
||||
```yaml
|
||||
input-validator:
|
||||
type: function
|
||||
name: "Input Validator"
|
||||
inputs:
|
||||
code: |-
|
||||
// Check if input number is greater than 5
|
||||
const inputValue = parseInt(<start.input>, 10);
|
||||
|
||||
if (inputValue > 5) {
|
||||
return {
|
||||
valid: true,
|
||||
value: inputValue,
|
||||
message: "Input is valid"
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
valid: false,
|
||||
value: inputValue,
|
||||
message: "Input must be greater than 5"
|
||||
};
|
||||
}
|
||||
connections:
|
||||
success: next-step
|
||||
error: handle-error
|
||||
```
|
||||
|
||||
### Data Processing
|
||||
|
||||
```yaml
|
||||
data-processor:
|
||||
type: function
|
||||
name: "Data Transformer"
|
||||
inputs:
|
||||
code: |
|
||||
// Transform the input data
|
||||
const rawData = <start.input>;
|
||||
|
||||
// Process and clean the data
|
||||
const processed = rawData
|
||||
.filter(item => item.status === 'active')
|
||||
.map(item => ({
|
||||
id: item.id,
|
||||
name: item.name.trim(),
|
||||
date: new Date(item.created).toISOString()
|
||||
}));
|
||||
|
||||
return processed;
|
||||
connections:
|
||||
success: api-save
|
||||
error: error-handler
|
||||
```
|
||||
|
||||
### API Integration
|
||||
|
||||
```yaml
|
||||
api-formatter:
|
||||
type: function
|
||||
name: "Format API Request"
|
||||
inputs:
|
||||
code: |
|
||||
// Prepare data for API submission
|
||||
const userData = <agent.response>;
|
||||
|
||||
const apiPayload = {
|
||||
timestamp: new Date().toISOString(),
|
||||
data: userData,
|
||||
source: "workflow-automation",
|
||||
version: "1.0"
|
||||
};
|
||||
|
||||
return apiPayload;
|
||||
connections:
|
||||
success: api-call
|
||||
```
|
||||
|
||||
### Calculations
|
||||
|
||||
```yaml
|
||||
calculator:
|
||||
type: function
|
||||
name: "Calculate Results"
|
||||
inputs:
|
||||
code: |
|
||||
// Perform calculations on input data
|
||||
const numbers = <start.input>;
|
||||
|
||||
const sum = numbers.reduce((a, b) => a + b, 0);
|
||||
const average = sum / numbers.length;
|
||||
const max = Math.max(...numbers);
|
||||
const min = Math.min(...numbers);
|
||||
|
||||
return {
|
||||
sum,
|
||||
average,
|
||||
max,
|
||||
min,
|
||||
count: numbers.length
|
||||
};
|
||||
connections:
|
||||
success: results-display
|
||||
```
|
||||
151
apps/docs/content/docs/yaml/blocks/index.mdx
Normal file
151
apps/docs/content/docs/yaml/blocks/index.mdx
Normal file
@@ -0,0 +1,151 @@
|
||||
---
|
||||
title: Block Schemas
|
||||
description: Complete YAML schema reference for all Sim Studio blocks
|
||||
---
|
||||
|
||||
import { Card, Cards } from "fumadocs-ui/components/card";
|
||||
|
||||
This section contains the complete YAML schema definitions for all available block types in Sim Studio. Each block type has specific configuration requirements and output formats.
|
||||
|
||||
## Core Blocks
|
||||
|
||||
These are the essential building blocks for creating workflows:
|
||||
|
||||
<Cards>
|
||||
<Card title="Starter Block" href="/yaml/blocks/starter">
|
||||
Workflow entry point supporting manual triggers, webhooks, and schedules
|
||||
</Card>
|
||||
<Card title="Agent Block" href="/yaml/blocks/agent">
|
||||
AI-powered processing with LLM integration and tool support
|
||||
</Card>
|
||||
<Card title="Function Block" href="/yaml/blocks/function">
|
||||
Custom JavaScript/TypeScript code execution environment
|
||||
</Card>
|
||||
<Card title="Response Block" href="/yaml/blocks/response">
|
||||
Format and return final workflow results
|
||||
</Card>
|
||||
</Cards>
|
||||
|
||||
## Logic & Control Flow
|
||||
|
||||
Blocks for implementing conditional logic and control flow:
|
||||
|
||||
<Cards>
|
||||
<Card title="Condition Block" href="/yaml/blocks/condition">
|
||||
Conditional branching based on boolean expressions
|
||||
</Card>
|
||||
<Card title="Router Block" href="/yaml/blocks/router">
|
||||
AI-powered intelligent routing to multiple paths
|
||||
</Card>
|
||||
<Card title="Loop Block" href="/yaml/blocks/loop">
|
||||
Iterative processing with for and forEach loops
|
||||
</Card>
|
||||
<Card title="Parallel Block" href="/yaml/blocks/parallel">
|
||||
Concurrent execution across multiple instances
|
||||
</Card>
|
||||
</Cards>
|
||||
|
||||
## Integration Blocks
|
||||
|
||||
Blocks for connecting to external services and systems:
|
||||
|
||||
<Cards>
|
||||
<Card title="API Block" href="/yaml/blocks/api">
|
||||
HTTP requests to external REST APIs
|
||||
</Card>
|
||||
<Card title="Webhook Block" href="/yaml/blocks/webhook">
|
||||
Webhook triggers for external integrations
|
||||
</Card>
|
||||
</Cards>
|
||||
|
||||
## Advanced Blocks
|
||||
|
||||
Specialized blocks for complex workflow patterns:
|
||||
|
||||
<Cards>
|
||||
<Card title="Evaluator Block" href="/yaml/blocks/evaluator">
|
||||
Validate outputs against defined criteria and metrics
|
||||
</Card>
|
||||
<Card title="Workflow Block" href="/yaml/blocks/workflow">
|
||||
Execute other workflows as reusable components
|
||||
</Card>
|
||||
</Cards>
|
||||
|
||||
## Common Schema Elements
|
||||
|
||||
All blocks share these common elements:
|
||||
|
||||
### Basic Structure
|
||||
|
||||
```yaml
|
||||
block-id:
|
||||
type: <block-type>
|
||||
name: <display-name>
|
||||
inputs:
|
||||
# Block-specific configuration
|
||||
connections:
|
||||
# Connection definitions
|
||||
```
|
||||
|
||||
### Connection Types
|
||||
|
||||
- **success**: Target block for successful execution
|
||||
- **error**: Target block for error handling (optional)
|
||||
- **conditions**: Multiple paths for conditional blocks
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Use double curly braces for environment variables:
|
||||
|
||||
```yaml
|
||||
inputs:
|
||||
apiKey: '{{API_KEY_NAME}}'
|
||||
endpoint: '{{SERVICE_ENDPOINT}}'
|
||||
```
|
||||
|
||||
### Block References
|
||||
|
||||
Reference other block outputs using the block name in lowercase:
|
||||
|
||||
```yaml
|
||||
inputs:
|
||||
userPrompt: <blockname.content>
|
||||
data: <functionblock.output>
|
||||
originalInput: <start.input>
|
||||
```
|
||||
|
||||
## Validation Rules
|
||||
|
||||
All YAML blocks are validated against their schemas:
|
||||
|
||||
1. **Required fields**: Must be present
|
||||
2. **Type validation**: Values must match expected types
|
||||
3. **Enum validation**: String values must be from allowed lists
|
||||
4. **Range validation**: Numbers must be within specified ranges
|
||||
5. **Pattern validation**: Strings must match regex patterns (where applicable)
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### Block Types and Properties
|
||||
|
||||
| Block Type | Primary Output | Common Use Cases |
|
||||
|------------|----------------|------------------|
|
||||
| starter | `.input` | Workflow entry point |
|
||||
| agent | `.content` | AI processing, text generation |
|
||||
| function | `.output` | Data transformation, calculations |
|
||||
| api | `.output` | External service integration |
|
||||
| condition | N/A (branching) | Conditional logic |
|
||||
| router | N/A (branching) | Intelligent routing |
|
||||
| response | N/A (terminal) | Final output formatting |
|
||||
| loop | `.results` | Iterative processing |
|
||||
| parallel | `.results` | Concurrent processing |
|
||||
| webhook | `.payload` | External triggers |
|
||||
| evaluator | `.score` | Output validation, quality assessment |
|
||||
| workflow | `.output` | Sub-workflow execution, modularity |
|
||||
|
||||
### Required vs Optional
|
||||
|
||||
- **Always required**: `type`, `name`
|
||||
- **Usually required**: `inputs`, `connections`
|
||||
- **Context dependent**: Specific input fields vary by block type
|
||||
- **Always optional**: `error` connections, UI-specific fields
|
||||
305
apps/docs/content/docs/yaml/blocks/loop.mdx
Normal file
305
apps/docs/content/docs/yaml/blocks/loop.mdx
Normal file
@@ -0,0 +1,305 @@
|
||||
---
|
||||
title: Loop Block YAML Schema
|
||||
description: YAML configuration reference for Loop blocks
|
||||
---
|
||||
|
||||
## Schema Definition
|
||||
|
||||
```yaml
|
||||
type: object
|
||||
required:
|
||||
- type
|
||||
- name
|
||||
- inputs
|
||||
- connections
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum: [loop]
|
||||
description: Block type identifier
|
||||
name:
|
||||
type: string
|
||||
description: Display name for this loop block
|
||||
inputs:
|
||||
type: object
|
||||
required:
|
||||
- loopType
|
||||
properties:
|
||||
loopType:
|
||||
type: string
|
||||
enum: [for, forEach]
|
||||
description: Type of loop to execute
|
||||
iterations:
|
||||
type: number
|
||||
description: Number of iterations (for 'for' loops)
|
||||
minimum: 1
|
||||
maximum: 1000
|
||||
collection:
|
||||
type: string
|
||||
description: Collection to iterate over (for 'forEach' loops)
|
||||
maxConcurrency:
|
||||
type: number
|
||||
description: Maximum concurrent executions
|
||||
default: 1
|
||||
minimum: 1
|
||||
maximum: 10
|
||||
connections:
|
||||
type: object
|
||||
required:
|
||||
- loop
|
||||
properties:
|
||||
loop:
|
||||
type: object
|
||||
required:
|
||||
- start
|
||||
properties:
|
||||
start:
|
||||
type: string
|
||||
description: Target block ID to execute inside the loop
|
||||
end:
|
||||
type: string
|
||||
description: Target block ID for loop completion (optional)
|
||||
success:
|
||||
type: string
|
||||
description: Target block ID after loop completion (alternative format)
|
||||
error:
|
||||
type: string
|
||||
description: Target block ID for error handling
|
||||
```
|
||||
|
||||
## Connection Configuration
|
||||
|
||||
Loop blocks use a special connection format with a `loop` section:
|
||||
|
||||
```yaml
|
||||
connections:
|
||||
loop:
|
||||
start: <string> # Target block ID to execute inside the loop
|
||||
end: <string> # Target block ID after loop completion (optional)
|
||||
error: <string> # Target block ID for error handling (optional)
|
||||
```
|
||||
|
||||
Alternative format (legacy):
|
||||
```yaml
|
||||
connections:
|
||||
success: <string> # Target block ID after loop completion
|
||||
error: <string> # Target block ID for error handling (optional)
|
||||
```
|
||||
|
||||
## Child Block Configuration
|
||||
|
||||
Blocks inside a loop must have their `parentId` set to the loop block ID:
|
||||
|
||||
```yaml
|
||||
loop-1:
|
||||
type: loop
|
||||
name: "Process Items"
|
||||
inputs:
|
||||
loopType: forEach
|
||||
collection: <start.items>
|
||||
connections:
|
||||
loop:
|
||||
start: process-item
|
||||
end: final-results
|
||||
|
||||
# Child block inside the loop
|
||||
process-item:
|
||||
type: agent
|
||||
name: "Process Item"
|
||||
parentId: loop-1 # References the loop block
|
||||
inputs:
|
||||
systemPrompt: "Process this item"
|
||||
userPrompt: <loop.currentItem>
|
||||
model: gpt-4o
|
||||
apiKey: '{{OPENAI_API_KEY}}'
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### For Loop (Fixed Iterations)
|
||||
|
||||
```yaml
|
||||
countdown-loop:
|
||||
type: loop
|
||||
name: "Countdown Loop"
|
||||
inputs:
|
||||
loopType: for
|
||||
iterations: 5
|
||||
connections:
|
||||
loop:
|
||||
start: countdown-agent
|
||||
end: countdown-complete
|
||||
|
||||
countdown-agent:
|
||||
type: agent
|
||||
name: "Countdown Agent"
|
||||
parentId: countdown-loop
|
||||
inputs:
|
||||
systemPrompt: "Generate a countdown message"
|
||||
userPrompt: "Count down from 5. Current number: <loop.index>"
|
||||
model: gpt-4o
|
||||
apiKey: '{{OPENAI_API_KEY}}'
|
||||
```
|
||||
|
||||
### ForEach Loop (Collection Processing)
|
||||
|
||||
```yaml
|
||||
email-processor-loop:
|
||||
type: loop
|
||||
name: "Email Processor Loop"
|
||||
inputs:
|
||||
loopType: forEach
|
||||
collection: <start.emails>
|
||||
connections:
|
||||
loop:
|
||||
start: process-single-email
|
||||
end: all-emails-processed
|
||||
|
||||
process-single-email:
|
||||
type: agent
|
||||
name: "Process Single Email"
|
||||
parentId: email-processor-loop
|
||||
inputs:
|
||||
systemPrompt: "Classify and respond to this email"
|
||||
userPrompt: "Email content: <loop.currentItem>"
|
||||
model: gpt-4o
|
||||
apiKey: '{{OPENAI_API_KEY}}'
|
||||
```
|
||||
|
||||
### Complex Loop with Multiple Child Blocks
|
||||
|
||||
```yaml
|
||||
data-analysis-loop:
|
||||
type: loop
|
||||
name: "Data Analysis Loop"
|
||||
inputs:
|
||||
loopType: forEach
|
||||
collection: <data-fetcher.records>
|
||||
maxConcurrency: 3
|
||||
connections:
|
||||
loop:
|
||||
start: validate-record
|
||||
end: generate-report
|
||||
error: handle-loop-error
|
||||
|
||||
validate-record:
|
||||
type: function
|
||||
name: "Validate Record"
|
||||
parentId: data-analysis-loop
|
||||
inputs:
|
||||
code: |
|
||||
const record = <loop.currentItem>;
|
||||
const index = <loop.index>;
|
||||
|
||||
// Validate the record
|
||||
if (!record.id || !record.data) {
|
||||
throw new Error(`Invalid record at index ${index}`);
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
recordId: record.id,
|
||||
processedAt: new Date().toISOString()
|
||||
};
|
||||
connections:
|
||||
success: analyze-record
|
||||
error: record-error
|
||||
|
||||
analyze-record:
|
||||
type: agent
|
||||
name: "Analyze Record"
|
||||
parentId: data-analysis-loop
|
||||
inputs:
|
||||
systemPrompt: "Analyze this data record and extract insights"
|
||||
userPrompt: |
|
||||
Record ID: <validaterecord.recordId>
|
||||
Data: <loop.currentItem.data>
|
||||
Position in collection: <loop.index>
|
||||
model: gpt-4o
|
||||
apiKey: '{{OPENAI_API_KEY}}'
|
||||
connections:
|
||||
success: store-analysis
|
||||
|
||||
store-analysis:
|
||||
type: function
|
||||
name: "Store Analysis"
|
||||
parentId: data-analysis-loop
|
||||
inputs:
|
||||
code: |
|
||||
const analysis = <analyzerecord.content>;
|
||||
const recordId = <validaterecord.recordId>;
|
||||
|
||||
// Store analysis result
|
||||
return {
|
||||
recordId,
|
||||
analysis,
|
||||
completedAt: new Date().toISOString()
|
||||
};
|
||||
```
|
||||
|
||||
### Concurrent Processing Loop
|
||||
|
||||
```yaml
|
||||
parallel-processing-loop:
|
||||
type: loop
|
||||
name: "Parallel Processing Loop"
|
||||
inputs:
|
||||
loopType: forEach
|
||||
collection: <start.tasks>
|
||||
maxConcurrency: 5
|
||||
connections:
|
||||
loop:
|
||||
start: process-task
|
||||
end: aggregate-results
|
||||
|
||||
process-task:
|
||||
type: api
|
||||
name: "Process Task"
|
||||
parentId: parallel-processing-loop
|
||||
inputs:
|
||||
url: "https://api.example.com/process"
|
||||
method: POST
|
||||
headers:
|
||||
- key: "Authorization"
|
||||
value: "Bearer {{API_TOKEN}}"
|
||||
body: |
|
||||
{
|
||||
"taskId": "<loop.currentItem.id>",
|
||||
"data": "<loop.currentItem.data>"
|
||||
}
|
||||
connections:
|
||||
success: task-completed
|
||||
```
|
||||
|
||||
## Loop Variables
|
||||
|
||||
Inside loop child blocks, these special variables are available:
|
||||
|
||||
```yaml
|
||||
# Available in all child blocks of the loop
|
||||
<loop.index> # Current iteration number (0-based)
|
||||
<loop.currentItem> # Current item being processed (forEach loops)
|
||||
<loop.items> # Full collection (forEach loops)
|
||||
```
|
||||
|
||||
## Output References
|
||||
|
||||
After a loop completes, you can reference its aggregated results:
|
||||
|
||||
```yaml
|
||||
# In blocks after the loop
|
||||
final-processor:
|
||||
inputs:
|
||||
all-results: <loop-name.results> # Array of all iteration results
|
||||
total-count: <loop-name.count> # Number of iterations completed
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
- Set reasonable iteration limits to avoid long execution times
|
||||
- Use forEach for collection processing, for loops for fixed iterations
|
||||
- Consider using maxConcurrency for I/O bound operations
|
||||
- Include error handling for robust loop execution
|
||||
- Use descriptive names for loop child blocks
|
||||
- Test with small collections first
|
||||
- Monitor execution time for large collections
|
||||
17
apps/docs/content/docs/yaml/blocks/meta.json
Normal file
17
apps/docs/content/docs/yaml/blocks/meta.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"title": "Block Schemas",
|
||||
"pages": [
|
||||
"starter",
|
||||
"agent",
|
||||
"function",
|
||||
"api",
|
||||
"condition",
|
||||
"router",
|
||||
"evaluator",
|
||||
"response",
|
||||
"loop",
|
||||
"parallel",
|
||||
"webhook",
|
||||
"workflow"
|
||||
]
|
||||
}
|
||||
322
apps/docs/content/docs/yaml/blocks/parallel.mdx
Normal file
322
apps/docs/content/docs/yaml/blocks/parallel.mdx
Normal file
@@ -0,0 +1,322 @@
|
||||
---
|
||||
title: Parallel Block YAML Schema
|
||||
description: YAML configuration reference for Parallel blocks
|
||||
---
|
||||
|
||||
## Schema Definition
|
||||
|
||||
```yaml
|
||||
type: object
|
||||
required:
|
||||
- type
|
||||
- name
|
||||
- inputs
|
||||
- connections
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum: [parallel]
|
||||
description: Block type identifier
|
||||
name:
|
||||
type: string
|
||||
description: Display name for this parallel block
|
||||
inputs:
|
||||
type: object
|
||||
required:
|
||||
- parallelType
|
||||
properties:
|
||||
parallelType:
|
||||
type: string
|
||||
enum: [count, collection]
|
||||
description: Type of parallel execution
|
||||
count:
|
||||
type: number
|
||||
description: Number of parallel instances (for 'count' type)
|
||||
minimum: 1
|
||||
maximum: 100
|
||||
collection:
|
||||
type: string
|
||||
description: Collection to distribute across instances (for 'collection' type)
|
||||
maxConcurrency:
|
||||
type: number
|
||||
description: Maximum concurrent executions
|
||||
default: 10
|
||||
minimum: 1
|
||||
maximum: 50
|
||||
connections:
|
||||
type: object
|
||||
required:
|
||||
- parallel
|
||||
properties:
|
||||
parallel:
|
||||
type: object
|
||||
required:
|
||||
- start
|
||||
properties:
|
||||
start:
|
||||
type: string
|
||||
description: Target block ID to execute inside each parallel instance
|
||||
end:
|
||||
type: string
|
||||
description: Target block ID after all parallel instances complete (optional)
|
||||
success:
|
||||
type: string
|
||||
description: Target block ID after all instances complete (alternative format)
|
||||
error:
|
||||
type: string
|
||||
description: Target block ID for error handling
|
||||
```
|
||||
|
||||
## Connection Configuration
|
||||
|
||||
Parallel blocks use a special connection format with a `parallel` section:
|
||||
|
||||
```yaml
|
||||
connections:
|
||||
parallel:
|
||||
start: <string> # Target block ID to execute inside each parallel instance
|
||||
end: <string> # Target block ID after all instances complete (optional)
|
||||
error: <string> # Target block ID for error handling (optional)
|
||||
```
|
||||
|
||||
Alternative format (legacy):
|
||||
```yaml
|
||||
connections:
|
||||
success: <string> # Target block ID after all instances complete
|
||||
error: <string> # Target block ID for error handling (optional)
|
||||
```
|
||||
|
||||
## Child Block Configuration
|
||||
|
||||
Blocks inside a parallel block must have their `parentId` set to the parallel block ID:
|
||||
|
||||
```yaml
|
||||
parallel-1:
|
||||
type: parallel
|
||||
name: "Process Items"
|
||||
inputs:
|
||||
parallelType: collection
|
||||
collection: <start.items>
|
||||
connections:
|
||||
parallel:
|
||||
start: process-item
|
||||
end: aggregate-results
|
||||
|
||||
# Child block inside the parallel
|
||||
process-item:
|
||||
type: agent
|
||||
name: "Process Item"
|
||||
parentId: parallel-1 # References the parallel block
|
||||
inputs:
|
||||
systemPrompt: "Process this item"
|
||||
userPrompt: <parallel.currentItem>
|
||||
model: gpt-4o
|
||||
apiKey: '{{OPENAI_API_KEY}}'
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### Count-Based Parallel Processing
|
||||
|
||||
```yaml
|
||||
worker-parallel:
|
||||
type: parallel
|
||||
name: "Worker Parallel"
|
||||
inputs:
|
||||
parallelType: count
|
||||
count: 5
|
||||
maxConcurrency: 3
|
||||
connections:
|
||||
parallel:
|
||||
start: worker-task
|
||||
end: collect-worker-results
|
||||
|
||||
worker-task:
|
||||
type: api
|
||||
name: "Worker Task"
|
||||
parentId: worker-parallel
|
||||
inputs:
|
||||
url: "https://api.worker.com/process"
|
||||
method: POST
|
||||
headers:
|
||||
- key: "Authorization"
|
||||
value: "Bearer {{WORKER_API_KEY}}"
|
||||
body: |
|
||||
{
|
||||
"instanceId": <parallel.index>,
|
||||
"timestamp": "{{new Date().toISOString()}}"
|
||||
}
|
||||
connections:
|
||||
success: worker-complete
|
||||
```
|
||||
|
||||
### Collection-Based Parallel Processing
|
||||
|
||||
```yaml
|
||||
api-parallel:
|
||||
type: parallel
|
||||
name: "API Parallel"
|
||||
inputs:
|
||||
parallelType: collection
|
||||
collection: <start.apiEndpoints>
|
||||
maxConcurrency: 10
|
||||
connections:
|
||||
parallel:
|
||||
start: call-api
|
||||
end: merge-api-results
|
||||
|
||||
call-api:
|
||||
type: api
|
||||
name: "Call API"
|
||||
parentId: api-parallel
|
||||
inputs:
|
||||
url: <parallel.currentItem.endpoint>
|
||||
method: <parallel.currentItem.method>
|
||||
headers:
|
||||
- key: "Authorization"
|
||||
value: "Bearer {{API_TOKEN}}"
|
||||
connections:
|
||||
success: api-complete
|
||||
```
|
||||
|
||||
### Complex Parallel Processing Pipeline
|
||||
|
||||
```yaml
|
||||
data-processing-parallel:
|
||||
type: parallel
|
||||
name: "Data Processing Parallel"
|
||||
inputs:
|
||||
parallelType: collection
|
||||
collection: <data-loader.records>
|
||||
maxConcurrency: 8
|
||||
connections:
|
||||
parallel:
|
||||
start: validate-data
|
||||
end: final-aggregation
|
||||
error: parallel-error-handler
|
||||
|
||||
validate-data:
|
||||
type: function
|
||||
name: "Validate Data"
|
||||
parentId: data-processing-parallel
|
||||
inputs:
|
||||
code: |
|
||||
const record = <parallel.currentItem>;
|
||||
const index = <parallel.index>;
|
||||
|
||||
// Validate record structure
|
||||
if (!record.id || !record.content) {
|
||||
throw new Error(`Invalid record at index ${index}`);
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
recordId: record.id,
|
||||
validatedAt: new Date().toISOString()
|
||||
};
|
||||
connections:
|
||||
success: process-data
|
||||
error: validation-error
|
||||
|
||||
process-data:
|
||||
type: agent
|
||||
name: "Process Data"
|
||||
parentId: data-processing-parallel
|
||||
inputs:
|
||||
systemPrompt: "Process and analyze this data record"
|
||||
userPrompt: |
|
||||
Record ID: <validatedata.recordId>
|
||||
Content: <parallel.currentItem.content>
|
||||
Instance: <parallel.index>
|
||||
model: gpt-4o
|
||||
temperature: 0.3
|
||||
apiKey: '{{OPENAI_API_KEY}}'
|
||||
connections:
|
||||
success: store-result
|
||||
|
||||
store-result:
|
||||
type: function
|
||||
name: "Store Result"
|
||||
parentId: data-processing-parallel
|
||||
inputs:
|
||||
code: |
|
||||
const processed = <processdata.content>;
|
||||
const recordId = <validatedata.recordId>;
|
||||
|
||||
return {
|
||||
recordId,
|
||||
processed,
|
||||
completedAt: new Date().toISOString(),
|
||||
instanceIndex: <parallel.index>
|
||||
};
|
||||
```
|
||||
|
||||
### Concurrent AI Analysis
|
||||
|
||||
```yaml
|
||||
multi-model-parallel:
|
||||
type: parallel
|
||||
name: "Multi-Model Analysis"
|
||||
inputs:
|
||||
parallelType: collection
|
||||
collection: |
|
||||
[
|
||||
{"model": "gpt-4o", "focus": "technical accuracy"},
|
||||
{"model": "claude-3-5-sonnet-20241022", "focus": "creative quality"},
|
||||
{"model": "gemini-2.0-flash-exp", "focus": "factual verification"}
|
||||
]
|
||||
maxConcurrency: 3
|
||||
connections:
|
||||
parallel:
|
||||
start: analyze-content
|
||||
end: combine-analyses
|
||||
|
||||
analyze-content:
|
||||
type: agent
|
||||
name: "Analyze Content"
|
||||
parentId: multi-model-parallel
|
||||
inputs:
|
||||
systemPrompt: |
|
||||
You are analyzing content with a focus on <parallel.currentItem.focus>.
|
||||
Provide detailed analysis from this perspective.
|
||||
userPrompt: |
|
||||
Content to analyze: <start.content>
|
||||
Analysis focus: <parallel.currentItem.focus>
|
||||
model: <parallel.currentItem.model>
|
||||
apiKey: '{{OPENAI_API_KEY}}'
|
||||
connections:
|
||||
success: analysis-complete
|
||||
```
|
||||
|
||||
## Parallel Variables
|
||||
|
||||
Inside parallel child blocks, these special variables are available:
|
||||
|
||||
```yaml
|
||||
# Available in all child blocks of the parallel
|
||||
<parallel.index> # Instance number (0-based)
|
||||
<parallel.currentItem> # Item for this instance (collection type)
|
||||
<parallel.items> # Full collection (collection type)
|
||||
```
|
||||
|
||||
## Output References
|
||||
|
||||
After a parallel block completes, you can reference its aggregated results:
|
||||
|
||||
```yaml
|
||||
# In blocks after the parallel
|
||||
final-processor:
|
||||
inputs:
|
||||
all-results: <parallel-name.results> # Array of all instance results
|
||||
total-count: <parallel-name.count> # Number of instances completed
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
- Use appropriate maxConcurrency to avoid overwhelming APIs
|
||||
- Ensure operations are independent and don't rely on each other
|
||||
- Include error handling for robust parallel execution
|
||||
- Test with small collections first
|
||||
- Monitor rate limits for external APIs
|
||||
- Use collection type for distributing work, count type for fixed instances
|
||||
- Consider memory usage with large collections
|
||||
140
apps/docs/content/docs/yaml/blocks/response.mdx
Normal file
140
apps/docs/content/docs/yaml/blocks/response.mdx
Normal file
@@ -0,0 +1,140 @@
|
||||
---
|
||||
title: Response Block YAML Schema
|
||||
description: YAML configuration reference for Response blocks
|
||||
---
|
||||
|
||||
## Schema Definition
|
||||
|
||||
```yaml
|
||||
type: object
|
||||
required:
|
||||
- type
|
||||
- name
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum: [response]
|
||||
description: Block type identifier
|
||||
name:
|
||||
type: string
|
||||
description: Display name for this response block
|
||||
inputs:
|
||||
type: object
|
||||
properties:
|
||||
dataMode:
|
||||
type: string
|
||||
enum: [structured, json]
|
||||
description: Mode for defining response data structure
|
||||
default: structured
|
||||
builderData:
|
||||
type: object
|
||||
description: Structured response data (when dataMode is 'structured')
|
||||
data:
|
||||
type: object
|
||||
description: JSON response data (when dataMode is 'json')
|
||||
status:
|
||||
type: number
|
||||
description: HTTP status code
|
||||
default: 200
|
||||
minimum: 100
|
||||
maximum: 599
|
||||
headers:
|
||||
type: array
|
||||
description: Response headers as key-value pairs
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
key:
|
||||
type: string
|
||||
description: Header name
|
||||
value:
|
||||
type: string
|
||||
description: Header value
|
||||
```
|
||||
|
||||
## Connection Configuration
|
||||
|
||||
Response blocks are terminal blocks (no outgoing connections) and define the final output:
|
||||
|
||||
```yaml
|
||||
# No connections object needed - Response blocks are always terminal
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### Simple Response
|
||||
|
||||
```yaml
|
||||
simple-response:
|
||||
type: response
|
||||
name: "Simple Response"
|
||||
inputs:
|
||||
data:
|
||||
message: "Hello World"
|
||||
timestamp: <function.timestamp>
|
||||
status: 200
|
||||
```
|
||||
|
||||
### Success Response
|
||||
|
||||
```yaml
|
||||
success-response:
|
||||
type: response
|
||||
name: "Success Response"
|
||||
inputs:
|
||||
data:
|
||||
success: true
|
||||
user:
|
||||
id: <agent.user_id>
|
||||
name: <agent.user_name>
|
||||
email: <agent.user_email>
|
||||
created_at: <function.timestamp>
|
||||
status: 201
|
||||
headers:
|
||||
- key: "Location"
|
||||
value: "/api/users/<agent.user_id>"
|
||||
- key: "X-Created-By"
|
||||
value: "workflow-engine"
|
||||
```
|
||||
|
||||
### Error Response
|
||||
|
||||
```yaml
|
||||
error-response:
|
||||
type: response
|
||||
name: "Error Response"
|
||||
inputs:
|
||||
data:
|
||||
error: true
|
||||
message: <agent.error_message>
|
||||
code: "VALIDATION_FAILED"
|
||||
details: <function.validation_errors>
|
||||
status: 400
|
||||
headers:
|
||||
- key: "X-Error-Code"
|
||||
value: "VALIDATION_FAILED"
|
||||
```
|
||||
|
||||
### Paginated Response
|
||||
|
||||
```yaml
|
||||
paginated-response:
|
||||
type: response
|
||||
name: "Paginated Response"
|
||||
inputs:
|
||||
data:
|
||||
data: <agent.results>
|
||||
pagination:
|
||||
page: <start.page>
|
||||
per_page: <start.per_page>
|
||||
total: <function.total_count>
|
||||
total_pages: <function.total_pages>
|
||||
status: 200
|
||||
headers:
|
||||
- key: "X-Total-Count"
|
||||
value: <function.total_count>
|
||||
- key: "Cache-Control"
|
||||
value: "public, max-age=300"
|
||||
- key: "Content-Type"
|
||||
value: "application/json"
|
||||
```
|
||||
200
apps/docs/content/docs/yaml/blocks/router.mdx
Normal file
200
apps/docs/content/docs/yaml/blocks/router.mdx
Normal file
@@ -0,0 +1,200 @@
|
||||
---
|
||||
title: Router Block YAML Schema
|
||||
description: YAML configuration reference for Router blocks
|
||||
---
|
||||
|
||||
## Schema Definition
|
||||
|
||||
```yaml
|
||||
type: object
|
||||
required:
|
||||
- type
|
||||
- name
|
||||
- inputs
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum: [router]
|
||||
description: Block type identifier
|
||||
name:
|
||||
type: string
|
||||
description: Display name for this router block
|
||||
inputs:
|
||||
type: object
|
||||
required:
|
||||
- prompt
|
||||
- model
|
||||
- apiKey
|
||||
properties:
|
||||
prompt:
|
||||
type: string
|
||||
description: Instructions for routing decisions and criteria
|
||||
model:
|
||||
type: string
|
||||
description: AI model identifier (e.g., gpt-4o, gemini-2.5-pro, deepseek-chat)
|
||||
apiKey:
|
||||
type: string
|
||||
description: API key for the model provider (use {{ENV_VAR}} format)
|
||||
temperature:
|
||||
type: number
|
||||
minimum: 0
|
||||
maximum: 2
|
||||
description: Model temperature for routing decisions
|
||||
default: 0.3
|
||||
azureEndpoint:
|
||||
type: string
|
||||
description: Azure OpenAI endpoint URL (required for Azure models)
|
||||
azureApiVersion:
|
||||
type: string
|
||||
description: Azure API version (required for Azure models)
|
||||
connections:
|
||||
type: object
|
||||
description: Multiple connection paths for different routing outcomes
|
||||
properties:
|
||||
success:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: Array of target block IDs for routing destinations
|
||||
```
|
||||
|
||||
## Connection Configuration
|
||||
|
||||
Router blocks use a success array containing all possible routing destinations:
|
||||
|
||||
```yaml
|
||||
connections:
|
||||
success:
|
||||
- <string> # Target block ID option 1
|
||||
- <string> # Target block ID option 2
|
||||
- <string> # Target block ID option 3
|
||||
# Additional target block IDs as needed
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### Content Type Router
|
||||
|
||||
```yaml
|
||||
content-router:
|
||||
type: router
|
||||
name: "Content Type Router"
|
||||
inputs:
|
||||
prompt: |
|
||||
Route this content based on its type:
|
||||
- If it's a question, route to question-handler
|
||||
- If it's a complaint, route to complaint-handler
|
||||
- If it's feedback, route to feedback-handler
|
||||
- If it's a request, route to request-handler
|
||||
|
||||
Content: <start.input>
|
||||
model: gpt-4o
|
||||
apiKey: '{{OPENAI_API_KEY}}'
|
||||
connections:
|
||||
success:
|
||||
- question-handler
|
||||
- complaint-handler
|
||||
- feedback-handler
|
||||
- request-handler
|
||||
```
|
||||
|
||||
### Priority Router
|
||||
|
||||
```yaml
|
||||
priority-router:
|
||||
type: router
|
||||
name: "Priority Router"
|
||||
inputs:
|
||||
prompt: |
|
||||
Analyze the urgency and route accordingly:
|
||||
- urgent-queue: High priority, needs immediate attention
|
||||
- standard-queue: Normal priority, standard processing
|
||||
- low-queue: Low priority, can be delayed
|
||||
|
||||
Email content: <email-analyzer.content>
|
||||
|
||||
Route based on urgency indicators, deadlines, and tone.
|
||||
model: gpt-4o
|
||||
temperature: 0.2
|
||||
apiKey: '{{OPENAI_API_KEY}}'
|
||||
connections:
|
||||
success:
|
||||
- urgent-queue
|
||||
- standard-queue
|
||||
- low-queue
|
||||
```
|
||||
|
||||
### Department Router
|
||||
|
||||
```yaml
|
||||
department-router:
|
||||
type: router
|
||||
name: "Department Router"
|
||||
inputs:
|
||||
prompt: |
|
||||
Route this customer inquiry to the appropriate department:
|
||||
|
||||
- sales-team: Sales questions, pricing, demos
|
||||
- support-team: Technical issues, bug reports, how-to questions
|
||||
- billing-team: Payment issues, subscription changes, invoices
|
||||
- general-team: General inquiries, feedback, other topics
|
||||
|
||||
Customer message: <start.input>
|
||||
Customer type: <customer-analyzer.type>
|
||||
model: claude-3-5-sonnet-20241022
|
||||
apiKey: '{{ANTHROPIC_API_KEY}}'
|
||||
connections:
|
||||
success:
|
||||
- sales-team
|
||||
- support-team
|
||||
- billing-team
|
||||
- general-team
|
||||
```
|
||||
|
||||
## Advanced Configuration
|
||||
|
||||
### Multiple Models Router
|
||||
|
||||
```yaml
|
||||
model-selector-router:
|
||||
type: router
|
||||
name: "Model Selection Router"
|
||||
inputs:
|
||||
prompt: |
|
||||
Based on the task complexity, route to the appropriate model:
|
||||
- simple-gpt35: Simple questions, basic tasks
|
||||
- advanced-gpt4: Complex analysis, detailed reasoning
|
||||
- specialized-claude: Creative writing, nuanced analysis
|
||||
|
||||
Task: <start.task>
|
||||
Complexity indicators: <analyzer.complexity>
|
||||
model: gpt-4o-mini
|
||||
temperature: 0.1
|
||||
apiKey: '{{OPENAI_API_KEY}}'
|
||||
connections:
|
||||
success:
|
||||
- simple-gpt35
|
||||
- advanced-gpt4
|
||||
- specialized-claude
|
||||
```
|
||||
|
||||
## Output References
|
||||
|
||||
Router blocks don't produce direct outputs but control workflow path:
|
||||
|
||||
```yaml
|
||||
# Router decisions affect which subsequent blocks execute
|
||||
# Access the routed block's outputs normally:
|
||||
final-step:
|
||||
inputs:
|
||||
routed-result: <routed-block-name.content>
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
- Provide clear routing criteria in the prompt
|
||||
- Use specific, descriptive target block names
|
||||
- Include examples of content for each routing path
|
||||
- Use lower temperature values for consistent routing
|
||||
- Test with diverse input types to ensure accurate routing
|
||||
- Consider fallback paths for edge cases
|
||||
183
apps/docs/content/docs/yaml/blocks/starter.mdx
Normal file
183
apps/docs/content/docs/yaml/blocks/starter.mdx
Normal file
@@ -0,0 +1,183 @@
|
||||
---
|
||||
title: Starter Block YAML Schema
|
||||
description: YAML configuration reference for Starter blocks
|
||||
---
|
||||
|
||||
## Schema Definition
|
||||
|
||||
```yaml
|
||||
type: object
|
||||
required:
|
||||
- type
|
||||
- name
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum: [starter]
|
||||
description: Block type identifier
|
||||
name:
|
||||
type: string
|
||||
description: Display name for this starter block
|
||||
inputs:
|
||||
type: object
|
||||
properties:
|
||||
startWorkflow:
|
||||
type: string
|
||||
enum: [manual, webhook, schedule]
|
||||
description: How the workflow should be triggered
|
||||
default: manual
|
||||
inputFormat:
|
||||
type: array
|
||||
description: Expected input structure for API calls (manual workflows)
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
description: Field name
|
||||
type:
|
||||
type: string
|
||||
enum: [string, number, boolean, object, array]
|
||||
description: Field type
|
||||
scheduleType:
|
||||
type: string
|
||||
enum: [hourly, daily, weekly, monthly]
|
||||
description: Schedule frequency (schedule workflows only)
|
||||
hourlyMinute:
|
||||
type: number
|
||||
minimum: 0
|
||||
maximum: 59
|
||||
description: Minute of the hour to run (hourly schedules)
|
||||
dailyTime:
|
||||
type: string
|
||||
pattern: "^([01]?[0-9]|2[0-3]):[0-5][0-9]$"
|
||||
description: Time of day to run in HH:MM format (daily schedules)
|
||||
weeklyDay:
|
||||
type: string
|
||||
enum: [MON, TUE, WED, THU, FRI, SAT, SUN]
|
||||
description: Day of week to run (weekly schedules)
|
||||
weeklyTime:
|
||||
type: string
|
||||
pattern: "^([01]?[0-9]|2[0-3]):[0-5][0-9]$"
|
||||
description: Time of day to run in HH:MM format (weekly schedules)
|
||||
monthlyDay:
|
||||
type: number
|
||||
minimum: 1
|
||||
maximum: 28
|
||||
description: Day of month to run (monthly schedules)
|
||||
monthlyTime:
|
||||
type: string
|
||||
pattern: "^([01]?[0-9]|2[0-3]):[0-5][0-9]$"
|
||||
description: Time of day to run in HH:MM format (monthly schedules)
|
||||
timezone:
|
||||
type: string
|
||||
description: Timezone for scheduled workflows
|
||||
default: UTC
|
||||
webhookProvider:
|
||||
type: string
|
||||
enum: [slack, gmail, airtable, telegram, generic, whatsapp, github, discord, stripe]
|
||||
description: Provider for webhook integration (webhook workflows only)
|
||||
webhookConfig:
|
||||
type: object
|
||||
description: Provider-specific webhook configuration
|
||||
connections:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: string
|
||||
description: Target block ID to execute when workflow starts
|
||||
```
|
||||
|
||||
## Connection Configuration
|
||||
|
||||
The starter block only has a success connection since it's the entry point:
|
||||
|
||||
```yaml
|
||||
connections:
|
||||
success: <string> # Target block ID to execute when workflow starts
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### Manual Start
|
||||
|
||||
```yaml
|
||||
start:
|
||||
type: starter
|
||||
name: Start
|
||||
inputs:
|
||||
startWorkflow: manual
|
||||
connections:
|
||||
success: next-block
|
||||
```
|
||||
|
||||
### Manual Start with Input Format
|
||||
|
||||
```yaml
|
||||
start:
|
||||
type: starter
|
||||
name: Start
|
||||
inputs:
|
||||
startWorkflow: manual
|
||||
inputFormat:
|
||||
- name: query
|
||||
type: string
|
||||
- name: email
|
||||
type: string
|
||||
- name: age
|
||||
type: number
|
||||
- name: isActive
|
||||
type: boolean
|
||||
- name: preferences
|
||||
type: object
|
||||
- name: tags
|
||||
type: array
|
||||
connections:
|
||||
success: agent-1
|
||||
```
|
||||
|
||||
### Daily Schedule
|
||||
|
||||
```yaml
|
||||
start:
|
||||
type: starter
|
||||
name: Start
|
||||
inputs:
|
||||
startWorkflow: schedule
|
||||
scheduleType: daily
|
||||
dailyTime: "09:00"
|
||||
timezone: "America/New_York"
|
||||
connections:
|
||||
success: daily-task
|
||||
```
|
||||
|
||||
### Weekly Schedule
|
||||
|
||||
```yaml
|
||||
start:
|
||||
type: starter
|
||||
name: Start
|
||||
inputs:
|
||||
startWorkflow: schedule
|
||||
scheduleType: weekly
|
||||
weeklyDay: MON
|
||||
weeklyTime: "08:30"
|
||||
timezone: UTC
|
||||
connections:
|
||||
success: weekly-report
|
||||
```
|
||||
|
||||
### Webhook Trigger
|
||||
|
||||
```yaml
|
||||
start:
|
||||
type: starter
|
||||
name: Start
|
||||
inputs:
|
||||
startWorkflow: webhook
|
||||
webhookProvider: slack
|
||||
webhookConfig:
|
||||
# Provider-specific configuration
|
||||
connections:
|
||||
success: process-webhook
|
||||
```
|
||||
278
apps/docs/content/docs/yaml/blocks/webhook.mdx
Normal file
278
apps/docs/content/docs/yaml/blocks/webhook.mdx
Normal file
@@ -0,0 +1,278 @@
|
||||
---
|
||||
title: Webhook Block YAML Schema
|
||||
description: YAML configuration reference for Webhook blocks
|
||||
---
|
||||
|
||||
## Schema Definition
|
||||
|
||||
```yaml
|
||||
type: object
|
||||
required:
|
||||
- type
|
||||
- name
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum: [webhook]
|
||||
description: Block type identifier
|
||||
name:
|
||||
type: string
|
||||
description: Display name for this webhook block
|
||||
inputs:
|
||||
type: object
|
||||
properties:
|
||||
webhookConfig:
|
||||
type: object
|
||||
description: Webhook configuration settings
|
||||
properties:
|
||||
enabled:
|
||||
type: boolean
|
||||
description: Whether the webhook is active
|
||||
default: true
|
||||
secret:
|
||||
type: string
|
||||
description: Secret key for webhook verification
|
||||
headers:
|
||||
type: array
|
||||
description: Expected headers for validation
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
key:
|
||||
type: string
|
||||
description: Header name
|
||||
value:
|
||||
type: string
|
||||
description: Expected header value
|
||||
methods:
|
||||
type: array
|
||||
description: Allowed HTTP methods
|
||||
items:
|
||||
type: string
|
||||
enum: [GET, POST, PUT, DELETE, PATCH]
|
||||
default: [POST]
|
||||
responseConfig:
|
||||
type: object
|
||||
description: Response configuration for the webhook
|
||||
properties:
|
||||
status:
|
||||
type: number
|
||||
description: HTTP status code to return
|
||||
default: 200
|
||||
minimum: 100
|
||||
maximum: 599
|
||||
headers:
|
||||
type: array
|
||||
description: Response headers
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
key:
|
||||
type: string
|
||||
description: Header name
|
||||
value:
|
||||
type: string
|
||||
description: Header value
|
||||
body:
|
||||
type: string
|
||||
description: Response body content
|
||||
connections:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: string
|
||||
description: Target block ID for successful webhook processing
|
||||
error:
|
||||
type: string
|
||||
description: Target block ID for error handling
|
||||
```
|
||||
|
||||
## Connection Configuration
|
||||
|
||||
Connections define where the workflow goes based on webhook processing:
|
||||
|
||||
```yaml
|
||||
connections:
|
||||
success: <string> # Target block ID for successful processing
|
||||
error: <string> # Target block ID for error handling (optional)
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### Basic Webhook Trigger
|
||||
|
||||
```yaml
|
||||
github-webhook:
|
||||
type: webhook
|
||||
name: "GitHub Webhook"
|
||||
inputs:
|
||||
webhookConfig:
|
||||
enabled: true
|
||||
secret: "{{GITHUB_WEBHOOK_SECRET}}"
|
||||
methods: [POST]
|
||||
headers:
|
||||
- key: "X-GitHub-Event"
|
||||
value: "push"
|
||||
responseConfig:
|
||||
status: 200
|
||||
body: |
|
||||
{
|
||||
"message": "Webhook received successfully",
|
||||
"timestamp": "{{new Date().toISOString()}}"
|
||||
}
|
||||
connections:
|
||||
success: process-github-event
|
||||
error: webhook-error-handler
|
||||
```
|
||||
|
||||
### Slack Event Webhook
|
||||
|
||||
```yaml
|
||||
slack-events:
|
||||
type: webhook
|
||||
name: "Slack Events"
|
||||
inputs:
|
||||
webhookConfig:
|
||||
enabled: true
|
||||
secret: "{{SLACK_SIGNING_SECRET}}"
|
||||
methods: [POST]
|
||||
headers:
|
||||
- key: "Content-Type"
|
||||
value: "application/json"
|
||||
responseConfig:
|
||||
status: 200
|
||||
headers:
|
||||
- key: "Content-Type"
|
||||
value: "application/json"
|
||||
body: |
|
||||
{
|
||||
"challenge": "<webhook.challenge>"
|
||||
}
|
||||
connections:
|
||||
success: handle-slack-event
|
||||
```
|
||||
|
||||
### Payment Webhook (Stripe)
|
||||
|
||||
```yaml
|
||||
stripe-webhook:
|
||||
type: webhook
|
||||
name: "Stripe Payment Webhook"
|
||||
inputs:
|
||||
webhookConfig:
|
||||
enabled: true
|
||||
secret: "{{STRIPE_WEBHOOK_SECRET}}"
|
||||
methods: [POST]
|
||||
headers:
|
||||
- key: "Stripe-Signature"
|
||||
value: "*"
|
||||
responseConfig:
|
||||
status: 200
|
||||
headers:
|
||||
- key: "Content-Type"
|
||||
value: "application/json"
|
||||
body: |
|
||||
{
|
||||
"received": true
|
||||
}
|
||||
connections:
|
||||
success: process-payment-event
|
||||
error: payment-webhook-error
|
||||
```
|
||||
|
||||
### Generic API Webhook
|
||||
|
||||
```yaml
|
||||
api-webhook:
|
||||
type: webhook
|
||||
name: "API Webhook"
|
||||
inputs:
|
||||
webhookConfig:
|
||||
enabled: true
|
||||
methods: [POST, PUT]
|
||||
headers:
|
||||
- key: "Authorization"
|
||||
value: "Bearer {{WEBHOOK_API_KEY}}"
|
||||
- key: "Content-Type"
|
||||
value: "application/json"
|
||||
responseConfig:
|
||||
status: 202
|
||||
headers:
|
||||
- key: "Content-Type"
|
||||
value: "application/json"
|
||||
- key: "X-Processed-By"
|
||||
value: "Sim Studio"
|
||||
body: |
|
||||
{
|
||||
"status": "accepted",
|
||||
"id": "{{Math.random().toString(36).substr(2, 9)}}",
|
||||
"received_at": "{{new Date().toISOString()}}"
|
||||
}
|
||||
connections:
|
||||
success: process-webhook-data
|
||||
```
|
||||
|
||||
### Multi-Method Webhook
|
||||
|
||||
```yaml
|
||||
crud-webhook:
|
||||
type: webhook
|
||||
name: "CRUD Webhook"
|
||||
inputs:
|
||||
webhookConfig:
|
||||
enabled: true
|
||||
methods: [GET, POST, PUT, DELETE]
|
||||
headers:
|
||||
- key: "X-API-Key"
|
||||
value: "{{CRUD_API_KEY}}"
|
||||
responseConfig:
|
||||
status: 200
|
||||
headers:
|
||||
- key: "Content-Type"
|
||||
value: "application/json"
|
||||
body: |
|
||||
{
|
||||
"method": "<webhook.method>",
|
||||
"processed": true,
|
||||
"timestamp": "{{new Date().toISOString()}}"
|
||||
}
|
||||
connections:
|
||||
success: route-by-method
|
||||
```
|
||||
|
||||
## Webhook Variables
|
||||
|
||||
Inside webhook-triggered workflows, these special variables are available:
|
||||
|
||||
```yaml
|
||||
# Available in blocks after the webhook
|
||||
<webhook.payload> # Full request payload/body
|
||||
<webhook.headers> # Request headers
|
||||
<webhook.method> # HTTP method used
|
||||
<webhook.query> # Query parameters
|
||||
<webhook.path> # Request path
|
||||
<webhook.challenge> # Challenge parameter (for verification)
|
||||
```
|
||||
|
||||
## Output References
|
||||
|
||||
After a webhook processes a request, you can reference its data:
|
||||
|
||||
```yaml
|
||||
# In subsequent blocks
|
||||
process-webhook:
|
||||
inputs:
|
||||
payload: <webhook-name.payload> # Request payload
|
||||
headers: <webhook-name.headers> # Request headers
|
||||
method: <webhook-name.method> # HTTP method
|
||||
```
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
- Always use webhook secrets for verification
|
||||
- Validate expected headers and methods
|
||||
- Implement proper error handling
|
||||
- Use HTTPS endpoints in production
|
||||
- Monitor webhook activity and failures
|
||||
- Set appropriate response timeouts
|
||||
- Validate payload structure before processing
|
||||
299
apps/docs/content/docs/yaml/blocks/workflow.mdx
Normal file
299
apps/docs/content/docs/yaml/blocks/workflow.mdx
Normal file
@@ -0,0 +1,299 @@
|
||||
---
|
||||
title: Workflow Block YAML Schema
|
||||
description: YAML configuration reference for Workflow blocks
|
||||
---
|
||||
|
||||
## Schema Definition
|
||||
|
||||
```yaml
|
||||
type: object
|
||||
required:
|
||||
- type
|
||||
- name
|
||||
- inputs
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum: [workflow]
|
||||
description: Block type identifier
|
||||
name:
|
||||
type: string
|
||||
description: Display name for this workflow block
|
||||
inputs:
|
||||
type: object
|
||||
required:
|
||||
- workflowId
|
||||
properties:
|
||||
workflowId:
|
||||
type: string
|
||||
description: ID of the workflow to execute
|
||||
inputMapping:
|
||||
type: object
|
||||
description: Map current workflow data to sub-workflow inputs
|
||||
additionalProperties:
|
||||
type: string
|
||||
description: Input value or reference to parent workflow data
|
||||
environmentVariables:
|
||||
type: object
|
||||
description: Environment variables to pass to sub-workflow
|
||||
additionalProperties:
|
||||
type: string
|
||||
description: Environment variable value
|
||||
timeout:
|
||||
type: number
|
||||
description: Maximum execution time in milliseconds
|
||||
default: 300000
|
||||
minimum: 1000
|
||||
maximum: 1800000
|
||||
connections:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: string
|
||||
description: Target block ID for successful workflow completion
|
||||
error:
|
||||
type: string
|
||||
description: Target block ID for error handling
|
||||
```
|
||||
|
||||
## Connection Configuration
|
||||
|
||||
Connections define where the workflow goes based on sub-workflow results:
|
||||
|
||||
```yaml
|
||||
connections:
|
||||
success: <string> # Target block ID for successful completion
|
||||
error: <string> # Target block ID for error handling (optional)
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### Simple Workflow Execution
|
||||
|
||||
```yaml
|
||||
data-processor:
|
||||
type: workflow
|
||||
name: "Data Processing Workflow"
|
||||
inputs:
|
||||
workflowId: "data-processing-v2"
|
||||
inputMapping:
|
||||
rawData: <start.input>
|
||||
userId: <user-validator.userId>
|
||||
environmentVariables:
|
||||
PROCESSING_MODE: "production"
|
||||
LOG_LEVEL: "info"
|
||||
connections:
|
||||
success: process-results
|
||||
error: workflow-error-handler
|
||||
```
|
||||
|
||||
### Content Generation Pipeline
|
||||
|
||||
```yaml
|
||||
content-generator:
|
||||
type: workflow
|
||||
name: "Content Generation Pipeline"
|
||||
inputs:
|
||||
workflowId: "content-generation-v3"
|
||||
inputMapping:
|
||||
topic: <start.topic>
|
||||
style: <style-analyzer.recommendedStyle>
|
||||
targetAudience: <audience-detector.audience>
|
||||
brandGuidelines: <brand-config.guidelines>
|
||||
environmentVariables:
|
||||
CONTENT_API_KEY: "{{CONTENT_API_KEY}}"
|
||||
QUALITY_THRESHOLD: "high"
|
||||
timeout: 120000
|
||||
connections:
|
||||
success: review-content
|
||||
error: content-generation-failed
|
||||
```
|
||||
|
||||
### Multi-Step Analysis Workflow
|
||||
|
||||
```yaml
|
||||
analysis-workflow:
|
||||
type: workflow
|
||||
name: "Analysis Workflow"
|
||||
inputs:
|
||||
workflowId: "comprehensive-analysis"
|
||||
inputMapping:
|
||||
document: <document-processor.content>
|
||||
analysisType: "comprehensive"
|
||||
includeMetrics: true
|
||||
outputFormat: "structured"
|
||||
environmentVariables:
|
||||
ANALYSIS_MODEL: "gpt-4o"
|
||||
OPENAI_API_KEY: "{{OPENAI_API_KEY}}"
|
||||
CLAUDE_API_KEY: "{{CLAUDE_API_KEY}}"
|
||||
connections:
|
||||
success: compile-analysis-report
|
||||
error: analysis-workflow-error
|
||||
```
|
||||
|
||||
### Conditional Workflow Execution
|
||||
|
||||
```yaml
|
||||
customer-workflow-router:
|
||||
type: condition
|
||||
name: "Customer Workflow Router"
|
||||
inputs:
|
||||
conditions:
|
||||
if: <customer-type.type> === "enterprise"
|
||||
else-if: <customer-type.type> === "premium"
|
||||
else: true
|
||||
connections:
|
||||
conditions:
|
||||
if: enterprise-workflow
|
||||
else-if: premium-workflow
|
||||
else: standard-workflow
|
||||
|
||||
enterprise-workflow:
|
||||
type: workflow
|
||||
name: "Enterprise Customer Workflow"
|
||||
inputs:
|
||||
workflowId: "enterprise-customer-processing"
|
||||
inputMapping:
|
||||
customerData: <customer-data.profile>
|
||||
accountManager: <account-assignment.manager>
|
||||
tier: "enterprise"
|
||||
environmentVariables:
|
||||
PRIORITY_LEVEL: "high"
|
||||
SLA_REQUIREMENTS: "strict"
|
||||
connections:
|
||||
success: enterprise-complete
|
||||
|
||||
premium-workflow:
|
||||
type: workflow
|
||||
name: "Premium Customer Workflow"
|
||||
inputs:
|
||||
workflowId: "premium-customer-processing"
|
||||
inputMapping:
|
||||
customerData: <customer-data.profile>
|
||||
supportLevel: "premium"
|
||||
environmentVariables:
|
||||
PRIORITY_LEVEL: "medium"
|
||||
connections:
|
||||
success: premium-complete
|
||||
|
||||
standard-workflow:
|
||||
type: workflow
|
||||
name: "Standard Customer Workflow"
|
||||
inputs:
|
||||
workflowId: "standard-customer-processing"
|
||||
inputMapping:
|
||||
customerData: <customer-data.profile>
|
||||
environmentVariables:
|
||||
PRIORITY_LEVEL: "standard"
|
||||
connections:
|
||||
success: standard-complete
|
||||
```
|
||||
|
||||
### Parallel Workflow Execution
|
||||
|
||||
```yaml
|
||||
parallel-workflows:
|
||||
type: parallel
|
||||
name: "Parallel Workflow Processing"
|
||||
inputs:
|
||||
parallelType: collection
|
||||
collection: |
|
||||
[
|
||||
{"workflowId": "sentiment-analysis", "focus": "sentiment"},
|
||||
{"workflowId": "topic-extraction", "focus": "topics"},
|
||||
{"workflowId": "entity-recognition", "focus": "entities"}
|
||||
]
|
||||
connections:
|
||||
success: merge-workflow-results
|
||||
|
||||
execute-analysis-workflow:
|
||||
type: workflow
|
||||
name: "Execute Analysis Workflow"
|
||||
parentId: parallel-workflows
|
||||
inputs:
|
||||
workflowId: <parallel.currentItem.workflowId>
|
||||
inputMapping:
|
||||
content: <start.content>
|
||||
analysisType: <parallel.currentItem.focus>
|
||||
environmentVariables:
|
||||
ANALYSIS_API_KEY: "{{ANALYSIS_API_KEY}}"
|
||||
connections:
|
||||
success: workflow-complete
|
||||
```
|
||||
|
||||
### Error Handling Workflow
|
||||
|
||||
```yaml
|
||||
main-workflow:
|
||||
type: workflow
|
||||
name: "Main Processing Workflow"
|
||||
inputs:
|
||||
workflowId: "main-processing-v1"
|
||||
inputMapping:
|
||||
data: <start.input>
|
||||
timeout: 180000
|
||||
connections:
|
||||
success: main-complete
|
||||
error: error-recovery-workflow
|
||||
|
||||
error-recovery-workflow:
|
||||
type: workflow
|
||||
name: "Error Recovery Workflow"
|
||||
inputs:
|
||||
workflowId: "error-recovery-v1"
|
||||
inputMapping:
|
||||
originalInput: <start.input>
|
||||
errorDetails: <main-workflow.error>
|
||||
failureTimestamp: "{{new Date().toISOString()}}"
|
||||
environmentVariables:
|
||||
RECOVERY_MODE: "automatic"
|
||||
FALLBACK_ENABLED: "true"
|
||||
connections:
|
||||
success: recovery-complete
|
||||
error: manual-intervention-required
|
||||
```
|
||||
|
||||
## Input Mapping
|
||||
|
||||
Map data from the parent workflow to the sub-workflow:
|
||||
|
||||
```yaml
|
||||
inputMapping:
|
||||
# Static values
|
||||
mode: "production"
|
||||
version: "1.0"
|
||||
|
||||
# References to parent workflow data
|
||||
userData: <user-processor.profile>
|
||||
settings: <config-loader.settings>
|
||||
|
||||
# Complex object mapping
|
||||
requestData:
|
||||
id: <start.requestId>
|
||||
timestamp: "{{new Date().toISOString()}}"
|
||||
source: "parent-workflow"
|
||||
```
|
||||
|
||||
## Output References
|
||||
|
||||
After a workflow block completes, you can reference its outputs:
|
||||
|
||||
```yaml
|
||||
# In subsequent blocks
|
||||
next-block:
|
||||
inputs:
|
||||
workflowResult: <workflow-name.output> # Sub-workflow output
|
||||
executionTime: <workflow-name.duration> # Execution duration
|
||||
status: <workflow-name.status> # Execution status
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
- Use descriptive workflow IDs for clarity
|
||||
- Map only necessary data to sub-workflows
|
||||
- Set appropriate timeouts for workflow complexity
|
||||
- Include error handling for robust execution
|
||||
- Pass environment variables securely
|
||||
- Test sub-workflows independently first
|
||||
- Monitor nested workflow performance
|
||||
- Use versioned workflow IDs for stability
|
||||
273
apps/docs/content/docs/yaml/examples.mdx
Normal file
273
apps/docs/content/docs/yaml/examples.mdx
Normal file
@@ -0,0 +1,273 @@
|
||||
---
|
||||
title: YAML Workflow Examples
|
||||
description: Examples of complete YAML workflows
|
||||
---
|
||||
|
||||
import { Tab, Tabs } from 'fumadocs-ui/components/tabs'
|
||||
|
||||
## Multi-Agent Chain Workflow
|
||||
|
||||
A workflow where multiple AI agents process information sequentially:
|
||||
|
||||
```yaml
|
||||
version: '1.0'
|
||||
blocks:
|
||||
start:
|
||||
type: starter
|
||||
name: Start
|
||||
inputs:
|
||||
startWorkflow: manual
|
||||
connections:
|
||||
success: agent-1-initiator
|
||||
|
||||
agent-1-initiator:
|
||||
type: agent
|
||||
name: Agent 1 Initiator
|
||||
inputs:
|
||||
systemPrompt: You are the first agent in a chain. Your role is to analyze the input and create an initial response that will be passed to the next agent.
|
||||
userPrompt: |-
|
||||
Welcome! I'm the first agent in our chain.
|
||||
|
||||
Input to process: <start.input>
|
||||
|
||||
Please create an initial analysis or greeting that the next agent can build upon. Be creative and set a positive tone for the chain!
|
||||
model: gpt-4o
|
||||
temperature: 0.7
|
||||
apiKey: '{{OPENAI_API_KEY}}'
|
||||
connections:
|
||||
success: agent-2-enhancer
|
||||
|
||||
agent-2-enhancer:
|
||||
type: agent
|
||||
name: Agent 2 Enhancer
|
||||
inputs:
|
||||
systemPrompt: You are the second agent in a chain. Take the output from Agent 1 and enhance it with additional insights or improvements.
|
||||
userPrompt: |-
|
||||
I'm the second agent! Here's what Agent 1 provided:
|
||||
|
||||
<agent1initiator.content>
|
||||
|
||||
Now I'll enhance this with additional details, insights, or improvements. Let me build upon their work!
|
||||
model: gpt-4o
|
||||
temperature: 0.7
|
||||
apiKey: '{{OPENAI_API_KEY}}'
|
||||
connections:
|
||||
success: agent-3-refiner
|
||||
|
||||
agent-3-refiner:
|
||||
type: agent
|
||||
name: Agent 3 Refiner
|
||||
inputs:
|
||||
systemPrompt: You are the third agent in a chain. Take the enhanced output from Agent 2 and refine it further, adding structure or organization.
|
||||
userPrompt: |-
|
||||
I'm the third agent in our chain! Here's the enhanced work from Agent 2:
|
||||
|
||||
<agent2enhancer.content>
|
||||
|
||||
My job is to refine and organize this content. I'll add structure, clarity, and polish to make it even better!
|
||||
model: gpt-4o
|
||||
temperature: 0.6
|
||||
apiKey: '{{OPENAI_API_KEY}}'
|
||||
connections:
|
||||
success: agent-4-finalizer
|
||||
|
||||
agent-4-finalizer:
|
||||
type: agent
|
||||
name: Agent 4 Finalizer
|
||||
inputs:
|
||||
systemPrompt: You are the final agent in a chain of 4. Create a comprehensive summary and conclusion based on all the previous agents' work.
|
||||
userPrompt: |-
|
||||
I'm the final agent! Here's the refined work from Agent 3:
|
||||
|
||||
<agent3refiner.content>
|
||||
|
||||
As the last agent in our chain, I'll create a final, polished summary that brings together all the work from our team of 4 agents. Let me conclude this beautifully!
|
||||
model: gpt-4o
|
||||
temperature: 0.5
|
||||
apiKey: '{{OPENAI_API_KEY}}'
|
||||
```
|
||||
|
||||
## Router-Based Conditional Workflow
|
||||
|
||||
A workflow that uses routing logic to send data to different agents based on conditions:
|
||||
|
||||
```yaml
|
||||
version: '1.0'
|
||||
blocks:
|
||||
start:
|
||||
type: starter
|
||||
name: Start
|
||||
inputs:
|
||||
startWorkflow: manual
|
||||
connections:
|
||||
success: router-1
|
||||
|
||||
router-1:
|
||||
type: router
|
||||
name: Router 1
|
||||
inputs:
|
||||
prompt: go to agent 1 if <start.input> is greater than 5. else agent 2 if greater than 10. else agent 3
|
||||
model: gpt-4o
|
||||
apiKey: '{{OPENAI_API_KEY}}'
|
||||
connections:
|
||||
success:
|
||||
- agent-1
|
||||
- agent-2
|
||||
- agent-3
|
||||
|
||||
agent-1:
|
||||
type: agent
|
||||
name: Agent 1
|
||||
inputs:
|
||||
systemPrompt: say 1
|
||||
model: gpt-4o
|
||||
apiKey: '{{OPENAI_API_KEY}}'
|
||||
|
||||
agent-2:
|
||||
type: agent
|
||||
name: Agent 2
|
||||
inputs:
|
||||
systemPrompt: say 2
|
||||
model: gpt-4o
|
||||
apiKey: '{{OPENAI_API_KEY}}'
|
||||
|
||||
agent-3:
|
||||
type: agent
|
||||
name: Agent 3
|
||||
inputs:
|
||||
systemPrompt: say 3
|
||||
model: gpt-4o
|
||||
apiKey: '{{OPENAI_API_KEY}}'
|
||||
```
|
||||
|
||||
## Web Search with Structured Output
|
||||
|
||||
A workflow that searches the web using tools and returns structured data:
|
||||
|
||||
```yaml
|
||||
version: '1.0'
|
||||
blocks:
|
||||
59eb07c1-1411-4b28-a274-fa78f55daf72:
|
||||
type: starter
|
||||
name: Start
|
||||
inputs:
|
||||
startWorkflow: manual
|
||||
connections:
|
||||
success: d77c2c98-56c4-432d-9338-9bac54a2d42f
|
||||
d77c2c98-56c4-432d-9338-9bac54a2d42f:
|
||||
type: agent
|
||||
name: Agent 1
|
||||
inputs:
|
||||
systemPrompt: look up the user input. use structured output
|
||||
userPrompt: <start.input>
|
||||
model: claude-sonnet-4-0
|
||||
apiKey: '{{ANTHROPIC_API_KEY}}'
|
||||
tools:
|
||||
- type: exa
|
||||
title: Exa
|
||||
params:
|
||||
type: auto
|
||||
apiKey: '{{EXA_API_KEY}}'
|
||||
numResults: ''
|
||||
toolId: exa_search
|
||||
operation: exa_search
|
||||
isExpanded: true
|
||||
usageControl: auto
|
||||
responseFormat: |-
|
||||
{
|
||||
"name": "output_schema",
|
||||
"description": "Defines the structure for an output object.",
|
||||
"strict": true,
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"output": {
|
||||
"type": "string",
|
||||
"description": "The output value"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": ["output"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Loop Processing with Collection
|
||||
|
||||
A workflow that processes each item in a collection using a loop:
|
||||
|
||||
```yaml
|
||||
version: '1.0'
|
||||
blocks:
|
||||
start:
|
||||
type: starter
|
||||
name: Start
|
||||
inputs:
|
||||
startWorkflow: manual
|
||||
connections:
|
||||
success: food-analysis-loop
|
||||
food-analysis-loop:
|
||||
type: loop
|
||||
name: Food Analysis Loop
|
||||
inputs:
|
||||
count: 5
|
||||
loopType: forEach
|
||||
collection: '["apple", "banana", "carrot"]'
|
||||
connections:
|
||||
loop:
|
||||
start: calorie-agent
|
||||
calorie-agent:
|
||||
type: agent
|
||||
name: Calorie Analyzer
|
||||
inputs:
|
||||
systemPrompt: Return the number of calories in the food
|
||||
userPrompt: <loop.currentItem>
|
||||
model: claude-sonnet-4-0
|
||||
apiKey: '{{ANTHROPIC_API_KEY}}'
|
||||
parentId: food-analysis-loop
|
||||
```
|
||||
|
||||
## Email Classification and Response
|
||||
|
||||
A workflow that classifies emails and generates appropriate responses:
|
||||
|
||||
```yaml
|
||||
version: '1.0'
|
||||
blocks:
|
||||
start:
|
||||
type: starter
|
||||
name: Start
|
||||
inputs:
|
||||
startWorkflow: manual
|
||||
connections:
|
||||
success: email-classifier
|
||||
|
||||
email-classifier:
|
||||
type: agent
|
||||
name: Email Classifier
|
||||
inputs:
|
||||
systemPrompt: Classify emails into categories and extract key information.
|
||||
userPrompt: |
|
||||
Classify this email: <start.input>
|
||||
|
||||
Categories: support, billing, sales, feedback
|
||||
Extract: urgency level, customer sentiment, main request
|
||||
model: gpt-4o
|
||||
apiKey: '{{OPENAI_API_KEY}}'
|
||||
connections:
|
||||
success: response-generator
|
||||
|
||||
response-generator:
|
||||
type: agent
|
||||
name: Response Generator
|
||||
inputs:
|
||||
systemPrompt: Generate appropriate responses based on email classification.
|
||||
userPrompt: |
|
||||
Email classification: <emailclassifier.content>
|
||||
Original email: <start.input>
|
||||
|
||||
Generate a professional, helpful response addressing the customer's needs.
|
||||
model: gpt-4o
|
||||
temperature: 0.7
|
||||
apiKey: '{{OPENAI_API_KEY}}'
|
||||
```
|
||||
159
apps/docs/content/docs/yaml/index.mdx
Normal file
159
apps/docs/content/docs/yaml/index.mdx
Normal file
@@ -0,0 +1,159 @@
|
||||
---
|
||||
title: YAML Workflow Reference
|
||||
description: Complete guide to writing YAML workflows in Sim Studio
|
||||
---
|
||||
|
||||
import { Card, Cards } from "fumadocs-ui/components/card";
|
||||
import { Step, Steps } from "fumadocs-ui/components/steps";
|
||||
import { Tab, Tabs } from "fumadocs-ui/components/tabs";
|
||||
|
||||
YAML workflows provide a powerful way to define, version, and share workflow configurations in Sim Studio. This reference guide covers the complete YAML syntax, block schemas, and best practices for creating robust workflows.
|
||||
|
||||
## Quick Start
|
||||
|
||||
Every Sim Studio workflow follows this basic structure:
|
||||
|
||||
```yaml
|
||||
version: '1.0'
|
||||
blocks:
|
||||
start:
|
||||
type: starter
|
||||
name: Start
|
||||
inputs:
|
||||
startWorkflow: manual
|
||||
connections:
|
||||
success: agent-1
|
||||
|
||||
agent-1:
|
||||
type: agent
|
||||
name: "AI Assistant"
|
||||
inputs:
|
||||
systemPrompt: "You are a helpful assistant."
|
||||
userPrompt: 'Hi'
|
||||
model: gpt-4o
|
||||
apiKey: '{{OPENAI_API_KEY}}'
|
||||
```
|
||||
|
||||
## Core Concepts
|
||||
|
||||
<Steps>
|
||||
<Step>
|
||||
<strong>Version Declaration</strong>: Must be exactly `version: '1.0'` (with quotes)
|
||||
</Step>
|
||||
<Step>
|
||||
<strong>Blocks Structure</strong>: All workflow blocks are defined under the `blocks` key
|
||||
</Step>
|
||||
<Step>
|
||||
<strong>Block References</strong>: Use block names in lowercase with spaces removed (e.g., `<aiassistant.content>`)
|
||||
</Step>
|
||||
<Step>
|
||||
<strong>Environment Variables</strong>: Reference with double curly braces `{{VARIABLE_NAME}}`
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## Block Types
|
||||
|
||||
Sim Studio supports several core block types, each with specific YAML schemas:
|
||||
|
||||
<Cards>
|
||||
<Card title="Starter Block" href="/yaml/blocks/starter">
|
||||
Workflow entry point with support for manual, webhook, and scheduled triggers
|
||||
</Card>
|
||||
<Card title="Agent Block" href="/yaml/blocks/agent">
|
||||
AI-powered processing with support for tools and structured output
|
||||
</Card>
|
||||
<Card title="Function Block" href="/yaml/blocks/function">
|
||||
Custom JavaScript/TypeScript code execution
|
||||
</Card>
|
||||
<Card title="API Block" href="/yaml/blocks/api">
|
||||
HTTP requests to external services
|
||||
</Card>
|
||||
<Card title="Condition Block" href="/yaml/blocks/condition">
|
||||
Conditional branching based on boolean expressions
|
||||
</Card>
|
||||
<Card title="Router Block" href="/yaml/blocks/router">
|
||||
AI-powered intelligent routing to multiple paths
|
||||
</Card>
|
||||
<Card title="Loop Block" href="/yaml/blocks/loop">
|
||||
Iterative processing with for and forEach loops
|
||||
</Card>
|
||||
<Card title="Parallel Block" href="/yaml/blocks/parallel">
|
||||
Concurrent execution across multiple instances
|
||||
</Card>
|
||||
<Card title="Webhook Block" href="/yaml/blocks/webhook">
|
||||
Webhook triggers for external integrations
|
||||
</Card>
|
||||
<Card title="Evaluator Block" href="/yaml/blocks/evaluator">
|
||||
Validate outputs against defined criteria and metrics
|
||||
</Card>
|
||||
<Card title="Workflow Block" href="/yaml/blocks/workflow">
|
||||
Execute other workflows as reusable components
|
||||
</Card>
|
||||
<Card title="Response Block" href="/yaml/blocks/response">
|
||||
Final workflow output formatting
|
||||
</Card>
|
||||
</Cards>
|
||||
|
||||
## Block Reference Syntax
|
||||
|
||||
The most critical aspect of YAML workflows is understanding how to reference data between blocks:
|
||||
|
||||
### Basic Rules
|
||||
|
||||
1. **Use the block name** (not the block ID) converted to lowercase with spaces removed
|
||||
2. **Add the appropriate property** (.content for agents, .output for tools)
|
||||
3. **When using chat, reference the starter block** as `<start.input>`
|
||||
|
||||
### Examples
|
||||
|
||||
```yaml
|
||||
# Block definitions
|
||||
email-processor:
|
||||
type: agent
|
||||
name: "Email Agent"
|
||||
# ... configuration
|
||||
|
||||
data-formatter:
|
||||
type: function
|
||||
name: "Data Agent"
|
||||
# ... configuration
|
||||
|
||||
# Referencing their outputs
|
||||
next-block:
|
||||
type: agent
|
||||
name: "Next Step"
|
||||
inputs:
|
||||
userPrompt: |
|
||||
Process this email: <emailagent.content>
|
||||
Use this formatted data: <dataagent.output>
|
||||
Original input: <start.input>
|
||||
```
|
||||
|
||||
### Special Cases
|
||||
|
||||
- **Loop Variables**: `<loop.index>`, `<loop.currentItem>`, `<loop.items>`
|
||||
- **Parallel Variables**: `<parallel.index>`, `<parallel.currentItem>`
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Use environment variables for sensitive data like API keys:
|
||||
|
||||
```yaml
|
||||
inputs:
|
||||
apiKey: '{{OPENAI_API_KEY}}'
|
||||
database: '{{DATABASE_URL}}'
|
||||
token: '{{SLACK_BOT_TOKEN}}'
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
- **Keep block names human-readable**: "Email Processor" for UI display
|
||||
- **Reference environment variables**: Never hardcode API keys
|
||||
- **Structure for readability**: Group related blocks logically
|
||||
- **Test incrementally**: Build workflows step by step
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Block Reference Syntax](/yaml/block-reference) - Detailed reference rules
|
||||
- [Complete Block Schemas](/yaml/blocks) - All available block types
|
||||
- [Workflow Examples](/yaml/examples) - Real-world workflow patterns
|
||||
4
apps/docs/content/docs/yaml/meta.json
Normal file
4
apps/docs/content/docs/yaml/meta.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"title": "YAML Reference",
|
||||
"pages": ["index", "block-reference", "blocks", "examples"]
|
||||
}
|
||||
@@ -172,6 +172,9 @@ function Integrations() {
|
||||
<div className='flex aspect-square h-16 w-16 items-center justify-center rounded-xl border border-[#353535] bg-[#242424] p-1 shadow-[0px_2px_6px_0px_rgba(126,_48,_252,_0.1)]'>
|
||||
<Icons.pinecone />
|
||||
</div>
|
||||
<div className='flex aspect-square h-16 w-16 items-center justify-center rounded-xl border border-[#353535] bg-[#242424] p-1 shadow-[0px_2px_6px_0px_rgba(126,_48,_252,_0.1)]'>
|
||||
<Icons.qdrant />
|
||||
</div>
|
||||
<div className='flex aspect-square h-16 w-16 items-center justify-center rounded-xl border border-[#353535] bg-[#242424] p-1 shadow-[0px_2px_6px_0px_rgba(126,_48,_252,_0.1)]'>
|
||||
<Icons.slack />
|
||||
</div>
|
||||
@@ -290,6 +293,9 @@ function Integrations() {
|
||||
<div className='flex aspect-square h-12 w-12 items-center justify-center rounded-xl border border-[#353535] bg-[#242424] p-1 shadow-[0px_2px_6px_0px_rgba(126,_48,_252,_0.1)]'>
|
||||
<Icons.pinecone />
|
||||
</div>
|
||||
<div className='flex aspect-square h-12 w-12 items-center justify-center rounded-xl border border-[#353535] bg-[#242424] p-1 shadow-[0px_2px_6px_0px_rgba(126,_48,_252,_0.1)]'>
|
||||
<Icons.qdrant />
|
||||
</div>
|
||||
<div className='flex aspect-square h-12 w-12 items-center justify-center rounded-xl border border-[#353535] bg-[#242424] p-1 shadow-[0px_2px_6px_0px_rgba(126,_48,_252,_0.1)]'>
|
||||
<Icons.slack />
|
||||
</div>
|
||||
@@ -512,6 +518,77 @@ const Icons = {
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
qdrant: () => (
|
||||
<svg width='48' height='48' fill='none' viewBox='0 0 49 56' xmlns='http://www.w3.org/2000/svg'>
|
||||
<g clipPath='url(#b)'>
|
||||
<path
|
||||
d='m38.489 51.477-1.1167-30.787-2.0223-8.1167 13.498 1.429v37.242l-8.2456 4.7589-2.1138-4.5259z'
|
||||
clipRule='evenodd'
|
||||
fill='#24386C'
|
||||
fillRule='evenodd'
|
||||
/>
|
||||
<path
|
||||
d='m48.847 14-8.2457 4.7622-17.016-3.7326-19.917 8.1094-3.3183-9.139 12.122-7 12.126-7 12.123 7 12.126 7z'
|
||||
clipRule='evenodd'
|
||||
fill='#7589BE'
|
||||
fillRule='evenodd'
|
||||
/>
|
||||
<path
|
||||
d='m0.34961 13.999 8.2457 4.7622 4.7798 14.215 16.139 12.913-4.9158 10.109-12.126-7.0004-12.123-7v-28z'
|
||||
clipRule='evenodd'
|
||||
fill='#B2BFE8'
|
||||
fillRule='evenodd'
|
||||
/>
|
||||
<path
|
||||
d='m30.066 38.421-5.4666 8.059v9.5207l7.757-4.4756 3.9968-5.9681'
|
||||
clipRule='evenodd'
|
||||
fill='#24386C'
|
||||
fillRule='evenodd'
|
||||
/>
|
||||
<path
|
||||
d='m24.602 36.962-7.7603-13.436 1.6715-4.4531 6.3544-3.0809 7.488 7.5343-7.7536 13.436z'
|
||||
clipRule='evenodd'
|
||||
fill='#7589BE'
|
||||
fillRule='evenodd'
|
||||
/>
|
||||
<path
|
||||
d='m16.843 23.525 7.7569 4.4756v8.9585l-7.1741 0.3087-4.3397-5.5412 3.7569-8.2016z'
|
||||
clipRule='evenodd'
|
||||
fill='#B2BFE8'
|
||||
fillRule='evenodd'
|
||||
/>
|
||||
<path
|
||||
d='m24.6 28 7.757-4.4752 5.2792 8.7903-6.3886 5.2784-6.6476-0.6346v-8.9589z'
|
||||
clipRule='evenodd'
|
||||
fill='#24386C'
|
||||
fillRule='evenodd'
|
||||
/>
|
||||
<path
|
||||
d='m32.355 51.524 8.2457 4.476v-37.238l-8.0032-4.6189-7.9995-4.6189-8.0031 4.6189-7.9995 4.6189v18.479l7.9995 4.6189 8.0031 4.6193 7.757-4.4797v9.5244zm0-19.045-7.757 4.4793-7.7569-4.4793v-8.9549l7.7569-4.4792 7.757 4.4792v8.9549z'
|
||||
clipRule='evenodd'
|
||||
fill='#DC244C'
|
||||
fillRule='evenodd'
|
||||
/>
|
||||
<path d='m24.603 46.483v-9.5222l-7.7166-4.4411v9.5064l7.7166 4.4569z' fill='url(#a)' />
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id='a'
|
||||
x1='23.18'
|
||||
x2='15.491'
|
||||
y1='38.781'
|
||||
y2='38.781'
|
||||
gradientUnits='userSpaceOnUse'
|
||||
>
|
||||
<stop stopColor='#FF3364' offset='0' />
|
||||
<stop stopColor='#C91540' stopOpacity='0' offset='1' />
|
||||
</linearGradient>
|
||||
<clipPath id='b'>
|
||||
<rect transform='translate(.34961)' width='48.3' height='56' fill='#fff' />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
),
|
||||
slack: () => (
|
||||
<svg width='48' height='48' viewBox='0 0 48 48' fill='none' xmlns='http://www.w3.org/2000/svg'>
|
||||
<g clipPath='url(#clip0_82_6239)'>
|
||||
|
||||
396
apps/sim/app/api/chat/edit/[id]/route.test.ts
Normal file
396
apps/sim/app/api/chat/edit/[id]/route.test.ts
Normal file
@@ -0,0 +1,396 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
/**
|
||||
* Tests for chat edit API route
|
||||
*
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
describe('Chat Edit API Route', () => {
|
||||
const mockSelect = vi.fn()
|
||||
const mockFrom = vi.fn()
|
||||
const mockWhere = vi.fn()
|
||||
const mockLimit = vi.fn()
|
||||
const mockUpdate = vi.fn()
|
||||
const mockSet = vi.fn()
|
||||
const mockDelete = vi.fn()
|
||||
|
||||
const mockCreateSuccessResponse = vi.fn()
|
||||
const mockCreateErrorResponse = vi.fn()
|
||||
const mockEncryptSecret = vi.fn()
|
||||
const mockCheckChatAccess = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
|
||||
mockSelect.mockReturnValue({ from: mockFrom })
|
||||
mockFrom.mockReturnValue({ where: mockWhere })
|
||||
mockWhere.mockReturnValue({ limit: mockLimit })
|
||||
mockUpdate.mockReturnValue({ set: mockSet })
|
||||
mockSet.mockReturnValue({ where: mockWhere })
|
||||
mockDelete.mockReturnValue({ where: mockWhere })
|
||||
|
||||
vi.doMock('@/db', () => ({
|
||||
db: {
|
||||
select: mockSelect,
|
||||
update: mockUpdate,
|
||||
delete: mockDelete,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.doMock('@/db/schema', () => ({
|
||||
chat: { id: 'id', subdomain: 'subdomain', userId: 'userId' },
|
||||
}))
|
||||
|
||||
vi.doMock('@/lib/logs/console-logger', () => ({
|
||||
createLogger: vi.fn().mockReturnValue({
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.doMock('@/app/api/workflows/utils', () => ({
|
||||
createSuccessResponse: mockCreateSuccessResponse.mockImplementation((data) => {
|
||||
return new Response(JSON.stringify(data), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}),
|
||||
createErrorResponse: mockCreateErrorResponse.mockImplementation((message, status = 500) => {
|
||||
return new Response(JSON.stringify({ error: message }), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.doMock('@/lib/utils', () => ({
|
||||
encryptSecret: mockEncryptSecret.mockResolvedValue({ encrypted: 'encrypted-password' }),
|
||||
}))
|
||||
|
||||
vi.doMock('@/lib/urls/utils', () => ({
|
||||
getEmailDomain: vi.fn().mockReturnValue('localhost:3000'),
|
||||
}))
|
||||
|
||||
vi.doMock('@/lib/environment', () => ({
|
||||
isDev: true,
|
||||
}))
|
||||
|
||||
vi.doMock('@/app/api/chat/utils', () => ({
|
||||
checkChatAccess: mockCheckChatAccess,
|
||||
}))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('GET', () => {
|
||||
it('should return 401 when user is not authenticated', async () => {
|
||||
vi.doMock('@/lib/auth', () => ({
|
||||
getSession: vi.fn().mockResolvedValue(null),
|
||||
}))
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/chat/edit/chat-123')
|
||||
const { GET } = await import('./route')
|
||||
const response = await GET(req, { params: Promise.resolve({ id: 'chat-123' }) })
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(mockCreateErrorResponse).toHaveBeenCalledWith('Unauthorized', 401)
|
||||
})
|
||||
|
||||
it('should return 404 when chat not found or access denied', async () => {
|
||||
vi.doMock('@/lib/auth', () => ({
|
||||
getSession: vi.fn().mockResolvedValue({
|
||||
user: { id: 'user-id' },
|
||||
}),
|
||||
}))
|
||||
|
||||
mockCheckChatAccess.mockResolvedValue({ hasAccess: false })
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/chat/edit/chat-123')
|
||||
const { GET } = await import('./route')
|
||||
const response = await GET(req, { params: Promise.resolve({ id: 'chat-123' }) })
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
expect(mockCreateErrorResponse).toHaveBeenCalledWith('Chat not found or access denied', 404)
|
||||
expect(mockCheckChatAccess).toHaveBeenCalledWith('chat-123', 'user-id')
|
||||
})
|
||||
|
||||
it('should return chat details when user has access', async () => {
|
||||
vi.doMock('@/lib/auth', () => ({
|
||||
getSession: vi.fn().mockResolvedValue({
|
||||
user: { id: 'user-id' },
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockChat = {
|
||||
id: 'chat-123',
|
||||
subdomain: 'test-chat',
|
||||
title: 'Test Chat',
|
||||
description: 'A test chat',
|
||||
password: 'encrypted-password',
|
||||
customizations: { primaryColor: '#000000' },
|
||||
}
|
||||
|
||||
mockCheckChatAccess.mockResolvedValue({ hasAccess: true, chat: mockChat })
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/chat/edit/chat-123')
|
||||
const { GET } = await import('./route')
|
||||
const response = await GET(req, { params: Promise.resolve({ id: 'chat-123' }) })
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(mockCreateSuccessResponse).toHaveBeenCalledWith({
|
||||
id: 'chat-123',
|
||||
subdomain: 'test-chat',
|
||||
title: 'Test Chat',
|
||||
description: 'A test chat',
|
||||
customizations: { primaryColor: '#000000' },
|
||||
chatUrl: 'http://test-chat.localhost:3000',
|
||||
hasPassword: true,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('PATCH', () => {
|
||||
it('should return 401 when user is not authenticated', async () => {
|
||||
vi.doMock('@/lib/auth', () => ({
|
||||
getSession: vi.fn().mockResolvedValue(null),
|
||||
}))
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/chat/edit/chat-123', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ title: 'Updated Chat' }),
|
||||
})
|
||||
const { PATCH } = await import('./route')
|
||||
const response = await PATCH(req, { params: Promise.resolve({ id: 'chat-123' }) })
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(mockCreateErrorResponse).toHaveBeenCalledWith('Unauthorized', 401)
|
||||
})
|
||||
|
||||
it('should return 404 when chat not found or access denied', async () => {
|
||||
vi.doMock('@/lib/auth', () => ({
|
||||
getSession: vi.fn().mockResolvedValue({
|
||||
user: { id: 'user-id' },
|
||||
}),
|
||||
}))
|
||||
|
||||
mockCheckChatAccess.mockResolvedValue({ hasAccess: false })
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/chat/edit/chat-123', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ title: 'Updated Chat' }),
|
||||
})
|
||||
const { PATCH } = await import('./route')
|
||||
const response = await PATCH(req, { params: Promise.resolve({ id: 'chat-123' }) })
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
expect(mockCreateErrorResponse).toHaveBeenCalledWith('Chat not found or access denied', 404)
|
||||
expect(mockCheckChatAccess).toHaveBeenCalledWith('chat-123', 'user-id')
|
||||
})
|
||||
|
||||
it('should update chat when user has access', async () => {
|
||||
vi.doMock('@/lib/auth', () => ({
|
||||
getSession: vi.fn().mockResolvedValue({
|
||||
user: { id: 'user-id' },
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockChat = {
|
||||
id: 'chat-123',
|
||||
subdomain: 'test-chat',
|
||||
title: 'Test Chat',
|
||||
authType: 'public',
|
||||
}
|
||||
|
||||
mockCheckChatAccess.mockResolvedValue({ hasAccess: true, chat: mockChat })
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/chat/edit/chat-123', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ title: 'Updated Chat', description: 'Updated description' }),
|
||||
})
|
||||
const { PATCH } = await import('./route')
|
||||
const response = await PATCH(req, { params: Promise.resolve({ id: 'chat-123' }) })
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(mockUpdate).toHaveBeenCalled()
|
||||
expect(mockCreateSuccessResponse).toHaveBeenCalledWith({
|
||||
id: 'chat-123',
|
||||
chatUrl: 'http://test-chat.localhost:3000',
|
||||
message: 'Chat deployment updated successfully',
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle subdomain conflicts', async () => {
|
||||
vi.doMock('@/lib/auth', () => ({
|
||||
getSession: vi.fn().mockResolvedValue({
|
||||
user: { id: 'user-id' },
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockChat = {
|
||||
id: 'chat-123',
|
||||
subdomain: 'test-chat',
|
||||
title: 'Test Chat',
|
||||
}
|
||||
|
||||
mockCheckChatAccess.mockResolvedValue({ hasAccess: true, chat: mockChat })
|
||||
// Mock subdomain conflict
|
||||
mockLimit.mockResolvedValueOnce([{ id: 'other-chat-id', subdomain: 'new-subdomain' }])
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/chat/edit/chat-123', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ subdomain: 'new-subdomain' }),
|
||||
})
|
||||
const { PATCH } = await import('./route')
|
||||
const response = await PATCH(req, { params: Promise.resolve({ id: 'chat-123' }) })
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(mockCreateErrorResponse).toHaveBeenCalledWith('Subdomain already in use', 400)
|
||||
})
|
||||
|
||||
it('should validate password requirement for password auth', async () => {
|
||||
vi.doMock('@/lib/auth', () => ({
|
||||
getSession: vi.fn().mockResolvedValue({
|
||||
user: { id: 'user-id' },
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockChat = {
|
||||
id: 'chat-123',
|
||||
subdomain: 'test-chat',
|
||||
title: 'Test Chat',
|
||||
authType: 'public',
|
||||
password: null,
|
||||
}
|
||||
|
||||
mockCheckChatAccess.mockResolvedValue({ hasAccess: true, chat: mockChat })
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/chat/edit/chat-123', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ authType: 'password' }), // No password provided
|
||||
})
|
||||
const { PATCH } = await import('./route')
|
||||
const response = await PATCH(req, { params: Promise.resolve({ id: 'chat-123' }) })
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(mockCreateErrorResponse).toHaveBeenCalledWith(
|
||||
'Password is required when using password protection',
|
||||
400
|
||||
)
|
||||
})
|
||||
|
||||
it('should allow access when user has workspace admin permission', async () => {
|
||||
vi.doMock('@/lib/auth', () => ({
|
||||
getSession: vi.fn().mockResolvedValue({
|
||||
user: { id: 'admin-user-id' },
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockChat = {
|
||||
id: 'chat-123',
|
||||
subdomain: 'test-chat',
|
||||
title: 'Test Chat',
|
||||
authType: 'public',
|
||||
}
|
||||
|
||||
// User doesn't own chat but has workspace admin access
|
||||
mockCheckChatAccess.mockResolvedValue({ hasAccess: true, chat: mockChat })
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/chat/edit/chat-123', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ title: 'Admin Updated Chat' }),
|
||||
})
|
||||
const { PATCH } = await import('./route')
|
||||
const response = await PATCH(req, { params: Promise.resolve({ id: 'chat-123' }) })
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(mockCheckChatAccess).toHaveBeenCalledWith('chat-123', 'admin-user-id')
|
||||
})
|
||||
})
|
||||
|
||||
describe('DELETE', () => {
|
||||
it('should return 401 when user is not authenticated', async () => {
|
||||
vi.doMock('@/lib/auth', () => ({
|
||||
getSession: vi.fn().mockResolvedValue(null),
|
||||
}))
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/chat/edit/chat-123', {
|
||||
method: 'DELETE',
|
||||
})
|
||||
const { DELETE } = await import('./route')
|
||||
const response = await DELETE(req, { params: Promise.resolve({ id: 'chat-123' }) })
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(mockCreateErrorResponse).toHaveBeenCalledWith('Unauthorized', 401)
|
||||
})
|
||||
|
||||
it('should return 404 when chat not found or access denied', async () => {
|
||||
vi.doMock('@/lib/auth', () => ({
|
||||
getSession: vi.fn().mockResolvedValue({
|
||||
user: { id: 'user-id' },
|
||||
}),
|
||||
}))
|
||||
|
||||
mockCheckChatAccess.mockResolvedValue({ hasAccess: false })
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/chat/edit/chat-123', {
|
||||
method: 'DELETE',
|
||||
})
|
||||
const { DELETE } = await import('./route')
|
||||
const response = await DELETE(req, { params: Promise.resolve({ id: 'chat-123' }) })
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
expect(mockCreateErrorResponse).toHaveBeenCalledWith('Chat not found or access denied', 404)
|
||||
expect(mockCheckChatAccess).toHaveBeenCalledWith('chat-123', 'user-id')
|
||||
})
|
||||
|
||||
it('should delete chat when user has access', async () => {
|
||||
vi.doMock('@/lib/auth', () => ({
|
||||
getSession: vi.fn().mockResolvedValue({
|
||||
user: { id: 'user-id' },
|
||||
}),
|
||||
}))
|
||||
|
||||
mockCheckChatAccess.mockResolvedValue({ hasAccess: true })
|
||||
mockWhere.mockResolvedValue(undefined)
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/chat/edit/chat-123', {
|
||||
method: 'DELETE',
|
||||
})
|
||||
const { DELETE } = await import('./route')
|
||||
const response = await DELETE(req, { params: Promise.resolve({ id: 'chat-123' }) })
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(mockDelete).toHaveBeenCalled()
|
||||
expect(mockCreateSuccessResponse).toHaveBeenCalledWith({
|
||||
message: 'Chat deployment deleted successfully',
|
||||
})
|
||||
})
|
||||
|
||||
it('should allow deletion when user has workspace admin permission', async () => {
|
||||
vi.doMock('@/lib/auth', () => ({
|
||||
getSession: vi.fn().mockResolvedValue({
|
||||
user: { id: 'admin-user-id' },
|
||||
}),
|
||||
}))
|
||||
|
||||
// User doesn't own chat but has workspace admin access
|
||||
mockCheckChatAccess.mockResolvedValue({ hasAccess: true })
|
||||
mockWhere.mockResolvedValue(undefined)
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/chat/edit/chat-123', {
|
||||
method: 'DELETE',
|
||||
})
|
||||
const { DELETE } = await import('./route')
|
||||
const response = await DELETE(req, { params: Promise.resolve({ id: 'chat-123' }) })
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(mockCheckChatAccess).toHaveBeenCalledWith('chat-123', 'admin-user-id')
|
||||
expect(mockDelete).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,11 +1,12 @@
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { isDev } from '@/lib/environment'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { getBaseDomain } from '@/lib/urls/utils'
|
||||
import { getEmailDomain } from '@/lib/urls/utils'
|
||||
import { encryptSecret } from '@/lib/utils'
|
||||
import { checkChatAccess } from '@/app/api/chat/utils'
|
||||
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
|
||||
import { db } from '@/db'
|
||||
import { chat } from '@/db/schema'
|
||||
@@ -57,23 +58,19 @@ export async function GET(_request: NextRequest, { params }: { params: Promise<{
|
||||
return createErrorResponse('Unauthorized', 401)
|
||||
}
|
||||
|
||||
// Get the specific chat deployment
|
||||
const chatInstance = await db
|
||||
.select()
|
||||
.from(chat)
|
||||
.where(and(eq(chat.id, chatId), eq(chat.userId, session.user.id)))
|
||||
.limit(1)
|
||||
// Check if user has access to view this chat
|
||||
const { hasAccess, chat: chatRecord } = await checkChatAccess(chatId, session.user.id)
|
||||
|
||||
if (chatInstance.length === 0) {
|
||||
if (!hasAccess || !chatRecord) {
|
||||
return createErrorResponse('Chat not found or access denied', 404)
|
||||
}
|
||||
|
||||
// Create a new result object without the password
|
||||
const { password, ...safeData } = chatInstance[0]
|
||||
const { password, ...safeData } = chatRecord
|
||||
|
||||
const chatUrl = isDev
|
||||
? `http://${chatInstance[0].subdomain}.${getBaseDomain()}`
|
||||
: `https://${chatInstance[0].subdomain}.simstudio.ai`
|
||||
const baseDomain = getEmailDomain()
|
||||
const protocol = isDev ? 'http' : 'https'
|
||||
const chatUrl = `${protocol}://${chatRecord.subdomain}.${baseDomain}`
|
||||
|
||||
const result = {
|
||||
...safeData,
|
||||
@@ -107,17 +104,15 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
|
||||
try {
|
||||
const validatedData = chatUpdateSchema.parse(body)
|
||||
|
||||
// Verify the chat exists and belongs to the user
|
||||
const existingChat = await db
|
||||
.select()
|
||||
.from(chat)
|
||||
.where(and(eq(chat.id, chatId), eq(chat.userId, session.user.id)))
|
||||
.limit(1)
|
||||
// Check if user has access to edit this chat
|
||||
const { hasAccess, chat: existingChatRecord } = await checkChatAccess(chatId, session.user.id)
|
||||
|
||||
if (existingChat.length === 0) {
|
||||
if (!hasAccess || !existingChatRecord) {
|
||||
return createErrorResponse('Chat not found or access denied', 404)
|
||||
}
|
||||
|
||||
const existingChat = [existingChatRecord] // Keep array format for compatibility
|
||||
|
||||
// Extract validated data
|
||||
const {
|
||||
workflowId,
|
||||
@@ -219,9 +214,9 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
|
||||
|
||||
const updatedSubdomain = subdomain || existingChat[0].subdomain
|
||||
|
||||
const chatUrl = isDev
|
||||
? `http://${updatedSubdomain}.${getBaseDomain()}`
|
||||
: `https://${updatedSubdomain}.simstudio.ai`
|
||||
const baseDomain = getEmailDomain()
|
||||
const protocol = isDev ? 'http' : 'https'
|
||||
const chatUrl = `${protocol}://${updatedSubdomain}.${baseDomain}`
|
||||
|
||||
logger.info(`Chat "${chatId}" updated successfully`)
|
||||
|
||||
@@ -260,14 +255,10 @@ export async function DELETE(
|
||||
return createErrorResponse('Unauthorized', 401)
|
||||
}
|
||||
|
||||
// Verify the chat exists and belongs to the user
|
||||
const existingChat = await db
|
||||
.select()
|
||||
.from(chat)
|
||||
.where(and(eq(chat.id, chatId), eq(chat.userId, session.user.id)))
|
||||
.limit(1)
|
||||
// Check if user has access to delete this chat
|
||||
const { hasAccess } = await checkChatAccess(chatId, session.user.id)
|
||||
|
||||
if (existingChat.length === 0) {
|
||||
if (!hasAccess) {
|
||||
return createErrorResponse('Chat not found or access denied', 404)
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ describe('Chat API Route', () => {
|
||||
const mockCreateSuccessResponse = vi.fn()
|
||||
const mockCreateErrorResponse = vi.fn()
|
||||
const mockEncryptSecret = vi.fn()
|
||||
const mockCheckWorkflowAccessForChatCreation = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
@@ -71,6 +72,10 @@ describe('Chat API Route', () => {
|
||||
vi.doMock('uuid', () => ({
|
||||
v4: vi.fn().mockReturnValue('test-uuid'),
|
||||
}))
|
||||
|
||||
vi.doMock('@/app/api/chat/utils', () => ({
|
||||
checkWorkflowAccessForChatCreation: mockCheckWorkflowAccessForChatCreation,
|
||||
}))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -194,7 +199,7 @@ describe('Chat API Route', () => {
|
||||
expect(mockCreateErrorResponse).toHaveBeenCalledWith('Subdomain already in use', 400)
|
||||
})
|
||||
|
||||
it('should reject if workflow not found or not owned by user', async () => {
|
||||
it('should reject if workflow not found', async () => {
|
||||
vi.doMock('@/lib/auth', () => ({
|
||||
getSession: vi.fn().mockResolvedValue({
|
||||
user: { id: 'user-id' },
|
||||
@@ -212,7 +217,7 @@ describe('Chat API Route', () => {
|
||||
}
|
||||
|
||||
mockLimit.mockResolvedValueOnce([]) // Subdomain is available
|
||||
mockLimit.mockResolvedValueOnce([]) // Workflow not found
|
||||
mockCheckWorkflowAccessForChatCreation.mockResolvedValue({ hasAccess: false })
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/chat', {
|
||||
method: 'POST',
|
||||
@@ -228,6 +233,158 @@ describe('Chat API Route', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('should allow chat deployment when user owns workflow directly', async () => {
|
||||
vi.doMock('@/lib/auth', () => ({
|
||||
getSession: vi.fn().mockResolvedValue({
|
||||
user: { id: 'user-id' },
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.doMock('@/lib/env', () => ({
|
||||
env: {
|
||||
NODE_ENV: 'development',
|
||||
NEXT_PUBLIC_APP_URL: 'http://localhost:3000',
|
||||
},
|
||||
}))
|
||||
|
||||
const validData = {
|
||||
workflowId: 'workflow-123',
|
||||
subdomain: 'test-chat',
|
||||
title: 'Test Chat',
|
||||
customizations: {
|
||||
primaryColor: '#000000',
|
||||
welcomeMessage: 'Hello',
|
||||
},
|
||||
}
|
||||
|
||||
mockLimit.mockResolvedValueOnce([]) // Subdomain is available
|
||||
mockCheckWorkflowAccessForChatCreation.mockResolvedValue({
|
||||
hasAccess: true,
|
||||
workflow: { userId: 'user-id', workspaceId: null, isDeployed: true },
|
||||
})
|
||||
mockReturning.mockResolvedValue([{ id: 'test-uuid' }])
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/chat', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(validData),
|
||||
})
|
||||
const { POST } = await import('./route')
|
||||
const response = await POST(req)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(mockCheckWorkflowAccessForChatCreation).toHaveBeenCalledWith('workflow-123', 'user-id')
|
||||
})
|
||||
|
||||
it('should allow chat deployment when user has workspace admin permission', async () => {
|
||||
vi.doMock('@/lib/auth', () => ({
|
||||
getSession: vi.fn().mockResolvedValue({
|
||||
user: { id: 'user-id' },
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.doMock('@/lib/env', () => ({
|
||||
env: {
|
||||
NODE_ENV: 'development',
|
||||
NEXT_PUBLIC_APP_URL: 'http://localhost:3000',
|
||||
},
|
||||
}))
|
||||
|
||||
const validData = {
|
||||
workflowId: 'workflow-123',
|
||||
subdomain: 'test-chat',
|
||||
title: 'Test Chat',
|
||||
customizations: {
|
||||
primaryColor: '#000000',
|
||||
welcomeMessage: 'Hello',
|
||||
},
|
||||
}
|
||||
|
||||
mockLimit.mockResolvedValueOnce([]) // Subdomain is available
|
||||
mockCheckWorkflowAccessForChatCreation.mockResolvedValue({
|
||||
hasAccess: true,
|
||||
workflow: { userId: 'other-user-id', workspaceId: 'workspace-123', isDeployed: true },
|
||||
})
|
||||
mockReturning.mockResolvedValue([{ id: 'test-uuid' }])
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/chat', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(validData),
|
||||
})
|
||||
const { POST } = await import('./route')
|
||||
const response = await POST(req)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(mockCheckWorkflowAccessForChatCreation).toHaveBeenCalledWith('workflow-123', 'user-id')
|
||||
})
|
||||
|
||||
it('should reject when workflow is in workspace but user lacks admin permission', async () => {
|
||||
vi.doMock('@/lib/auth', () => ({
|
||||
getSession: vi.fn().mockResolvedValue({
|
||||
user: { id: 'user-id' },
|
||||
}),
|
||||
}))
|
||||
|
||||
const validData = {
|
||||
workflowId: 'workflow-123',
|
||||
subdomain: 'test-chat',
|
||||
title: 'Test Chat',
|
||||
customizations: {
|
||||
primaryColor: '#000000',
|
||||
welcomeMessage: 'Hello',
|
||||
},
|
||||
}
|
||||
|
||||
mockLimit.mockResolvedValueOnce([]) // Subdomain is available
|
||||
mockCheckWorkflowAccessForChatCreation.mockResolvedValue({
|
||||
hasAccess: false,
|
||||
})
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/chat', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(validData),
|
||||
})
|
||||
const { POST } = await import('./route')
|
||||
const response = await POST(req)
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
expect(mockCreateErrorResponse).toHaveBeenCalledWith(
|
||||
'Workflow not found or access denied',
|
||||
404
|
||||
)
|
||||
expect(mockCheckWorkflowAccessForChatCreation).toHaveBeenCalledWith('workflow-123', 'user-id')
|
||||
})
|
||||
|
||||
it('should handle workspace permission check errors gracefully', async () => {
|
||||
vi.doMock('@/lib/auth', () => ({
|
||||
getSession: vi.fn().mockResolvedValue({
|
||||
user: { id: 'user-id' },
|
||||
}),
|
||||
}))
|
||||
|
||||
const validData = {
|
||||
workflowId: 'workflow-123',
|
||||
subdomain: 'test-chat',
|
||||
title: 'Test Chat',
|
||||
customizations: {
|
||||
primaryColor: '#000000',
|
||||
welcomeMessage: 'Hello',
|
||||
},
|
||||
}
|
||||
|
||||
mockLimit.mockResolvedValueOnce([]) // Subdomain is available
|
||||
mockCheckWorkflowAccessForChatCreation.mockRejectedValue(new Error('Permission check failed'))
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/chat', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(validData),
|
||||
})
|
||||
const { POST } = await import('./route')
|
||||
const response = await POST(req)
|
||||
|
||||
expect(response.status).toBe(500)
|
||||
expect(mockCheckWorkflowAccessForChatCreation).toHaveBeenCalledWith('workflow-123', 'user-id')
|
||||
})
|
||||
|
||||
it('should reject if workflow is not deployed', async () => {
|
||||
vi.doMock('@/lib/auth', () => ({
|
||||
getSession: vi.fn().mockResolvedValue({
|
||||
@@ -246,7 +403,10 @@ describe('Chat API Route', () => {
|
||||
}
|
||||
|
||||
mockLimit.mockResolvedValueOnce([]) // Subdomain is available
|
||||
mockLimit.mockResolvedValueOnce([{ isDeployed: false }]) // Workflow exists but not deployed
|
||||
mockCheckWorkflowAccessForChatCreation.mockResolvedValue({
|
||||
hasAccess: true,
|
||||
workflow: { userId: 'user-id', workspaceId: null, isDeployed: false },
|
||||
})
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/chat', {
|
||||
method: 'POST',
|
||||
@@ -261,57 +421,5 @@ describe('Chat API Route', () => {
|
||||
400
|
||||
)
|
||||
})
|
||||
|
||||
it('should successfully create a chat deployment', async () => {
|
||||
vi.doMock('@/lib/auth', () => ({
|
||||
getSession: vi.fn().mockResolvedValue({
|
||||
user: { id: 'user-id' },
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.doMock('@/lib/env', () => ({
|
||||
env: {
|
||||
NODE_ENV: 'development',
|
||||
NEXT_PUBLIC_APP_URL: 'http://localhost:3000',
|
||||
},
|
||||
}))
|
||||
|
||||
vi.stubGlobal('process', {
|
||||
...process,
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_ENV: 'development',
|
||||
NEXT_PUBLIC_APP_URL: 'http://localhost:3000',
|
||||
},
|
||||
})
|
||||
|
||||
const validData = {
|
||||
workflowId: 'workflow-123',
|
||||
subdomain: 'test-chat',
|
||||
title: 'Test Chat',
|
||||
customizations: {
|
||||
primaryColor: '#000000',
|
||||
welcomeMessage: 'Hello',
|
||||
},
|
||||
}
|
||||
|
||||
mockLimit.mockResolvedValueOnce([]) // Subdomain is available
|
||||
mockLimit.mockResolvedValueOnce([{ isDeployed: true }]) // Workflow exists and is deployed
|
||||
mockReturning.mockResolvedValue([{ id: 'test-uuid' }])
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/chat', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(validData),
|
||||
})
|
||||
const { POST } = await import('./route')
|
||||
const response = await POST(req)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(mockCreateSuccessResponse).toHaveBeenCalledWith({
|
||||
id: 'test-uuid',
|
||||
chatUrl: 'http://test-chat.localhost:3000',
|
||||
message: 'Chat deployment created successfully',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { z } from 'zod'
|
||||
@@ -7,9 +7,10 @@ import { env } from '@/lib/env'
|
||||
import { isDev } from '@/lib/environment'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { encryptSecret } from '@/lib/utils'
|
||||
import { checkWorkflowAccessForChatCreation } from '@/app/api/chat/utils'
|
||||
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
|
||||
import { db } from '@/db'
|
||||
import { chat, workflow } from '@/db/schema'
|
||||
import { chat } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('ChatAPI')
|
||||
|
||||
@@ -107,19 +108,18 @@ export async function POST(request: NextRequest) {
|
||||
return createErrorResponse('Subdomain already in use', 400)
|
||||
}
|
||||
|
||||
// Verify the workflow exists and belongs to the user
|
||||
const workflowExists = await db
|
||||
.select()
|
||||
.from(workflow)
|
||||
.where(and(eq(workflow.id, workflowId), eq(workflow.userId, session.user.id)))
|
||||
.limit(1)
|
||||
// Check if user has permission to create chat for this workflow
|
||||
const { hasAccess, workflow: workflowRecord } = await checkWorkflowAccessForChatCreation(
|
||||
workflowId,
|
||||
session.user.id
|
||||
)
|
||||
|
||||
if (workflowExists.length === 0) {
|
||||
if (!hasAccess || !workflowRecord) {
|
||||
return createErrorResponse('Workflow not found or access denied', 404)
|
||||
}
|
||||
|
||||
// Verify the workflow is deployed (required for chat deployment)
|
||||
if (!workflowExists[0].isDeployed) {
|
||||
if (!workflowRecord.isDeployed) {
|
||||
return createErrorResponse('Workflow must be deployed before creating a chat', 400)
|
||||
}
|
||||
|
||||
@@ -169,23 +169,28 @@ export async function POST(request: NextRequest) {
|
||||
})
|
||||
|
||||
// Return successful response with chat URL
|
||||
// Check if we're in development or production
|
||||
// Generate chat URL based on the configured base URL
|
||||
const baseUrl = env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
|
||||
|
||||
let chatUrl: string
|
||||
if (isDev) {
|
||||
try {
|
||||
const url = new URL(baseUrl)
|
||||
chatUrl = `${url.protocol}//${subdomain}.${url.host}`
|
||||
} catch (error) {
|
||||
logger.warn('Failed to parse baseUrl, falling back to localhost:', {
|
||||
baseUrl,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
})
|
||||
chatUrl = `http://${subdomain}.localhost:3000`
|
||||
try {
|
||||
const url = new URL(baseUrl)
|
||||
let host = url.host
|
||||
if (host.startsWith('www.')) {
|
||||
host = host.substring(4)
|
||||
}
|
||||
chatUrl = `${url.protocol}//${subdomain}.${host}`
|
||||
} catch (error) {
|
||||
logger.warn('Failed to parse baseUrl, falling back to defaults:', {
|
||||
baseUrl,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
})
|
||||
// Fallback based on environment
|
||||
if (isDev) {
|
||||
chatUrl = `http://${subdomain}.localhost:3000`
|
||||
} else {
|
||||
chatUrl = `https://${subdomain}.simstudio.ai`
|
||||
}
|
||||
} else {
|
||||
chatUrl = `https://${subdomain}.simstudio.ai`
|
||||
}
|
||||
|
||||
logger.info(`Chat "${title}" deployed successfully at ${chatUrl}`)
|
||||
|
||||
@@ -7,7 +7,6 @@ import type { NextResponse } from 'next/server'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { env } from '@/lib/env'
|
||||
|
||||
// Mock all the problematic imports that cause timeouts
|
||||
vi.mock('@/db', () => ({
|
||||
db: {
|
||||
select: vi.fn(),
|
||||
|
||||
@@ -5,7 +5,9 @@ import { isDev } from '@/lib/environment'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { EnhancedLoggingSession } from '@/lib/logs/enhanced-logging-session'
|
||||
import { buildTraceSpans } from '@/lib/logs/trace-spans'
|
||||
import { hasAdminPermission } from '@/lib/permissions/utils'
|
||||
import { processStreamingBlockLogs } from '@/lib/tokenization'
|
||||
import { getEmailDomain } from '@/lib/urls/utils'
|
||||
import { decryptSecret } from '@/lib/utils'
|
||||
import { db } from '@/db'
|
||||
import { chat, environment as envTable, userStats, workflow } from '@/db/schema'
|
||||
@@ -21,6 +23,80 @@ declare global {
|
||||
|
||||
const logger = createLogger('ChatAuthUtils')
|
||||
|
||||
/**
|
||||
* Check if user has permission to create a chat for a specific workflow
|
||||
* Either the user owns the workflow directly OR has admin permission for the workflow's workspace
|
||||
*/
|
||||
export async function checkWorkflowAccessForChatCreation(
|
||||
workflowId: string,
|
||||
userId: string
|
||||
): Promise<{ hasAccess: boolean; workflow?: any }> {
|
||||
// Get workflow data
|
||||
const workflowData = await db.select().from(workflow).where(eq(workflow.id, workflowId)).limit(1)
|
||||
|
||||
if (workflowData.length === 0) {
|
||||
return { hasAccess: false }
|
||||
}
|
||||
|
||||
const workflowRecord = workflowData[0]
|
||||
|
||||
// Case 1: User owns the workflow directly
|
||||
if (workflowRecord.userId === userId) {
|
||||
return { hasAccess: true, workflow: workflowRecord }
|
||||
}
|
||||
|
||||
// Case 2: Workflow belongs to a workspace and user has admin permission
|
||||
if (workflowRecord.workspaceId) {
|
||||
const hasAdmin = await hasAdminPermission(userId, workflowRecord.workspaceId)
|
||||
if (hasAdmin) {
|
||||
return { hasAccess: true, workflow: workflowRecord }
|
||||
}
|
||||
}
|
||||
|
||||
return { hasAccess: false }
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has access to view/edit/delete a specific chat
|
||||
* Either the user owns the chat directly OR has admin permission for the workflow's workspace
|
||||
*/
|
||||
export async function checkChatAccess(
|
||||
chatId: string,
|
||||
userId: string
|
||||
): Promise<{ hasAccess: boolean; chat?: any }> {
|
||||
// Get chat with workflow information
|
||||
const chatData = await db
|
||||
.select({
|
||||
chat: chat,
|
||||
workflowWorkspaceId: workflow.workspaceId,
|
||||
})
|
||||
.from(chat)
|
||||
.innerJoin(workflow, eq(chat.workflowId, workflow.id))
|
||||
.where(eq(chat.id, chatId))
|
||||
.limit(1)
|
||||
|
||||
if (chatData.length === 0) {
|
||||
return { hasAccess: false }
|
||||
}
|
||||
|
||||
const { chat: chatRecord, workflowWorkspaceId } = chatData[0]
|
||||
|
||||
// Case 1: User owns the chat directly
|
||||
if (chatRecord.userId === userId) {
|
||||
return { hasAccess: true, chat: chatRecord }
|
||||
}
|
||||
|
||||
// Case 2: Chat's workflow belongs to a workspace and user has admin permission
|
||||
if (workflowWorkspaceId) {
|
||||
const hasAdmin = await hasAdminPermission(userId, workflowWorkspaceId)
|
||||
if (hasAdmin) {
|
||||
return { hasAccess: true, chat: chatRecord }
|
||||
}
|
||||
}
|
||||
|
||||
return { hasAccess: false }
|
||||
}
|
||||
|
||||
export const encryptAuthToken = (subdomainId: string, type: string): string => {
|
||||
return Buffer.from(`${subdomainId}:${type}:${Date.now()}`).toString('base64')
|
||||
}
|
||||
@@ -66,7 +142,7 @@ export const setChatAuthCookie = (
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
// Using subdomain for the domain in production
|
||||
domain: isDev ? undefined : '.simstudio.ai',
|
||||
domain: isDev ? undefined : `.${getEmailDomain()}`,
|
||||
maxAge: 60 * 60 * 24, // 24 hours
|
||||
})
|
||||
}
|
||||
|
||||
138
apps/sim/app/api/copilot/checkpoints/[id]/revert/route.ts
Normal file
138
apps/sim/app/api/copilot/checkpoints/[id]/revert/route.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { db } from '@/db'
|
||||
import { copilotCheckpoints, workflow as workflowTable } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('RevertCheckpointAPI')
|
||||
|
||||
/**
|
||||
* POST /api/copilot/checkpoints/[id]/revert
|
||||
* Revert workflow to a specific checkpoint
|
||||
*/
|
||||
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const requestId = crypto.randomUUID().slice(0, 8)
|
||||
const checkpointId = (await params).id
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Reverting to checkpoint: ${checkpointId}`, {
|
||||
userId: session.user.id,
|
||||
})
|
||||
|
||||
// Get the checkpoint
|
||||
const checkpoint = await db
|
||||
.select()
|
||||
.from(copilotCheckpoints)
|
||||
.where(
|
||||
and(eq(copilotCheckpoints.id, checkpointId), eq(copilotCheckpoints.userId, session.user.id))
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!checkpoint.length) {
|
||||
return NextResponse.json({ error: 'Checkpoint not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const checkpointData = checkpoint[0]
|
||||
const { workflowId, yaml: yamlContent } = checkpointData
|
||||
|
||||
logger.info(`[${requestId}] Processing checkpoint revert`, {
|
||||
workflowId,
|
||||
yamlLength: yamlContent.length,
|
||||
})
|
||||
|
||||
// Use the consolidated YAML endpoint instead of duplicating the processing logic
|
||||
const yamlEndpointUrl = `${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/api/workflows/${workflowId}/yaml`
|
||||
|
||||
const yamlResponse = await fetch(yamlEndpointUrl, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
// Forward auth cookies from the original request
|
||||
Cookie: request.headers.get('Cookie') || '',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
yamlContent,
|
||||
description: `Reverted to checkpoint from ${new Date(checkpointData.createdAt).toLocaleString()}`,
|
||||
source: 'checkpoint_revert',
|
||||
applyAutoLayout: true,
|
||||
createCheckpoint: false, // Don't create a checkpoint when reverting to one
|
||||
}),
|
||||
})
|
||||
|
||||
if (!yamlResponse.ok) {
|
||||
const errorData = await yamlResponse.json()
|
||||
logger.error(`[${requestId}] Consolidated YAML endpoint failed:`, errorData)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Failed to revert checkpoint via YAML endpoint',
|
||||
details: errorData.errors || [errorData.error || 'Unknown error'],
|
||||
},
|
||||
{ status: yamlResponse.status }
|
||||
)
|
||||
}
|
||||
|
||||
const yamlResult = await yamlResponse.json()
|
||||
|
||||
if (!yamlResult.success) {
|
||||
logger.error(`[${requestId}] YAML endpoint returned failure:`, yamlResult)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Failed to process checkpoint YAML',
|
||||
details: yamlResult.errors || ['Unknown error'],
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Update workflow's lastSynced timestamp
|
||||
await db
|
||||
.update(workflowTable)
|
||||
.set({
|
||||
lastSynced: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(workflowTable.id, workflowId))
|
||||
|
||||
// Notify the socket server to tell clients to rehydrate stores from database
|
||||
try {
|
||||
const socketUrl = process.env.SOCKET_URL || 'http://localhost:3002'
|
||||
await fetch(`${socketUrl}/api/copilot-workflow-edit`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
workflowId,
|
||||
description: `Reverted to checkpoint from ${new Date(checkpointData.createdAt).toLocaleString()}`,
|
||||
}),
|
||||
})
|
||||
logger.info(`[${requestId}] Notified socket server of checkpoint revert`)
|
||||
} catch (socketError) {
|
||||
logger.warn(`[${requestId}] Failed to notify socket server:`, socketError)
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Successfully reverted to checkpoint`)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Successfully reverted to checkpoint from ${new Date(checkpointData.createdAt).toLocaleString()}`,
|
||||
summary: yamlResult.summary || `Restored workflow from checkpoint.`,
|
||||
warnings: yamlResult.warnings || [],
|
||||
data: yamlResult.data,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error reverting checkpoint:`, error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Failed to revert checkpoint: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
64
apps/sim/app/api/copilot/checkpoints/route.ts
Normal file
64
apps/sim/app/api/copilot/checkpoints/route.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { and, desc, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { db } from '@/db'
|
||||
import { copilotCheckpoints } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('CopilotCheckpointsAPI')
|
||||
|
||||
/**
|
||||
* GET /api/copilot/checkpoints
|
||||
* List checkpoints for a specific chat
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
const requestId = crypto.randomUUID()
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const chatId = searchParams.get('chatId')
|
||||
const limit = Number(searchParams.get('limit')) || 10
|
||||
const offset = Number(searchParams.get('offset')) || 0
|
||||
|
||||
if (!chatId) {
|
||||
return NextResponse.json({ error: 'chatId is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Listing checkpoints for chat: ${chatId}`, {
|
||||
userId: session.user.id,
|
||||
limit,
|
||||
offset,
|
||||
})
|
||||
|
||||
const checkpoints = await db
|
||||
.select()
|
||||
.from(copilotCheckpoints)
|
||||
.where(
|
||||
and(eq(copilotCheckpoints.userId, session.user.id), eq(copilotCheckpoints.chatId, chatId))
|
||||
)
|
||||
.orderBy(desc(copilotCheckpoints.createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
|
||||
// Format timestamps to ISO strings for consistent timezone handling
|
||||
const formattedCheckpoints = checkpoints.map((checkpoint) => ({
|
||||
id: checkpoint.id,
|
||||
userId: checkpoint.userId,
|
||||
workflowId: checkpoint.workflowId,
|
||||
chatId: checkpoint.chatId,
|
||||
yaml: checkpoint.yaml,
|
||||
createdAt: checkpoint.createdAt.toISOString(),
|
||||
updatedAt: checkpoint.updatedAt.toISOString(),
|
||||
}))
|
||||
|
||||
return NextResponse.json({ checkpoints: formattedCheckpoints })
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error listing checkpoints:`, error)
|
||||
return NextResponse.json({ error: 'Failed to list checkpoints' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -1,281 +0,0 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import {
|
||||
type CopilotChat,
|
||||
type CopilotMessage,
|
||||
createChat,
|
||||
generateChatTitle,
|
||||
generateDocsResponse,
|
||||
getChat,
|
||||
updateChat,
|
||||
} from '@/lib/copilot/service'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
|
||||
const logger = createLogger('CopilotDocsAPI')
|
||||
|
||||
// Schema for docs queries
|
||||
const DocsQuerySchema = z.object({
|
||||
query: z.string().min(1, 'Query is required'),
|
||||
topK: z.number().min(1).max(20).default(5),
|
||||
provider: z.string().optional(),
|
||||
model: z.string().optional(),
|
||||
stream: z.boolean().optional().default(false),
|
||||
chatId: z.string().optional(),
|
||||
workflowId: z.string().optional(),
|
||||
createNewChat: z.boolean().optional().default(false),
|
||||
})
|
||||
|
||||
/**
|
||||
* POST /api/copilot/docs
|
||||
* Ask questions about documentation using RAG
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
const requestId = crypto.randomUUID()
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
const { query, topK, provider, model, stream, chatId, workflowId, createNewChat } =
|
||||
DocsQuerySchema.parse(body)
|
||||
|
||||
logger.info(`[${requestId}] Docs RAG query: "${query}"`, {
|
||||
provider,
|
||||
model,
|
||||
topK,
|
||||
chatId,
|
||||
workflowId,
|
||||
createNewChat,
|
||||
userId: session.user.id,
|
||||
})
|
||||
|
||||
// Handle chat context
|
||||
let currentChat: CopilotChat | null = null
|
||||
let conversationHistory: CopilotMessage[] = []
|
||||
|
||||
if (chatId) {
|
||||
// Load existing chat
|
||||
currentChat = await getChat(chatId, session.user.id)
|
||||
if (currentChat) {
|
||||
conversationHistory = currentChat.messages
|
||||
}
|
||||
} else if (createNewChat && workflowId) {
|
||||
// Create new chat
|
||||
currentChat = await createChat(session.user.id, workflowId)
|
||||
}
|
||||
|
||||
// Generate docs response
|
||||
const result = await generateDocsResponse(query, conversationHistory, {
|
||||
topK,
|
||||
provider,
|
||||
model,
|
||||
stream,
|
||||
workflowId,
|
||||
requestId,
|
||||
})
|
||||
|
||||
if (stream && result.response instanceof ReadableStream) {
|
||||
// Handle streaming response with docs sources
|
||||
logger.info(`[${requestId}] Returning streaming docs response`)
|
||||
|
||||
const encoder = new TextEncoder()
|
||||
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
async start(controller) {
|
||||
const reader = (result.response as ReadableStream).getReader()
|
||||
let accumulatedResponse = ''
|
||||
|
||||
try {
|
||||
// Send initial metadata including sources
|
||||
const metadata = {
|
||||
type: 'metadata',
|
||||
chatId: currentChat?.id,
|
||||
sources: result.sources,
|
||||
citations: result.sources.map((source, index) => ({
|
||||
id: index + 1,
|
||||
title: source.title,
|
||||
url: source.url,
|
||||
})),
|
||||
metadata: {
|
||||
requestId,
|
||||
chunksFound: result.sources.length,
|
||||
query,
|
||||
topSimilarity: result.sources[0]?.similarity,
|
||||
provider,
|
||||
model,
|
||||
},
|
||||
}
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(metadata)}\n\n`))
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
const chunk = new TextDecoder().decode(value)
|
||||
// Clean up any object serialization artifacts in streaming content
|
||||
const cleanedChunk = chunk.replace(/\[object Object\],?/g, '')
|
||||
accumulatedResponse += cleanedChunk
|
||||
|
||||
const contentChunk = {
|
||||
type: 'content',
|
||||
content: cleanedChunk,
|
||||
}
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(contentChunk)}\n\n`))
|
||||
}
|
||||
|
||||
// Send completion marker first to unblock the user
|
||||
controller.enqueue(encoder.encode(`data: {"type":"done"}\n\n`))
|
||||
|
||||
// Save conversation to database asynchronously (non-blocking)
|
||||
if (currentChat) {
|
||||
// Fire-and-forget database save to avoid blocking stream completion
|
||||
Promise.resolve()
|
||||
.then(async () => {
|
||||
try {
|
||||
const userMessage: CopilotMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
role: 'user',
|
||||
content: query,
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
|
||||
const assistantMessage: CopilotMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
role: 'assistant',
|
||||
content: accumulatedResponse,
|
||||
timestamp: new Date().toISOString(),
|
||||
citations: result.sources.map((source, index) => ({
|
||||
id: index + 1,
|
||||
title: source.title,
|
||||
url: source.url,
|
||||
})),
|
||||
}
|
||||
|
||||
const updatedMessages = [
|
||||
...conversationHistory,
|
||||
userMessage,
|
||||
assistantMessage,
|
||||
]
|
||||
|
||||
// Generate title if this is the first message
|
||||
let updatedTitle = currentChat.title ?? undefined
|
||||
if (!updatedTitle && conversationHistory.length === 0) {
|
||||
updatedTitle = await generateChatTitle(query)
|
||||
}
|
||||
|
||||
// Update the chat in database
|
||||
await updateChat(currentChat.id, session.user.id, {
|
||||
title: updatedTitle,
|
||||
messages: updatedMessages,
|
||||
})
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Updated chat ${currentChat.id} with new docs messages`
|
||||
)
|
||||
} catch (dbError) {
|
||||
logger.error(`[${requestId}] Failed to save chat to database:`, dbError)
|
||||
// Database errors don't affect the user's streaming experience
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error(`[${requestId}] Unexpected error in async database save:`, error)
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Docs streaming error:`, error)
|
||||
try {
|
||||
const errorChunk = {
|
||||
type: 'error',
|
||||
error: 'Streaming failed',
|
||||
}
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(errorChunk)}\n\n`))
|
||||
} catch (enqueueError) {
|
||||
logger.error(`[${requestId}] Failed to enqueue error response:`, enqueueError)
|
||||
}
|
||||
} finally {
|
||||
controller.close()
|
||||
}
|
||||
},
|
||||
}),
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Handle non-streaming response
|
||||
logger.info(`[${requestId}] Docs RAG response generated successfully`)
|
||||
|
||||
// Save conversation to database if we have a chat
|
||||
if (currentChat) {
|
||||
const userMessage: CopilotMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
role: 'user',
|
||||
content: query,
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
|
||||
const assistantMessage: CopilotMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
role: 'assistant',
|
||||
content: typeof result.response === 'string' ? result.response : '[Streaming Response]',
|
||||
timestamp: new Date().toISOString(),
|
||||
citations: result.sources.map((source, index) => ({
|
||||
id: index + 1,
|
||||
title: source.title,
|
||||
url: source.url,
|
||||
})),
|
||||
}
|
||||
|
||||
const updatedMessages = [...conversationHistory, userMessage, assistantMessage]
|
||||
|
||||
// Generate title if this is the first message
|
||||
let updatedTitle = currentChat.title ?? undefined
|
||||
if (!updatedTitle && conversationHistory.length === 0) {
|
||||
updatedTitle = await generateChatTitle(query)
|
||||
}
|
||||
|
||||
// Update the chat in database
|
||||
await updateChat(currentChat.id, session.user.id, {
|
||||
title: updatedTitle,
|
||||
messages: updatedMessages,
|
||||
})
|
||||
|
||||
logger.info(`[${requestId}] Updated chat ${currentChat.id} with new docs messages`)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
response: result.response,
|
||||
sources: result.sources,
|
||||
chatId: currentChat?.id,
|
||||
metadata: {
|
||||
requestId,
|
||||
chunksFound: result.sources.length,
|
||||
query,
|
||||
topSimilarity: result.sources[0]?.similarity,
|
||||
provider,
|
||||
model,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid request data', details: error.errors },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Copilot docs error:`, error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ const SendMessageSchema = z.object({
|
||||
message: z.string().min(1, 'Message is required'),
|
||||
chatId: z.string().optional(),
|
||||
workflowId: z.string().optional(),
|
||||
mode: z.enum(['ask', 'agent']).optional().default('ask'),
|
||||
createNewChat: z.boolean().optional().default(false),
|
||||
stream: z.boolean().optional().default(false),
|
||||
})
|
||||
@@ -90,7 +91,8 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
try {
|
||||
const body = await req.json()
|
||||
const { message, chatId, workflowId, createNewChat, stream } = SendMessageSchema.parse(body)
|
||||
const { message, chatId, workflowId, mode, createNewChat, stream } =
|
||||
SendMessageSchema.parse(body)
|
||||
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
@@ -100,6 +102,7 @@ export async function POST(req: NextRequest) {
|
||||
logger.info(`[${requestId}] Copilot message: "${message}"`, {
|
||||
chatId,
|
||||
workflowId,
|
||||
mode,
|
||||
createNewChat,
|
||||
stream,
|
||||
userId: session.user.id,
|
||||
@@ -110,6 +113,7 @@ export async function POST(req: NextRequest) {
|
||||
message,
|
||||
chatId,
|
||||
workflowId,
|
||||
mode,
|
||||
createNewChat,
|
||||
stream,
|
||||
userId: session.user.id,
|
||||
|
||||
@@ -36,7 +36,7 @@ export async function POST(
|
||||
): Promise<NextResponse<DocsSearchSuccessResponse | DocsSearchErrorResponse>> {
|
||||
try {
|
||||
const requestBody: DocsSearchRequest = await request.json()
|
||||
const { query, topK = 5 } = requestBody
|
||||
const { query, topK = 10 } = requestBody
|
||||
|
||||
if (!query) {
|
||||
const errorResponse: DocsSearchErrorResponse = {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { db } from '@/db'
|
||||
import { workflow, workflowExecutionBlocks, workflowExecutionLogs } from '@/db/schema'
|
||||
import { permissions, workflow, workflowExecutionBlocks, workflowExecutionLogs } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('EnhancedLogsAPI')
|
||||
|
||||
@@ -74,11 +74,24 @@ export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const params = QueryParamsSchema.parse(Object.fromEntries(searchParams.entries()))
|
||||
|
||||
// Get user's workflows
|
||||
// Get workflows that user can access through direct ownership OR workspace permissions
|
||||
const userWorkflows = await db
|
||||
.select({ id: workflow.id, folderId: workflow.folderId })
|
||||
.from(workflow)
|
||||
.where(eq(workflow.userId, userId))
|
||||
.leftJoin(
|
||||
permissions,
|
||||
and(
|
||||
eq(permissions.entityType, 'workspace'),
|
||||
eq(permissions.entityId, workflow.workspaceId),
|
||||
eq(permissions.userId, userId)
|
||||
)
|
||||
)
|
||||
.where(
|
||||
or(
|
||||
eq(workflow.userId, userId),
|
||||
and(eq(permissions.userId, userId), eq(permissions.entityType, 'workspace'))
|
||||
)
|
||||
)
|
||||
|
||||
const userWorkflowIds = userWorkflows.map((w) => w.id)
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { getConfluenceCloudId } from '@/tools/confluence/utils'
|
||||
|
||||
const logger = createLogger('ConfluencePages')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export async function POST(request: Request) {
|
||||
@@ -39,7 +42,7 @@ export async function POST(request: Request) {
|
||||
const queryString = queryParams.toString()
|
||||
const url = queryString ? `${baseUrl}?${queryString}` : baseUrl
|
||||
|
||||
console.log(`Fetching Confluence pages from: ${url}`)
|
||||
logger.info(`Fetching Confluence pages from: ${url}`)
|
||||
|
||||
// Make the request to Confluence API with OAuth Bearer token
|
||||
const response = await fetch(url, {
|
||||
@@ -50,23 +53,23 @@ export async function POST(request: Request) {
|
||||
},
|
||||
})
|
||||
|
||||
console.log('Response status:', response.status, response.statusText)
|
||||
logger.info('Response status:', response.status, response.statusText)
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`Confluence API error: ${response.status} ${response.statusText}`)
|
||||
logger.error(`Confluence API error: ${response.status} ${response.statusText}`)
|
||||
let errorMessage
|
||||
|
||||
try {
|
||||
const errorData = await response.json()
|
||||
console.error('Error details:', JSON.stringify(errorData, null, 2))
|
||||
logger.error('Error details:', JSON.stringify(errorData, null, 2))
|
||||
errorMessage = errorData.message || `Failed to fetch Confluence pages (${response.status})`
|
||||
} catch (e) {
|
||||
console.error('Could not parse error response as JSON:', e)
|
||||
logger.error('Could not parse error response as JSON:', e)
|
||||
|
||||
// Try to get the response text for more context
|
||||
try {
|
||||
const text = await response.text()
|
||||
console.error('Response text:', text)
|
||||
logger.error('Response text:', text)
|
||||
errorMessage = `Failed to fetch Confluence pages: ${response.status} ${response.statusText}`
|
||||
} catch (_textError) {
|
||||
errorMessage = `Failed to fetch Confluence pages: ${response.status} ${response.statusText}`
|
||||
@@ -77,13 +80,13 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
console.log('Confluence API response:', `${JSON.stringify(data, null, 2).substring(0, 300)}...`)
|
||||
console.log(`Found ${data.results?.length || 0} pages`)
|
||||
logger.info('Confluence API response:', `${JSON.stringify(data, null, 2).substring(0, 300)}...`)
|
||||
logger.info(`Found ${data.results?.length || 0} pages`)
|
||||
|
||||
if (data.results && data.results.length > 0) {
|
||||
console.log('First few pages:')
|
||||
logger.info('First few pages:')
|
||||
for (const page of data.results.slice(0, 3)) {
|
||||
console.log(`- ${page.id}: ${page.title}`)
|
||||
logger.info(`- ${page.id}: ${page.title}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,7 +102,7 @@ export async function POST(request: Request) {
|
||||
})),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error fetching Confluence pages:', error)
|
||||
logger.error('Error fetching Confluence pages:', error)
|
||||
return NextResponse.json(
|
||||
{ error: (error as Error).message || 'Internal server error' },
|
||||
{ status: 500 }
|
||||
|
||||
412
apps/sim/app/api/tools/edit-workflow/route.ts
Normal file
412
apps/sim/app/api/tools/edit-workflow/route.ts
Normal file
@@ -0,0 +1,412 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { autoLayoutWorkflow } from '@/lib/autolayout/service'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import {
|
||||
loadWorkflowFromNormalizedTables,
|
||||
saveWorkflowToNormalizedTables,
|
||||
} from '@/lib/workflows/db-helpers'
|
||||
import { generateWorkflowYaml } from '@/lib/workflows/yaml-generator'
|
||||
import { getUserId } from '@/app/api/auth/oauth/utils'
|
||||
import { getBlock } from '@/blocks'
|
||||
import { db } from '@/db'
|
||||
import { copilotCheckpoints, workflow as workflowTable } from '@/db/schema'
|
||||
import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils'
|
||||
import { convertYamlToWorkflow, parseWorkflowYaml } from '@/stores/workflows/yaml/importer'
|
||||
|
||||
const logger = createLogger('EditWorkflowAPI')
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const requestId = crypto.randomUUID().slice(0, 8)
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { yamlContent, workflowId, description, chatId } = body
|
||||
|
||||
if (!yamlContent) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'yamlContent is required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!workflowId) {
|
||||
return NextResponse.json({ success: false, error: 'workflowId is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Processing workflow edit request`, {
|
||||
workflowId,
|
||||
yamlLength: yamlContent.length,
|
||||
hasDescription: !!description,
|
||||
hasChatId: !!chatId,
|
||||
})
|
||||
|
||||
// Log the full YAML content for debugging
|
||||
logger.info(`[${requestId}] Full YAML content from copilot:`)
|
||||
logger.info('='.repeat(80))
|
||||
logger.info(yamlContent)
|
||||
logger.info('='.repeat(80))
|
||||
|
||||
// Get the user ID for checkpoint creation
|
||||
const userId = await getUserId(requestId, workflowId)
|
||||
if (!userId) {
|
||||
return NextResponse.json({ success: false, error: 'User not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Create checkpoint before making changes (only if chatId is provided)
|
||||
if (chatId) {
|
||||
try {
|
||||
logger.info(`[${requestId}] Creating checkpoint before workflow edit`)
|
||||
|
||||
// Get current workflow state
|
||||
const currentWorkflowData = await loadWorkflowFromNormalizedTables(workflowId)
|
||||
|
||||
if (currentWorkflowData) {
|
||||
// Generate YAML from current state
|
||||
const currentYaml = generateWorkflowYaml(currentWorkflowData)
|
||||
|
||||
// Create checkpoint
|
||||
await db.insert(copilotCheckpoints).values({
|
||||
userId,
|
||||
workflowId,
|
||||
chatId,
|
||||
yaml: currentYaml,
|
||||
})
|
||||
|
||||
logger.info(`[${requestId}] Checkpoint created successfully`)
|
||||
} else {
|
||||
logger.warn(`[${requestId}] Could not load current workflow state for checkpoint`)
|
||||
}
|
||||
} catch (checkpointError) {
|
||||
logger.error(`[${requestId}] Failed to create checkpoint:`, checkpointError)
|
||||
// Continue with workflow edit even if checkpoint fails
|
||||
}
|
||||
}
|
||||
|
||||
// Parse YAML content server-side
|
||||
const { data: yamlWorkflow, errors: parseErrors } = parseWorkflowYaml(yamlContent)
|
||||
|
||||
if (!yamlWorkflow || parseErrors.length > 0) {
|
||||
logger.error('[edit-workflow] YAML parsing failed', { parseErrors })
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
success: false,
|
||||
message: 'Failed to parse YAML workflow',
|
||||
errors: parseErrors,
|
||||
warnings: [],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Convert YAML to workflow format
|
||||
const { blocks, edges, errors: convertErrors, warnings } = convertYamlToWorkflow(yamlWorkflow)
|
||||
|
||||
if (convertErrors.length > 0) {
|
||||
logger.error('[edit-workflow] YAML conversion failed', { convertErrors })
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
success: false,
|
||||
message: 'Failed to convert YAML to workflow',
|
||||
errors: convertErrors,
|
||||
warnings,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Create workflow state (same format as applyWorkflowDiff)
|
||||
const newWorkflowState: any = {
|
||||
blocks: {} as Record<string, any>,
|
||||
edges: [] as any[],
|
||||
loops: {} as Record<string, any>,
|
||||
parallels: {} as Record<string, any>,
|
||||
lastSaved: Date.now(),
|
||||
isDeployed: false,
|
||||
deployedAt: undefined,
|
||||
deploymentStatuses: {} as Record<string, any>,
|
||||
hasActiveSchedule: false,
|
||||
hasActiveWebhook: false,
|
||||
}
|
||||
|
||||
// Process blocks and assign new IDs (complete replacement)
|
||||
const blockIdMapping = new Map<string, string>()
|
||||
|
||||
for (const block of blocks) {
|
||||
const newId = crypto.randomUUID()
|
||||
blockIdMapping.set(block.id, newId)
|
||||
|
||||
// Get block configuration to set proper defaults
|
||||
const blockConfig = getBlock(block.type)
|
||||
const subBlocks: Record<string, any> = {}
|
||||
const outputs: Record<string, any> = {}
|
||||
|
||||
// Set up subBlocks from block configuration
|
||||
if (blockConfig?.subBlocks) {
|
||||
blockConfig.subBlocks.forEach((subBlock) => {
|
||||
subBlocks[subBlock.id] = {
|
||||
id: subBlock.id,
|
||||
type: subBlock.type,
|
||||
value: null,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Set up outputs from block configuration
|
||||
if (blockConfig?.outputs) {
|
||||
if (Array.isArray(blockConfig.outputs)) {
|
||||
blockConfig.outputs.forEach((output) => {
|
||||
outputs[output.id] = { type: output.type }
|
||||
})
|
||||
} else if (typeof blockConfig.outputs === 'object') {
|
||||
Object.assign(outputs, blockConfig.outputs)
|
||||
}
|
||||
}
|
||||
|
||||
newWorkflowState.blocks[newId] = {
|
||||
id: newId,
|
||||
type: block.type,
|
||||
name: block.name,
|
||||
position: block.position,
|
||||
subBlocks,
|
||||
outputs,
|
||||
enabled: true,
|
||||
horizontalHandles: true,
|
||||
isWide: false,
|
||||
height: 0,
|
||||
data: block.data || {},
|
||||
}
|
||||
|
||||
// Set input values as subblock values with block reference mapping
|
||||
if (block.inputs && typeof block.inputs === 'object') {
|
||||
Object.entries(block.inputs).forEach(([key, value]) => {
|
||||
if (newWorkflowState.blocks[newId].subBlocks[key]) {
|
||||
// Update block references in values to use new mapped IDs
|
||||
let processedValue = value
|
||||
if (typeof value === 'string' && value.includes('<') && value.includes('>')) {
|
||||
// Update block references to use new mapped IDs
|
||||
const blockMatches = value.match(/<([^>]+)>/g)
|
||||
if (blockMatches) {
|
||||
for (const match of blockMatches) {
|
||||
const path = match.slice(1, -1)
|
||||
const [blockRef] = path.split('.')
|
||||
|
||||
// Skip system references (start, loop, parallel, variable)
|
||||
if (['start', 'loop', 'parallel', 'variable'].includes(blockRef.toLowerCase())) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if this references an old block ID that needs mapping
|
||||
const newMappedId = blockIdMapping.get(blockRef)
|
||||
if (newMappedId) {
|
||||
logger.info(
|
||||
`[${requestId}] Updating block reference: ${blockRef} -> ${newMappedId}`
|
||||
)
|
||||
processedValue = processedValue.replace(
|
||||
new RegExp(`<${blockRef}\\.`, 'g'),
|
||||
`<${newMappedId}.`
|
||||
)
|
||||
processedValue = processedValue.replace(
|
||||
new RegExp(`<${blockRef}>`, 'g'),
|
||||
`<${newMappedId}>`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
newWorkflowState.blocks[newId].subBlocks[key].value = processedValue
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Update parent-child relationships with mapped IDs
|
||||
logger.info(`[${requestId}] Block ID mapping:`, Object.fromEntries(blockIdMapping))
|
||||
for (const [newId, blockData] of Object.entries(newWorkflowState.blocks)) {
|
||||
const block = blockData as any
|
||||
if (block.data?.parentId) {
|
||||
logger.info(
|
||||
`[${requestId}] Found child block ${block.name} with parentId: ${block.data.parentId}`
|
||||
)
|
||||
const mappedParentId = blockIdMapping.get(block.data.parentId)
|
||||
if (mappedParentId) {
|
||||
logger.info(
|
||||
`[${requestId}] Updating parent reference: ${block.data.parentId} -> ${mappedParentId}`
|
||||
)
|
||||
block.data.parentId = mappedParentId
|
||||
// Ensure extent is set for child blocks
|
||||
if (!block.data.extent) {
|
||||
block.data.extent = 'parent'
|
||||
}
|
||||
} else {
|
||||
logger.error(
|
||||
`[${requestId}] ❌ Parent block not found for mapping: ${block.data.parentId}`
|
||||
)
|
||||
logger.error(`[${requestId}] Available mappings:`, Array.from(blockIdMapping.keys()))
|
||||
// Remove invalid parent reference
|
||||
block.data.parentId = undefined
|
||||
block.data.extent = undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process edges with mapped IDs
|
||||
for (const edge of edges) {
|
||||
const sourceId = blockIdMapping.get(edge.source)
|
||||
const targetId = blockIdMapping.get(edge.target)
|
||||
|
||||
if (sourceId && targetId) {
|
||||
newWorkflowState.edges.push({
|
||||
id: crypto.randomUUID(),
|
||||
source: sourceId,
|
||||
target: targetId,
|
||||
sourceHandle: edge.sourceHandle,
|
||||
targetHandle: edge.targetHandle,
|
||||
type: edge.type || 'default',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Generate loop and parallel configurations from the imported blocks
|
||||
const loops = generateLoopBlocks(newWorkflowState.blocks)
|
||||
const parallels = generateParallelBlocks(newWorkflowState.blocks)
|
||||
|
||||
// Update workflow state with generated configurations
|
||||
newWorkflowState.loops = loops
|
||||
newWorkflowState.parallels = parallels
|
||||
|
||||
logger.info(`[${requestId}] Generated loop and parallel configurations`, {
|
||||
loopsCount: Object.keys(loops).length,
|
||||
parallelsCount: Object.keys(parallels).length,
|
||||
loopIds: Object.keys(loops),
|
||||
parallelIds: Object.keys(parallels),
|
||||
})
|
||||
|
||||
// Apply intelligent autolayout to optimize block positions
|
||||
try {
|
||||
logger.info(
|
||||
`[${requestId}] Applying autolayout to ${Object.keys(newWorkflowState.blocks).length} blocks`
|
||||
)
|
||||
|
||||
const layoutedBlocks = await autoLayoutWorkflow(
|
||||
newWorkflowState.blocks,
|
||||
newWorkflowState.edges,
|
||||
{
|
||||
strategy: 'smart',
|
||||
direction: 'auto',
|
||||
spacing: {
|
||||
horizontal: 400,
|
||||
vertical: 200,
|
||||
layer: 600,
|
||||
},
|
||||
alignment: 'center',
|
||||
padding: {
|
||||
x: 200,
|
||||
y: 200,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// Update workflow state with optimized positions
|
||||
newWorkflowState.blocks = layoutedBlocks
|
||||
|
||||
logger.info(`[${requestId}] Autolayout completed successfully`)
|
||||
} catch (layoutError) {
|
||||
// Log the error but don't fail the entire workflow save
|
||||
logger.warn(`[${requestId}] Autolayout failed, using original positions:`, layoutError)
|
||||
}
|
||||
|
||||
// Save directly to database using the same function as the workflow state API
|
||||
const saveResult = await saveWorkflowToNormalizedTables(workflowId, newWorkflowState)
|
||||
|
||||
if (!saveResult.success) {
|
||||
logger.error('[edit-workflow] Failed to save workflow state:', saveResult.error)
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
success: false,
|
||||
message: `Database save failed: ${saveResult.error || 'Unknown error'}`,
|
||||
errors: [saveResult.error || 'Database save failed'],
|
||||
warnings,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Update workflow's lastSynced timestamp
|
||||
await db
|
||||
.update(workflowTable)
|
||||
.set({
|
||||
lastSynced: new Date(),
|
||||
updatedAt: new Date(),
|
||||
state: saveResult.jsonBlob, // Also update JSON blob for backward compatibility
|
||||
})
|
||||
.where(eq(workflowTable.id, workflowId))
|
||||
|
||||
// Notify the socket server to tell clients to rehydrate stores from database
|
||||
try {
|
||||
const socketUrl = process.env.SOCKET_URL || 'http://localhost:3002'
|
||||
await fetch(`${socketUrl}/api/copilot-workflow-edit`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
workflowId,
|
||||
description: description || 'Copilot edited workflow',
|
||||
}),
|
||||
})
|
||||
logger.info('[edit-workflow] Notified socket server to rehydrate client stores from database')
|
||||
} catch (socketError) {
|
||||
// Don't fail the main request if socket notification fails
|
||||
logger.warn('[edit-workflow] Failed to notify socket server:', socketError)
|
||||
}
|
||||
|
||||
// Calculate summary with loop/parallel information
|
||||
const loopBlocksCount = Object.values(newWorkflowState.blocks).filter(
|
||||
(b: any) => b.type === 'loop'
|
||||
).length
|
||||
const parallelBlocksCount = Object.values(newWorkflowState.blocks).filter(
|
||||
(b: any) => b.type === 'parallel'
|
||||
).length
|
||||
|
||||
let summaryDetails = `Successfully created workflow with ${blocks.length} blocks and ${edges.length} connections.`
|
||||
|
||||
if (loopBlocksCount > 0 || parallelBlocksCount > 0) {
|
||||
summaryDetails += ` Generated ${Object.keys(loops).length} loop configurations and ${Object.keys(parallels).length} parallel configurations.`
|
||||
}
|
||||
|
||||
const result = {
|
||||
success: true,
|
||||
errors: [],
|
||||
warnings,
|
||||
summary: summaryDetails,
|
||||
}
|
||||
|
||||
logger.info('[edit-workflow] Import result', {
|
||||
success: result.success,
|
||||
errorCount: result.errors.length,
|
||||
warningCount: result.warnings.length,
|
||||
summary: result.summary,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
success: result.success,
|
||||
message: result.success
|
||||
? `Workflow updated successfully${description ? `: ${description}` : ''}`
|
||||
: 'Failed to update workflow',
|
||||
summary: result.summary,
|
||||
errors: result.errors,
|
||||
warnings: result.warnings,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('[edit-workflow] Error:', error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: `Failed to edit workflow: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
66
apps/sim/app/api/tools/get-all-blocks/route.ts
Normal file
66
apps/sim/app/api/tools/get-all-blocks/route.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { registry as blockRegistry } from '@/blocks/registry'
|
||||
|
||||
const logger = createLogger('GetAllBlocksAPI')
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { includeDetails = false, filterCategory } = body
|
||||
|
||||
logger.info('Getting all blocks and tools', { includeDetails, filterCategory })
|
||||
|
||||
// Create mapping of block_id -> [tool_ids]
|
||||
const blockToToolsMapping: Record<string, string[]> = {}
|
||||
|
||||
// Process blocks - filter out hidden blocks and map to their tools
|
||||
Object.entries(blockRegistry)
|
||||
.filter(([blockType, blockConfig]) => {
|
||||
// Filter out hidden blocks
|
||||
if (blockConfig.hideFromToolbar) return false
|
||||
|
||||
// Apply category filter if specified
|
||||
if (filterCategory && blockConfig.category !== filterCategory) return false
|
||||
|
||||
return true
|
||||
})
|
||||
.forEach(([blockType, blockConfig]) => {
|
||||
// Get the tools for this block
|
||||
const blockTools = blockConfig.tools?.access || []
|
||||
blockToToolsMapping[blockType] = blockTools
|
||||
})
|
||||
|
||||
const totalBlocks = Object.keys(blockRegistry).length
|
||||
const includedBlocks = Object.keys(blockToToolsMapping).length
|
||||
const filteredBlocksCount = totalBlocks - includedBlocks
|
||||
|
||||
// Log block to tools mapping for debugging
|
||||
const blockToolsInfo = Object.entries(blockToToolsMapping)
|
||||
.map(([blockType, tools]) => `${blockType}: [${tools.join(', ')}]`)
|
||||
.sort()
|
||||
|
||||
logger.info(`Successfully mapped ${includedBlocks} blocks to their tools`, {
|
||||
totalBlocks,
|
||||
includedBlocks,
|
||||
filteredBlocks: filteredBlocksCount,
|
||||
filterCategory,
|
||||
blockToolsMapping: blockToolsInfo,
|
||||
outputMapping: blockToToolsMapping,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: blockToToolsMapping,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Get all blocks failed', error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: `Failed to get blocks and tools: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
239
apps/sim/app/api/tools/get-blocks-metadata/route.ts
Normal file
239
apps/sim/app/api/tools/get-blocks-metadata/route.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
import { existsSync, readFileSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { registry as blockRegistry } from '@/blocks/registry'
|
||||
import { tools as toolsRegistry } from '@/tools/registry'
|
||||
|
||||
const logger = createLogger('GetBlockMetadataAPI')
|
||||
|
||||
// Core blocks that have documentation with YAML schemas
|
||||
const CORE_BLOCKS_WITH_DOCS = [
|
||||
'agent',
|
||||
'function',
|
||||
'api',
|
||||
'condition',
|
||||
'loop',
|
||||
'parallel',
|
||||
'response',
|
||||
'router',
|
||||
'evaluator',
|
||||
'webhook',
|
||||
]
|
||||
|
||||
// Mapping for blocks that have different doc file names
|
||||
const DOCS_FILE_MAPPING: Record<string, string> = {
|
||||
webhook: 'webhook_trigger',
|
||||
}
|
||||
|
||||
// Helper function to read YAML schema from dedicated YAML documentation files
|
||||
function getYamlSchemaFromDocs(blockType: string): string | null {
|
||||
try {
|
||||
const docFileName = DOCS_FILE_MAPPING[blockType] || blockType
|
||||
// Read from the new YAML documentation structure
|
||||
const yamlDocsPath = join(
|
||||
process.cwd(),
|
||||
'..',
|
||||
'docs/content/docs/yaml/blocks',
|
||||
`${docFileName}.mdx`
|
||||
)
|
||||
|
||||
if (!existsSync(yamlDocsPath)) {
|
||||
logger.warn(`YAML schema file not found for ${blockType} at ${yamlDocsPath}`)
|
||||
return null
|
||||
}
|
||||
|
||||
const content = readFileSync(yamlDocsPath, 'utf-8')
|
||||
|
||||
// Remove the frontmatter and return the content after the title
|
||||
const contentWithoutFrontmatter = content.replace(/^---[\s\S]*?---\s*/, '')
|
||||
return contentWithoutFrontmatter.trim()
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to read YAML schema for ${blockType}:`, error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { blockIds } = body
|
||||
|
||||
if (!blockIds || !Array.isArray(blockIds)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'blockIds must be an array of block IDs',
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.info('Getting block metadata', {
|
||||
blockIds,
|
||||
blockCount: blockIds.length,
|
||||
requestedBlocks: blockIds.join(', '),
|
||||
})
|
||||
|
||||
// Create result object
|
||||
const result: Record<string, any> = {}
|
||||
|
||||
for (const blockId of blockIds) {
|
||||
const blockConfig = blockRegistry[blockId]
|
||||
|
||||
if (!blockConfig) {
|
||||
logger.warn(`Block not found: ${blockId}`)
|
||||
continue
|
||||
}
|
||||
|
||||
// Always include code schemas from block configuration
|
||||
const codeSchemas = {
|
||||
inputs: blockConfig.inputs,
|
||||
outputs: blockConfig.outputs,
|
||||
subBlocks: blockConfig.subBlocks,
|
||||
}
|
||||
|
||||
// Check if this is a core block with YAML documentation
|
||||
if (CORE_BLOCKS_WITH_DOCS.includes(blockId)) {
|
||||
// For core blocks, return both YAML schema from documentation AND code schemas
|
||||
const yamlSchema = getYamlSchemaFromDocs(blockId)
|
||||
|
||||
if (yamlSchema) {
|
||||
result[blockId] = {
|
||||
type: 'block',
|
||||
description: blockConfig.description || '',
|
||||
longDescription: blockConfig.longDescription,
|
||||
category: blockConfig.category || '',
|
||||
yamlSchema: yamlSchema,
|
||||
docsLink: blockConfig.docsLink,
|
||||
// Include actual schemas from code
|
||||
codeSchemas: codeSchemas,
|
||||
}
|
||||
} else {
|
||||
// Fallback to regular metadata if YAML schema not found
|
||||
result[blockId] = {
|
||||
type: 'block',
|
||||
description: blockConfig.description || '',
|
||||
longDescription: blockConfig.longDescription,
|
||||
category: blockConfig.category || '',
|
||||
inputs: blockConfig.inputs,
|
||||
outputs: blockConfig.outputs,
|
||||
subBlocks: blockConfig.subBlocks,
|
||||
// Include actual schemas from code
|
||||
codeSchemas: codeSchemas,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// For tool blocks, return tool schema information AND code schemas
|
||||
const blockTools = blockConfig.tools?.access || []
|
||||
const toolSchemas: Record<string, any> = {}
|
||||
|
||||
for (const toolId of blockTools) {
|
||||
const toolConfig = toolsRegistry[toolId]
|
||||
if (toolConfig) {
|
||||
toolSchemas[toolId] = {
|
||||
id: toolConfig.id,
|
||||
name: toolConfig.name,
|
||||
description: toolConfig.description || '',
|
||||
version: toolConfig.version,
|
||||
params: toolConfig.params,
|
||||
request: toolConfig.request
|
||||
? {
|
||||
method: toolConfig.request.method,
|
||||
url: toolConfig.request.url,
|
||||
headers:
|
||||
typeof toolConfig.request.headers === 'function'
|
||||
? 'function'
|
||||
: toolConfig.request.headers,
|
||||
isInternalRoute: toolConfig.request.isInternalRoute,
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
} else {
|
||||
logger.warn(`Tool not found: ${toolId} for block: ${blockId}`)
|
||||
toolSchemas[toolId] = {
|
||||
id: toolId,
|
||||
description: 'Tool not found',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result[blockId] = {
|
||||
type: 'tool',
|
||||
description: blockConfig.description || '',
|
||||
longDescription: blockConfig.longDescription,
|
||||
category: blockConfig.category || '',
|
||||
inputs: blockConfig.inputs,
|
||||
outputs: blockConfig.outputs,
|
||||
subBlocks: blockConfig.subBlocks,
|
||||
toolSchemas: toolSchemas,
|
||||
// Include actual schemas from code
|
||||
codeSchemas: codeSchemas,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const processedBlocks = Object.keys(result).length
|
||||
const requestedBlocks = blockIds.length
|
||||
const notFoundBlocks = requestedBlocks - processedBlocks
|
||||
|
||||
// Log detailed output for debugging
|
||||
Object.entries(result).forEach(([blockId, blockData]) => {
|
||||
if (blockData.type === 'block' && blockData.yamlSchema) {
|
||||
logger.info(`Retrieved YAML schema + code schemas for core block: ${blockId}`, {
|
||||
blockId,
|
||||
type: blockData.type,
|
||||
description: blockData.description,
|
||||
yamlSchemaLength: blockData.yamlSchema.length,
|
||||
yamlSchemaPreview: `${blockData.yamlSchema.substring(0, 200)}...`,
|
||||
hasCodeSchemas: !!blockData.codeSchemas,
|
||||
codeSubBlocksCount: blockData.codeSchemas?.subBlocks?.length || 0,
|
||||
})
|
||||
} else if (blockData.type === 'tool' && blockData.toolSchemas) {
|
||||
const toolIds = Object.keys(blockData.toolSchemas)
|
||||
logger.info(`Retrieved tool schemas + code schemas for tool block: ${blockId}`, {
|
||||
blockId,
|
||||
type: blockData.type,
|
||||
description: blockData.description,
|
||||
toolCount: toolIds.length,
|
||||
toolIds: toolIds,
|
||||
hasCodeSchemas: !!blockData.codeSchemas,
|
||||
codeSubBlocksCount: blockData.codeSchemas?.subBlocks?.length || 0,
|
||||
})
|
||||
} else {
|
||||
logger.info(`Retrieved metadata + code schemas for block: ${blockId}`, {
|
||||
blockId,
|
||||
type: blockData.type,
|
||||
description: blockData.description,
|
||||
hasInputs: !!blockData.inputs,
|
||||
hasOutputs: !!blockData.outputs,
|
||||
hasSubBlocks: !!blockData.subBlocks,
|
||||
hasCodeSchemas: !!blockData.codeSchemas,
|
||||
codeSubBlocksCount: blockData.codeSchemas?.subBlocks?.length || 0,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
logger.info(`Successfully processed ${processedBlocks} block metadata`, {
|
||||
requestedBlocks,
|
||||
processedBlocks,
|
||||
notFoundBlocks,
|
||||
coreBlocks: blockIds.filter((id) => CORE_BLOCKS_WITH_DOCS.includes(id)),
|
||||
toolBlocks: blockIds.filter((id) => !CORE_BLOCKS_WITH_DOCS.includes(id)),
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: result,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Get block metadata failed', error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: `Failed to get block metadata: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ export async function POST(request: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
logger.info('Fetching workflow for YAML generation', { workflowId })
|
||||
logger.info('Fetching user workflow', { workflowId })
|
||||
|
||||
// Fetch workflow from database
|
||||
const [workflowRecord] = await db
|
||||
@@ -190,9 +190,9 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('Successfully generated workflow YAML', {
|
||||
logger.info('Successfully fetched user workflow YAML', {
|
||||
workflowId,
|
||||
blockCount: response.blockCount,
|
||||
blockCount: response.summary.blockCount,
|
||||
yamlLength: yaml.length,
|
||||
})
|
||||
|
||||
|
||||
25
apps/sim/app/api/tools/get-yaml-structure/route.ts
Normal file
25
apps/sim/app/api/tools/get-yaml-structure/route.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { YAML_WORKFLOW_PROMPT } from '../../../../lib/copilot/prompts'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
console.log('[get-yaml-structure] API endpoint called')
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
guide: YAML_WORKFLOW_PROMPT,
|
||||
message: 'Complete YAML workflow syntax guide with examples and best practices',
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[get-yaml-structure] Error:', error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Failed to get YAML structure guide',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -465,6 +465,58 @@ export async function GET(request: NextRequest) {
|
||||
})
|
||||
}
|
||||
|
||||
case 'microsoftteams': {
|
||||
const hmacSecret = providerConfig.hmacSecret
|
||||
|
||||
if (!hmacSecret) {
|
||||
logger.warn(`[${requestId}] Microsoft Teams webhook missing HMAC secret: ${webhookId}`)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Microsoft Teams webhook requires HMAC secret' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Microsoft Teams webhook test successful: ${webhookId}`)
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
webhook: {
|
||||
id: foundWebhook.id,
|
||||
url: webhookUrl,
|
||||
isActive: foundWebhook.isActive,
|
||||
},
|
||||
message: 'Microsoft Teams outgoing webhook configuration is valid.',
|
||||
setup: {
|
||||
url: webhookUrl,
|
||||
hmacSecretConfigured: !!hmacSecret,
|
||||
instructions: [
|
||||
'Create an outgoing webhook in Microsoft Teams',
|
||||
'Set the callback URL to the webhook URL above',
|
||||
'Copy the HMAC security token to the configuration',
|
||||
'Users can trigger the webhook by @mentioning it in Teams',
|
||||
],
|
||||
},
|
||||
test: {
|
||||
curlCommand: `curl -X POST "${webhookUrl}" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-H "Authorization: HMAC <signature>" \\
|
||||
-d '{"type":"message","text":"Hello from Microsoft Teams!","from":{"id":"test","name":"Test User"}}'`,
|
||||
samplePayload: {
|
||||
type: 'message',
|
||||
id: '1234567890',
|
||||
timestamp: new Date().toISOString(),
|
||||
text: 'Hello Sim Studio Bot!',
|
||||
from: {
|
||||
id: '29:1234567890abcdef',
|
||||
name: 'Test User',
|
||||
},
|
||||
conversation: {
|
||||
id: '19:meeting_abcdef@thread.v2',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
default: {
|
||||
// Generic webhook test
|
||||
logger.info(`[${requestId}] Generic webhook test successful: ${webhookId}`)
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
processGenericDeduplication,
|
||||
processWebhook,
|
||||
processWhatsAppDeduplication,
|
||||
validateMicrosoftTeamsSignature,
|
||||
} from '@/lib/webhooks/utils'
|
||||
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/db-helpers'
|
||||
import { db } from '@/db'
|
||||
@@ -243,6 +244,51 @@ export async function POST(
|
||||
return slackChallengeResponse
|
||||
}
|
||||
|
||||
// Handle Microsoft Teams outgoing webhook signature verification (must be done before timeout)
|
||||
if (foundWebhook.provider === 'microsoftteams') {
|
||||
const providerConfig = (foundWebhook.providerConfig as Record<string, any>) || {}
|
||||
|
||||
if (providerConfig.hmacSecret) {
|
||||
const authHeader = request.headers.get('authorization')
|
||||
|
||||
if (!authHeader || !authHeader.startsWith('HMAC ')) {
|
||||
logger.warn(
|
||||
`[${requestId}] Microsoft Teams outgoing webhook missing HMAC authorization header`
|
||||
)
|
||||
return new NextResponse('Unauthorized - Missing HMAC signature', { status: 401 })
|
||||
}
|
||||
|
||||
// Get the raw body for HMAC verification
|
||||
const rawBody = await request.text()
|
||||
|
||||
const isValidSignature = validateMicrosoftTeamsSignature(
|
||||
providerConfig.hmacSecret,
|
||||
authHeader,
|
||||
rawBody
|
||||
)
|
||||
|
||||
if (!isValidSignature) {
|
||||
logger.warn(`[${requestId}] Microsoft Teams HMAC signature verification failed`)
|
||||
return new NextResponse('Unauthorized - Invalid HMAC signature', { status: 401 })
|
||||
}
|
||||
|
||||
logger.debug(`[${requestId}] Microsoft Teams HMAC signature verified successfully`)
|
||||
|
||||
// Parse the body again since we consumed it for verification
|
||||
try {
|
||||
body = JSON.parse(rawBody)
|
||||
} catch (parseError) {
|
||||
logger.error(
|
||||
`[${requestId}] Failed to parse Microsoft Teams webhook body after verification`,
|
||||
{
|
||||
error: parseError instanceof Error ? parseError.message : String(parseError),
|
||||
}
|
||||
)
|
||||
return new NextResponse('Invalid JSON payload', { status: 400 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Skip processing if another instance is already handling this request
|
||||
if (!hasExecutionLock) {
|
||||
logger.info(`[${requestId}] Skipping execution as lock was not acquired`)
|
||||
|
||||
223
apps/sim/app/api/workflows/[id]/autolayout/route.ts
Normal file
223
apps/sim/app/api/workflows/[id]/autolayout/route.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { autoLayoutWorkflow } from '@/lib/autolayout/service'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { getUserEntityPermissions } from '@/lib/permissions/utils'
|
||||
import {
|
||||
loadWorkflowFromNormalizedTables,
|
||||
saveWorkflowToNormalizedTables,
|
||||
} from '@/lib/workflows/db-helpers'
|
||||
import { db } from '@/db'
|
||||
import { workflow as workflowTable } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('AutoLayoutAPI')
|
||||
|
||||
const AutoLayoutRequestSchema = z.object({
|
||||
strategy: z
|
||||
.enum(['smart', 'hierarchical', 'layered', 'force-directed'])
|
||||
.optional()
|
||||
.default('smart'),
|
||||
direction: z.enum(['horizontal', 'vertical', 'auto']).optional().default('auto'),
|
||||
spacing: z
|
||||
.object({
|
||||
horizontal: z.number().min(100).max(1000).optional().default(400),
|
||||
vertical: z.number().min(50).max(500).optional().default(200),
|
||||
layer: z.number().min(200).max(1200).optional().default(600),
|
||||
})
|
||||
.optional()
|
||||
.default({}),
|
||||
alignment: z.enum(['start', 'center', 'end']).optional().default('center'),
|
||||
padding: z
|
||||
.object({
|
||||
x: z.number().min(50).max(500).optional().default(200),
|
||||
y: z.number().min(50).max(500).optional().default(200),
|
||||
})
|
||||
.optional()
|
||||
.default({}),
|
||||
})
|
||||
|
||||
type AutoLayoutRequest = z.infer<typeof AutoLayoutRequestSchema>
|
||||
|
||||
/**
|
||||
* POST /api/workflows/[id]/autolayout
|
||||
* Apply autolayout to an existing workflow
|
||||
*/
|
||||
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const requestId = crypto.randomUUID().slice(0, 8)
|
||||
const startTime = Date.now()
|
||||
const { id: workflowId } = await params
|
||||
|
||||
try {
|
||||
// Get the session
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
logger.warn(`[${requestId}] Unauthorized autolayout attempt for workflow ${workflowId}`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const userId = session.user.id
|
||||
|
||||
// Parse request body
|
||||
const body = await request.json()
|
||||
const layoutOptions = AutoLayoutRequestSchema.parse(body)
|
||||
|
||||
logger.info(`[${requestId}] Processing autolayout request for workflow ${workflowId}`, {
|
||||
strategy: layoutOptions.strategy,
|
||||
direction: layoutOptions.direction,
|
||||
userId,
|
||||
})
|
||||
|
||||
// Fetch the workflow to check ownership/access
|
||||
const workflowData = await db
|
||||
.select()
|
||||
.from(workflowTable)
|
||||
.where(eq(workflowTable.id, workflowId))
|
||||
.then((rows) => rows[0])
|
||||
|
||||
if (!workflowData) {
|
||||
logger.warn(`[${requestId}] Workflow ${workflowId} not found for autolayout`)
|
||||
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Check if user has permission to update this workflow
|
||||
let canUpdate = false
|
||||
|
||||
// Case 1: User owns the workflow
|
||||
if (workflowData.userId === userId) {
|
||||
canUpdate = true
|
||||
}
|
||||
|
||||
// Case 2: Workflow belongs to a workspace and user has write or admin permission
|
||||
if (!canUpdate && workflowData.workspaceId) {
|
||||
const userPermission = await getUserEntityPermissions(
|
||||
userId,
|
||||
'workspace',
|
||||
workflowData.workspaceId
|
||||
)
|
||||
if (userPermission === 'write' || userPermission === 'admin') {
|
||||
canUpdate = true
|
||||
}
|
||||
}
|
||||
|
||||
if (!canUpdate) {
|
||||
logger.warn(
|
||||
`[${requestId}] User ${userId} denied permission to autolayout workflow ${workflowId}`
|
||||
)
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Load current workflow state
|
||||
const currentWorkflowData = await loadWorkflowFromNormalizedTables(workflowId)
|
||||
|
||||
if (!currentWorkflowData) {
|
||||
logger.error(`[${requestId}] Could not load workflow ${workflowId} for autolayout`)
|
||||
return NextResponse.json({ error: 'Could not load workflow data' }, { status: 500 })
|
||||
}
|
||||
|
||||
// Apply autolayout
|
||||
logger.info(
|
||||
`[${requestId}] Applying autolayout to ${Object.keys(currentWorkflowData.blocks).length} blocks`
|
||||
)
|
||||
|
||||
const layoutedBlocks = await autoLayoutWorkflow(
|
||||
currentWorkflowData.blocks,
|
||||
currentWorkflowData.edges,
|
||||
{
|
||||
strategy: layoutOptions.strategy,
|
||||
direction: layoutOptions.direction,
|
||||
spacing: {
|
||||
horizontal: layoutOptions.spacing?.horizontal || 400,
|
||||
vertical: layoutOptions.spacing?.vertical || 200,
|
||||
layer: layoutOptions.spacing?.layer || 600,
|
||||
},
|
||||
alignment: layoutOptions.alignment,
|
||||
padding: {
|
||||
x: layoutOptions.padding?.x || 200,
|
||||
y: layoutOptions.padding?.y || 200,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// Create updated workflow state
|
||||
const updatedWorkflowState = {
|
||||
...currentWorkflowData,
|
||||
blocks: layoutedBlocks,
|
||||
lastSaved: Date.now(),
|
||||
}
|
||||
|
||||
// Save to database
|
||||
const saveResult = await saveWorkflowToNormalizedTables(workflowId, updatedWorkflowState)
|
||||
|
||||
if (!saveResult.success) {
|
||||
logger.error(`[${requestId}] Failed to save autolayout results:`, saveResult.error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to save autolayout results', details: saveResult.error },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
// Update workflow's lastSynced timestamp
|
||||
await db
|
||||
.update(workflowTable)
|
||||
.set({
|
||||
lastSynced: new Date(),
|
||||
updatedAt: new Date(),
|
||||
state: saveResult.jsonBlob,
|
||||
})
|
||||
.where(eq(workflowTable.id, workflowId))
|
||||
|
||||
// Notify the socket server to tell clients about the autolayout update
|
||||
try {
|
||||
const socketUrl = process.env.SOCKET_URL || 'http://localhost:3002'
|
||||
await fetch(`${socketUrl}/api/workflow-updated`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workflowId }),
|
||||
})
|
||||
logger.info(`[${requestId}] Notified socket server of autolayout update`)
|
||||
} catch (socketError) {
|
||||
logger.warn(`[${requestId}] Failed to notify socket server:`, socketError)
|
||||
}
|
||||
|
||||
const elapsed = Date.now() - startTime
|
||||
const blockCount = Object.keys(layoutedBlocks).length
|
||||
|
||||
logger.info(`[${requestId}] Autolayout completed successfully in ${elapsed}ms`, {
|
||||
blockCount,
|
||||
strategy: layoutOptions.strategy,
|
||||
workflowId,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Autolayout applied successfully to ${blockCount} blocks`,
|
||||
data: {
|
||||
strategy: layoutOptions.strategy,
|
||||
direction: layoutOptions.direction,
|
||||
blockCount,
|
||||
elapsed: `${elapsed}ms`,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
const elapsed = Date.now() - startTime
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
logger.warn(`[${requestId}] Invalid autolayout request data`, { errors: error.errors })
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid request data', details: error.errors },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Autolayout failed after ${elapsed}ms:`, error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Autolayout failed',
|
||||
details: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,7 @@ const BlockDataSchema = z.object({
|
||||
const SubBlockStateSchema = z.object({
|
||||
id: z.string(),
|
||||
type: z.string(),
|
||||
value: z.union([z.string(), z.number(), z.array(z.array(z.string())), z.null()]),
|
||||
value: z.any(),
|
||||
})
|
||||
|
||||
const BlockOutputSchema = z.any()
|
||||
|
||||
538
apps/sim/app/api/workflows/[id]/yaml/route.ts
Normal file
538
apps/sim/app/api/workflows/[id]/yaml/route.ts
Normal file
@@ -0,0 +1,538 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { autoLayoutWorkflow } from '@/lib/autolayout/service'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { getUserEntityPermissions } from '@/lib/permissions/utils'
|
||||
import {
|
||||
loadWorkflowFromNormalizedTables,
|
||||
saveWorkflowToNormalizedTables,
|
||||
} from '@/lib/workflows/db-helpers'
|
||||
import { generateWorkflowYaml } from '@/lib/workflows/yaml-generator'
|
||||
import { getUserId as getOAuthUserId } from '@/app/api/auth/oauth/utils'
|
||||
import { getBlock } from '@/blocks'
|
||||
import { resolveOutputType } from '@/blocks/utils'
|
||||
import { db } from '@/db'
|
||||
import { copilotCheckpoints, workflow as workflowTable } from '@/db/schema'
|
||||
import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils'
|
||||
import { convertYamlToWorkflow, parseWorkflowYaml } from '@/stores/workflows/yaml/importer'
|
||||
|
||||
const logger = createLogger('WorkflowYamlAPI')
|
||||
|
||||
// Request schema for YAML workflow operations
|
||||
const YamlWorkflowRequestSchema = z.object({
|
||||
yamlContent: z.string().min(1, 'YAML content is required'),
|
||||
description: z.string().optional(),
|
||||
chatId: z.string().optional(), // For copilot checkpoints
|
||||
source: z.enum(['copilot', 'import', 'editor']).default('editor'),
|
||||
applyAutoLayout: z.boolean().default(true),
|
||||
createCheckpoint: z.boolean().default(false),
|
||||
})
|
||||
|
||||
type YamlWorkflowRequest = z.infer<typeof YamlWorkflowRequestSchema>
|
||||
|
||||
/**
|
||||
* Helper function to create a checkpoint before workflow changes
|
||||
*/
|
||||
async function createWorkflowCheckpoint(
|
||||
userId: string,
|
||||
workflowId: string,
|
||||
chatId: string,
|
||||
requestId: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
logger.info(`[${requestId}] Creating checkpoint before workflow edit`)
|
||||
|
||||
// Get current workflow state
|
||||
const currentWorkflowData = await loadWorkflowFromNormalizedTables(workflowId)
|
||||
|
||||
if (currentWorkflowData) {
|
||||
// Generate YAML from current state
|
||||
const currentYaml = generateWorkflowYaml(currentWorkflowData)
|
||||
|
||||
// Create checkpoint
|
||||
await db.insert(copilotCheckpoints).values({
|
||||
userId,
|
||||
workflowId,
|
||||
chatId,
|
||||
yaml: currentYaml,
|
||||
})
|
||||
|
||||
logger.info(`[${requestId}] Checkpoint created successfully`)
|
||||
return true
|
||||
}
|
||||
logger.warn(`[${requestId}] Could not load current workflow state for checkpoint`)
|
||||
return false
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Failed to create checkpoint:`, error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to get user ID with proper authentication for both tool calls and direct requests
|
||||
*/
|
||||
async function getUserId(requestId: string, workflowId: string): Promise<string | null> {
|
||||
// Use the OAuth utils function that handles both session and workflow-based auth
|
||||
const userId = await getOAuthUserId(requestId, workflowId)
|
||||
|
||||
if (!userId) {
|
||||
logger.warn(`[${requestId}] Could not determine user ID for workflow ${workflowId}`)
|
||||
return null
|
||||
}
|
||||
|
||||
// For additional security, verify the user has permission to access this workflow
|
||||
const workflowData = await db
|
||||
.select()
|
||||
.from(workflowTable)
|
||||
.where(eq(workflowTable.id, workflowId))
|
||||
.then((rows) => rows[0])
|
||||
|
||||
if (!workflowData) {
|
||||
logger.warn(`[${requestId}] Workflow ${workflowId} not found`)
|
||||
return null
|
||||
}
|
||||
|
||||
// Check if user has permission to update this workflow
|
||||
let canUpdate = false
|
||||
|
||||
// Case 1: User owns the workflow
|
||||
if (workflowData.userId === userId) {
|
||||
canUpdate = true
|
||||
}
|
||||
|
||||
// Case 2: Workflow belongs to a workspace and user has write or admin permission
|
||||
if (!canUpdate && workflowData.workspaceId) {
|
||||
try {
|
||||
const userPermission = await getUserEntityPermissions(
|
||||
userId,
|
||||
'workspace',
|
||||
workflowData.workspaceId
|
||||
)
|
||||
if (userPermission === 'write' || userPermission === 'admin') {
|
||||
canUpdate = true
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`[${requestId}] Error checking workspace permissions:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
if (!canUpdate) {
|
||||
logger.warn(`[${requestId}] User ${userId} denied permission to update workflow ${workflowId}`)
|
||||
return null
|
||||
}
|
||||
|
||||
return userId
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to update block references in values with new mapped IDs
|
||||
*/
|
||||
function updateBlockReferences(
|
||||
value: any,
|
||||
blockIdMapping: Map<string, string>,
|
||||
requestId: string
|
||||
): any {
|
||||
if (typeof value === 'string' && value.includes('<') && value.includes('>')) {
|
||||
let processedValue = value
|
||||
const blockMatches = value.match(/<([^>]+)>/g)
|
||||
|
||||
if (blockMatches) {
|
||||
for (const match of blockMatches) {
|
||||
const path = match.slice(1, -1)
|
||||
const [blockRef] = path.split('.')
|
||||
|
||||
// Skip system references (start, loop, parallel, variable)
|
||||
if (['start', 'loop', 'parallel', 'variable'].includes(blockRef.toLowerCase())) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if this references an old block ID that needs mapping
|
||||
const newMappedId = blockIdMapping.get(blockRef)
|
||||
if (newMappedId) {
|
||||
logger.info(`[${requestId}] Updating block reference: ${blockRef} -> ${newMappedId}`)
|
||||
processedValue = processedValue.replace(
|
||||
new RegExp(`<${blockRef}\\.`, 'g'),
|
||||
`<${newMappedId}.`
|
||||
)
|
||||
processedValue = processedValue.replace(
|
||||
new RegExp(`<${blockRef}>`, 'g'),
|
||||
`<${newMappedId}>`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return processedValue
|
||||
}
|
||||
|
||||
// Handle arrays
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => updateBlockReferences(item, blockIdMapping, requestId))
|
||||
}
|
||||
|
||||
// Handle objects
|
||||
if (value !== null && typeof value === 'object') {
|
||||
const result = { ...value }
|
||||
for (const key in result) {
|
||||
result[key] = updateBlockReferences(result[key], blockIdMapping, requestId)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/workflows/[id]/yaml
|
||||
* Consolidated YAML workflow saving endpoint
|
||||
* Handles copilot edits, imports, and text editor saves
|
||||
*/
|
||||
export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const requestId = crypto.randomUUID().slice(0, 8)
|
||||
const startTime = Date.now()
|
||||
const { id: workflowId } = await params
|
||||
|
||||
try {
|
||||
// Parse and validate request
|
||||
const body = await request.json()
|
||||
const { yamlContent, description, chatId, source, applyAutoLayout, createCheckpoint } =
|
||||
YamlWorkflowRequestSchema.parse(body)
|
||||
|
||||
logger.info(`[${requestId}] Processing ${source} YAML workflow save`, {
|
||||
workflowId,
|
||||
yamlLength: yamlContent.length,
|
||||
hasDescription: !!description,
|
||||
hasChatId: !!chatId,
|
||||
applyAutoLayout,
|
||||
createCheckpoint,
|
||||
})
|
||||
|
||||
// Get and validate user
|
||||
const userId = await getUserId(requestId, workflowId)
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized or workflow not found' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Create checkpoint if requested (typically for copilot)
|
||||
if (createCheckpoint && chatId) {
|
||||
await createWorkflowCheckpoint(userId, workflowId, chatId, requestId)
|
||||
}
|
||||
|
||||
// Parse YAML content
|
||||
const { data: yamlWorkflow, errors: parseErrors } = parseWorkflowYaml(yamlContent)
|
||||
|
||||
if (!yamlWorkflow || parseErrors.length > 0) {
|
||||
logger.error(`[${requestId}] YAML parsing failed`, { parseErrors })
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
message: 'Failed to parse YAML workflow',
|
||||
errors: parseErrors,
|
||||
warnings: [],
|
||||
})
|
||||
}
|
||||
|
||||
// Convert YAML to workflow format
|
||||
const { blocks, edges, errors: convertErrors, warnings } = convertYamlToWorkflow(yamlWorkflow)
|
||||
|
||||
if (convertErrors.length > 0) {
|
||||
logger.error(`[${requestId}] YAML conversion failed`, { convertErrors })
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
message: 'Failed to convert YAML to workflow',
|
||||
errors: convertErrors,
|
||||
warnings,
|
||||
})
|
||||
}
|
||||
|
||||
// Create workflow state
|
||||
const newWorkflowState: any = {
|
||||
blocks: {} as Record<string, any>,
|
||||
edges: [] as any[],
|
||||
loops: {} as Record<string, any>,
|
||||
parallels: {} as Record<string, any>,
|
||||
lastSaved: Date.now(),
|
||||
isDeployed: false,
|
||||
deployedAt: undefined,
|
||||
deploymentStatuses: {} as Record<string, any>,
|
||||
hasActiveSchedule: false,
|
||||
hasActiveWebhook: false,
|
||||
}
|
||||
|
||||
// Process blocks with proper configuration setup and assign new IDs
|
||||
const blockIdMapping = new Map<string, string>()
|
||||
|
||||
for (const block of blocks) {
|
||||
const newId = crypto.randomUUID()
|
||||
blockIdMapping.set(block.id, newId)
|
||||
|
||||
// Get block configuration for proper setup
|
||||
const blockConfig = getBlock(block.type)
|
||||
|
||||
if (!blockConfig && (block.type === 'loop' || block.type === 'parallel')) {
|
||||
// Handle loop/parallel blocks (they don't have regular block configs)
|
||||
newWorkflowState.blocks[newId] = {
|
||||
id: newId,
|
||||
type: block.type,
|
||||
name: block.name,
|
||||
position: block.position,
|
||||
subBlocks: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
horizontalHandles: true,
|
||||
isWide: false,
|
||||
height: 0,
|
||||
data: block.data || {},
|
||||
}
|
||||
logger.debug(`[${requestId}] Processed loop/parallel block: ${block.id} -> ${newId}`)
|
||||
} else if (blockConfig) {
|
||||
// Handle regular blocks with proper configuration
|
||||
const subBlocks: Record<string, any> = {}
|
||||
|
||||
// Set up subBlocks from block configuration
|
||||
blockConfig.subBlocks.forEach((subBlock) => {
|
||||
subBlocks[subBlock.id] = {
|
||||
id: subBlock.id,
|
||||
type: subBlock.type,
|
||||
value: null,
|
||||
}
|
||||
})
|
||||
|
||||
// Also ensure we have subBlocks for any YAML inputs that might not be in the config
|
||||
// This handles cases where hidden fields or dynamic configurations exist
|
||||
Object.keys(block.inputs).forEach((inputKey) => {
|
||||
if (!subBlocks[inputKey]) {
|
||||
subBlocks[inputKey] = {
|
||||
id: inputKey,
|
||||
type: 'short-input', // Default type for dynamic inputs
|
||||
value: null,
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Set up outputs from block configuration
|
||||
const outputs = resolveOutputType(blockConfig.outputs)
|
||||
|
||||
newWorkflowState.blocks[newId] = {
|
||||
id: newId,
|
||||
type: block.type,
|
||||
name: block.name,
|
||||
position: block.position,
|
||||
subBlocks,
|
||||
outputs,
|
||||
enabled: true,
|
||||
horizontalHandles: true,
|
||||
isWide: false,
|
||||
height: 0,
|
||||
data: block.data || {},
|
||||
}
|
||||
|
||||
logger.debug(`[${requestId}] Processed regular block: ${block.id} -> ${newId}`)
|
||||
} else {
|
||||
logger.warn(`[${requestId}] Unknown block type: ${block.type}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Set input values as subblock values with block reference mapping
|
||||
for (const block of blocks) {
|
||||
const newId = blockIdMapping.get(block.id)
|
||||
if (!newId || !newWorkflowState.blocks[newId]) continue
|
||||
|
||||
if (block.inputs && typeof block.inputs === 'object') {
|
||||
Object.entries(block.inputs).forEach(([key, value]) => {
|
||||
if (newWorkflowState.blocks[newId].subBlocks[key]) {
|
||||
// Update block references in values to use new mapped IDs
|
||||
const processedValue = updateBlockReferences(value, blockIdMapping, requestId)
|
||||
newWorkflowState.blocks[newId].subBlocks[key].value = processedValue
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Update parent-child relationships with mapped IDs
|
||||
logger.info(`[${requestId}] Block ID mapping:`, Object.fromEntries(blockIdMapping))
|
||||
for (const [newId, blockData] of Object.entries(newWorkflowState.blocks)) {
|
||||
const block = blockData as any
|
||||
if (block.data?.parentId) {
|
||||
logger.info(
|
||||
`[${requestId}] Found child block ${block.name} with parentId: ${block.data.parentId}`
|
||||
)
|
||||
const mappedParentId = blockIdMapping.get(block.data.parentId)
|
||||
if (mappedParentId) {
|
||||
logger.info(
|
||||
`[${requestId}] Updating parent reference: ${block.data.parentId} -> ${mappedParentId}`
|
||||
)
|
||||
block.data.parentId = mappedParentId
|
||||
// Ensure extent is set for child blocks
|
||||
if (!block.data.extent) {
|
||||
block.data.extent = 'parent'
|
||||
}
|
||||
} else {
|
||||
logger.error(
|
||||
`[${requestId}] ❌ Parent block not found for mapping: ${block.data.parentId}`
|
||||
)
|
||||
logger.error(`[${requestId}] Available mappings:`, Array.from(blockIdMapping.keys()))
|
||||
// Remove invalid parent reference
|
||||
block.data.parentId = undefined
|
||||
block.data.extent = undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process edges with mapped IDs and handles
|
||||
for (const edge of edges) {
|
||||
const sourceId = blockIdMapping.get(edge.source)
|
||||
const targetId = blockIdMapping.get(edge.target)
|
||||
|
||||
if (sourceId && targetId) {
|
||||
const newEdgeId = crypto.randomUUID()
|
||||
newWorkflowState.edges.push({
|
||||
id: newEdgeId,
|
||||
source: sourceId,
|
||||
target: targetId,
|
||||
sourceHandle: edge.sourceHandle,
|
||||
targetHandle: edge.targetHandle,
|
||||
type: edge.type || 'default',
|
||||
})
|
||||
} else {
|
||||
logger.warn(
|
||||
`[${requestId}] Skipping edge - missing blocks: ${edge.source} -> ${edge.target}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Generate loop and parallel configurations
|
||||
const loops = generateLoopBlocks(newWorkflowState.blocks)
|
||||
const parallels = generateParallelBlocks(newWorkflowState.blocks)
|
||||
newWorkflowState.loops = loops
|
||||
newWorkflowState.parallels = parallels
|
||||
|
||||
logger.info(`[${requestId}] Generated workflow state`, {
|
||||
blocksCount: Object.keys(newWorkflowState.blocks).length,
|
||||
edgesCount: newWorkflowState.edges.length,
|
||||
loopsCount: Object.keys(loops).length,
|
||||
parallelsCount: Object.keys(parallels).length,
|
||||
})
|
||||
|
||||
// Apply intelligent autolayout if requested
|
||||
if (applyAutoLayout) {
|
||||
try {
|
||||
logger.info(`[${requestId}] Applying autolayout`)
|
||||
|
||||
const layoutedBlocks = await autoLayoutWorkflow(
|
||||
newWorkflowState.blocks,
|
||||
newWorkflowState.edges,
|
||||
{
|
||||
strategy: 'smart',
|
||||
direction: 'auto',
|
||||
spacing: {
|
||||
horizontal: 400,
|
||||
vertical: 200,
|
||||
layer: 600,
|
||||
},
|
||||
alignment: 'center',
|
||||
padding: {
|
||||
x: 200,
|
||||
y: 200,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
newWorkflowState.blocks = layoutedBlocks
|
||||
logger.info(`[${requestId}] Autolayout completed successfully`)
|
||||
} catch (layoutError) {
|
||||
logger.warn(`[${requestId}] Autolayout failed, using original positions:`, layoutError)
|
||||
}
|
||||
}
|
||||
|
||||
// Save to database
|
||||
const saveResult = await saveWorkflowToNormalizedTables(workflowId, newWorkflowState)
|
||||
|
||||
if (!saveResult.success) {
|
||||
logger.error(`[${requestId}] Failed to save workflow state:`, saveResult.error)
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
message: `Database save failed: ${saveResult.error || 'Unknown error'}`,
|
||||
errors: [saveResult.error || 'Database save failed'],
|
||||
warnings,
|
||||
})
|
||||
}
|
||||
|
||||
// Update workflow's lastSynced timestamp
|
||||
await db
|
||||
.update(workflowTable)
|
||||
.set({
|
||||
lastSynced: new Date(),
|
||||
updatedAt: new Date(),
|
||||
state: saveResult.jsonBlob,
|
||||
})
|
||||
.where(eq(workflowTable.id, workflowId))
|
||||
|
||||
// Notify socket server for real-time collaboration (for copilot and editor)
|
||||
if (source === 'copilot' || source === 'editor') {
|
||||
try {
|
||||
const socketUrl = process.env.SOCKET_URL || 'http://localhost:3002'
|
||||
await fetch(`${socketUrl}/api/copilot-workflow-edit`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
workflowId,
|
||||
description: description || `${source} edited workflow`,
|
||||
}),
|
||||
})
|
||||
logger.info(`[${requestId}] Notified socket server`)
|
||||
} catch (socketError) {
|
||||
logger.warn(`[${requestId}] Failed to notify socket server:`, socketError)
|
||||
}
|
||||
}
|
||||
|
||||
const elapsed = Date.now() - startTime
|
||||
const totalBlocksInWorkflow = Object.keys(newWorkflowState.blocks).length
|
||||
const summary = `Successfully saved workflow with ${totalBlocksInWorkflow} blocks and ${newWorkflowState.edges.length} connections.`
|
||||
|
||||
logger.info(`[${requestId}] YAML workflow save completed in ${elapsed}ms`, {
|
||||
success: true,
|
||||
blocksCount: totalBlocksInWorkflow,
|
||||
edgesCount: newWorkflowState.edges.length,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: description ? `Workflow updated: ${description}` : 'Workflow updated successfully',
|
||||
summary,
|
||||
data: {
|
||||
blocksCount: totalBlocksInWorkflow,
|
||||
edgesCount: newWorkflowState.edges.length,
|
||||
loopsCount: Object.keys(loops).length,
|
||||
parallelsCount: Object.keys(parallels).length,
|
||||
},
|
||||
errors: [],
|
||||
warnings,
|
||||
})
|
||||
} catch (error) {
|
||||
const elapsed = Date.now() - startTime
|
||||
logger.error(`[${requestId}] YAML workflow save failed in ${elapsed}ms:`, error)
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: 'Invalid request data',
|
||||
errors: error.errors.map((e) => `${e.path.join('.')}: ${e.message}`),
|
||||
warnings: [],
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: `Failed to save YAML workflow: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
errors: [error instanceof Error ? error.message : 'Unknown error'],
|
||||
warnings: [],
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,12 @@ import crypto from 'crypto'
|
||||
import { and, desc, eq, isNull } from 'drizzle-orm'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { db } from '@/db'
|
||||
import { permissions, workflow, workflowBlocks, workspace } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('Workspaces')
|
||||
|
||||
// Get all workspaces for the current user
|
||||
export async function GET() {
|
||||
const session = await getSession()
|
||||
@@ -244,12 +247,12 @@ async function createWorkspace(userId: string, name: string) {
|
||||
updatedAt: now,
|
||||
})
|
||||
|
||||
console.log(
|
||||
`✅ Created workspace ${workspaceId} with initial workflow ${workflowId} for user ${userId}`
|
||||
logger.info(
|
||||
`Created workspace ${workspaceId} with initial workflow ${workflowId} for user ${userId}`
|
||||
)
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(`❌ Failed to create workspace ${workspaceId} with initial workflow:`, error)
|
||||
logger.error(`Failed to create workspace ${workspaceId} with initial workflow:`, error)
|
||||
throw error
|
||||
}
|
||||
|
||||
@@ -276,7 +279,7 @@ async function migrateExistingWorkflows(userId: string, workspaceId: string) {
|
||||
return // No orphaned workflows to migrate
|
||||
}
|
||||
|
||||
console.log(
|
||||
logger.info(
|
||||
`Migrating ${orphanedWorkflows.length} workflows to workspace ${workspaceId} for user ${userId}`
|
||||
)
|
||||
|
||||
@@ -308,6 +311,6 @@ async function ensureWorkflowsHaveWorkspace(userId: string, defaultWorkspaceId:
|
||||
})
|
||||
.where(and(eq(workflow.userId, userId), isNull(workflow.workspaceId)))
|
||||
|
||||
console.log(`Fixed ${orphanedWorkflows.length} orphaned workflows for user ${userId}`)
|
||||
logger.info(`Fixed ${orphanedWorkflows.length} orphaned workflows for user ${userId}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
import { memo, useMemo, useState } from 'react'
|
||||
import { Check, Copy } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ToolCallCompletion, ToolCallExecution } from '@/components/ui/tool-call'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { parseMessageContent, stripToolCallIndicators } from '@/lib/tool-call-parser'
|
||||
import MarkdownRenderer from './components/markdown-renderer'
|
||||
|
||||
export interface ChatMessage {
|
||||
@@ -31,6 +33,22 @@ export const ClientChatMessage = memo(
|
||||
return typeof message.content === 'object' && message.content !== null
|
||||
}, [message.content])
|
||||
|
||||
// Parse message content to separate text and tool calls (only for assistant messages)
|
||||
const parsedContent = useMemo(() => {
|
||||
if (message.type === 'assistant' && typeof message.content === 'string') {
|
||||
return parseMessageContent(message.content)
|
||||
}
|
||||
return null
|
||||
}, [message.type, message.content])
|
||||
|
||||
// Get clean text content without tool call indicators
|
||||
const cleanTextContent = useMemo(() => {
|
||||
if (message.type === 'assistant' && typeof message.content === 'string') {
|
||||
return stripToolCallIndicators(message.content)
|
||||
}
|
||||
return message.content
|
||||
}, [message.type, message.content])
|
||||
|
||||
// For user messages (on the right)
|
||||
if (message.type === 'user') {
|
||||
return (
|
||||
@@ -56,18 +74,58 @@ export const ClientChatMessage = memo(
|
||||
return (
|
||||
<div className='px-4 pt-5 pb-2' data-message-id={message.id}>
|
||||
<div className='mx-auto max-w-3xl'>
|
||||
<div className='flex flex-col'>
|
||||
<div>
|
||||
<div className='break-words text-base'>
|
||||
{isJsonObject ? (
|
||||
<pre className='text-gray-800 dark:text-gray-100'>
|
||||
{JSON.stringify(message.content, null, 2)}
|
||||
</pre>
|
||||
) : (
|
||||
<EnhancedMarkdownRenderer content={message.content as string} />
|
||||
)}
|
||||
<div className='flex flex-col space-y-3'>
|
||||
{/* Inline content rendering - tool calls and text in order */}
|
||||
{parsedContent?.inlineContent && parsedContent.inlineContent.length > 0 ? (
|
||||
<div className='space-y-2'>
|
||||
{parsedContent.inlineContent.map((item, index) => {
|
||||
if (item.type === 'tool_call' && item.toolCall) {
|
||||
const toolCall = item.toolCall
|
||||
return (
|
||||
<div key={`${toolCall.id}-${index}`}>
|
||||
{toolCall.state === 'detecting' && (
|
||||
<div className='flex items-center gap-2 rounded-lg border border-blue-200 bg-blue-50 px-3 py-2 text-sm dark:border-blue-800 dark:bg-blue-950'>
|
||||
<div className='h-4 w-4 animate-spin rounded-full border-2 border-blue-600 border-t-transparent dark:border-blue-400' />
|
||||
<span className='text-blue-800 dark:text-blue-200'>
|
||||
Detecting {toolCall.displayName || toolCall.name}...
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{toolCall.state === 'executing' && (
|
||||
<ToolCallExecution toolCall={toolCall} isCompact={true} />
|
||||
)}
|
||||
{(toolCall.state === 'completed' || toolCall.state === 'error') && (
|
||||
<ToolCallCompletion toolCall={toolCall} isCompact={true} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (item.type === 'text' && item.content.trim()) {
|
||||
return (
|
||||
<div key={`text-${index}`}>
|
||||
<div className='break-words text-base'>
|
||||
<EnhancedMarkdownRenderer content={item.content} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Fallback for empty content or no inline content */
|
||||
<div>
|
||||
<div className='break-words text-base'>
|
||||
{isJsonObject ? (
|
||||
<pre className='text-gray-800 dark:text-gray-100'>
|
||||
{JSON.stringify(cleanTextContent, null, 2)}
|
||||
</pre>
|
||||
) : (
|
||||
<EnhancedMarkdownRenderer content={cleanTextContent as string} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{message.type === 'assistant' && !isJsonObject && !message.isInitialMessage && (
|
||||
<div className='flex items-center justify-start space-x-2'>
|
||||
{/* Copy Button - Only show when not streaming */}
|
||||
@@ -80,7 +138,11 @@ export const ClientChatMessage = memo(
|
||||
size='sm'
|
||||
className='flex items-center gap-1.5 px-2 py-1'
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(message.content as string)
|
||||
const contentToCopy =
|
||||
typeof cleanTextContent === 'string'
|
||||
? cleanTextContent
|
||||
: JSON.stringify(cleanTextContent, null, 2)
|
||||
navigator.clipboard.writeText(contentToCopy)
|
||||
setIsCopied(true)
|
||||
setTimeout(() => setIsCopied(false), 2000)
|
||||
}}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { notFound } from 'next/navigation'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { db } from '@/db'
|
||||
import { templateStars, templates } from '@/db/schema'
|
||||
import type { Template } from '../templates'
|
||||
import TemplateDetails from './template'
|
||||
|
||||
const logger = createLogger('TemplatePage')
|
||||
|
||||
interface TemplatePageProps {
|
||||
params: Promise<{
|
||||
workspaceId: string
|
||||
@@ -58,7 +61,7 @@ export default async function TemplatePage({ params }: TemplatePageProps) {
|
||||
|
||||
// Validate that required fields are present
|
||||
if (!template.id || !template.name || !template.author) {
|
||||
console.error('Template missing required fields:', {
|
||||
logger.error('Template missing required fields:', {
|
||||
id: template.id,
|
||||
name: template.name,
|
||||
author: template.author,
|
||||
@@ -100,9 +103,9 @@ export default async function TemplatePage({ params }: TemplatePageProps) {
|
||||
isStarred,
|
||||
}
|
||||
|
||||
console.log('Template from DB:', template)
|
||||
console.log('Serialized template:', serializedTemplate)
|
||||
console.log('Template state from DB:', template.state)
|
||||
logger.info('Template from DB:', template)
|
||||
logger.info('Serialized template:', serializedTemplate)
|
||||
logger.info('Template state from DB:', template.state)
|
||||
|
||||
return (
|
||||
<TemplateDetails
|
||||
|
||||
@@ -143,7 +143,7 @@ export default function TemplateDetails({
|
||||
const renderWorkflowPreview = () => {
|
||||
// Follow the same pattern as deployed-workflow-card.tsx
|
||||
if (!template?.state) {
|
||||
console.log('Template has no state:', template)
|
||||
logger.info('Template has no state:', template)
|
||||
return (
|
||||
<div className='flex h-full items-center justify-center text-center'>
|
||||
<div className='text-muted-foreground'>
|
||||
@@ -154,10 +154,10 @@ export default function TemplateDetails({
|
||||
)
|
||||
}
|
||||
|
||||
console.log('Template state:', template.state)
|
||||
console.log('Template state type:', typeof template.state)
|
||||
console.log('Template state blocks:', template.state.blocks)
|
||||
console.log('Template state edges:', template.state.edges)
|
||||
logger.info('Template state:', template.state)
|
||||
logger.info('Template state type:', typeof template.state)
|
||||
logger.info('Template state blocks:', template.state.blocks)
|
||||
logger.info('Template state edges:', template.state.edges)
|
||||
|
||||
try {
|
||||
return (
|
||||
|
||||
@@ -92,7 +92,7 @@ export default function Templates({ initialTemplates, currentUserId }: Templates
|
||||
|
||||
const handleCreateNew = () => {
|
||||
// TODO: Open create template modal or navigate to create page
|
||||
console.log('Create new template')
|
||||
logger.info('Create new template')
|
||||
}
|
||||
|
||||
// Handle star change callback from template card
|
||||
|
||||
@@ -30,9 +30,8 @@ import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { isDev } from '@/lib/environment'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { getBaseDomain } from '@/lib/urls/utils'
|
||||
import { getBaseDomain, getEmailDomain } from '@/lib/urls/utils'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { OutputSelect } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/chat/components/output-select/output-select'
|
||||
import type { OutputConfig } from '@/stores/panel/chat/types'
|
||||
@@ -54,11 +53,10 @@ interface ChatDeployProps {
|
||||
type AuthType = 'public' | 'password' | 'email'
|
||||
|
||||
const getDomainSuffix = (() => {
|
||||
const suffix = isDev ? `.${getBaseDomain()}` : '.simstudio.ai'
|
||||
const suffix = `.${getEmailDomain()}`
|
||||
return () => suffix
|
||||
})()
|
||||
|
||||
// Define Zod schema for API request validation
|
||||
const chatSchema = z.object({
|
||||
workflowId: z.string().min(1, 'Workflow ID is required'),
|
||||
subdomain: z
|
||||
@@ -124,10 +122,6 @@ export function ChatDeploy({
|
||||
selectedOutputIds: string[]
|
||||
} | null>(null)
|
||||
|
||||
// State to track if any changes have been made
|
||||
const [hasChanges, setHasChanges] = useState(false)
|
||||
|
||||
// Confirmation dialogs
|
||||
const [showEditConfirmation, setShowEditConfirmation] = useState(false)
|
||||
const [internalShowDeleteConfirmation, setInternalShowDeleteConfirmation] = useState(false)
|
||||
|
||||
@@ -184,53 +178,6 @@ export function ChatDeploy({
|
||||
}
|
||||
}, [workflowId])
|
||||
|
||||
// Check for changes when form values update
|
||||
useEffect(() => {
|
||||
if (originalValues && existingChat) {
|
||||
const currentAuthTypeChanged = authType !== originalValues.authType
|
||||
const subdomainChanged = subdomain !== originalValues.subdomain
|
||||
const titleChanged = title !== originalValues.title
|
||||
const descriptionChanged = description !== originalValues.description
|
||||
const outputBlockChanged = selectedOutputBlocks.some(
|
||||
(blockId) => !originalValues.selectedOutputIds.includes(blockId)
|
||||
)
|
||||
const welcomeMessageChanged =
|
||||
welcomeMessage !==
|
||||
(existingChat.customizations?.welcomeMessage || 'Hi there! How can I help you today?')
|
||||
|
||||
// Check if emails have changed
|
||||
const emailsChanged =
|
||||
emails.length !== originalValues.emails.length ||
|
||||
emails.some((email) => !originalValues.emails.includes(email))
|
||||
|
||||
// Check if password has changed - any value in password field means change
|
||||
const passwordChanged = password.length > 0
|
||||
|
||||
// Determine if any changes have been made
|
||||
const changed =
|
||||
subdomainChanged ||
|
||||
titleChanged ||
|
||||
descriptionChanged ||
|
||||
currentAuthTypeChanged ||
|
||||
emailsChanged ||
|
||||
passwordChanged ||
|
||||
outputBlockChanged ||
|
||||
welcomeMessageChanged
|
||||
|
||||
setHasChanges(changed)
|
||||
}
|
||||
}, [
|
||||
subdomain,
|
||||
title,
|
||||
description,
|
||||
authType,
|
||||
emails,
|
||||
password,
|
||||
selectedOutputBlocks,
|
||||
welcomeMessage,
|
||||
originalValues,
|
||||
])
|
||||
|
||||
// Fetch existing chat data for this workflow
|
||||
const fetchExistingChat = async () => {
|
||||
try {
|
||||
@@ -310,7 +257,6 @@ export function ChatDeploy({
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
setDataFetched(true)
|
||||
setHasChanges(false) // Reset changes detection after loading
|
||||
}
|
||||
}
|
||||
|
||||
@@ -490,6 +436,8 @@ export function ChatDeploy({
|
||||
(!originalValues || subdomain !== originalValues.subdomain)
|
||||
) {
|
||||
setIsCheckingSubdomain(true)
|
||||
setSubdomainError('')
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/chat/subdomains/validate?subdomain=${encodeURIComponent(subdomain)}`
|
||||
@@ -497,11 +445,15 @@ export function ChatDeploy({
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok || !data.available) {
|
||||
setSubdomainError('This subdomain is already in use')
|
||||
const errorMsg = data.error || 'This subdomain is already in use'
|
||||
setSubdomainError(errorMsg)
|
||||
setChatSubmitting(false)
|
||||
setIsCheckingSubdomain(false)
|
||||
logger.warn('Subdomain validation failed:', errorMsg)
|
||||
return
|
||||
}
|
||||
|
||||
setSubdomainError('')
|
||||
} catch (error) {
|
||||
logger.error('Error checking subdomain availability:', error)
|
||||
setSubdomainError('Error checking subdomain availability')
|
||||
@@ -512,15 +464,16 @@ export function ChatDeploy({
|
||||
setIsCheckingSubdomain(false)
|
||||
}
|
||||
|
||||
// Verify output selection if it's set
|
||||
if (selectedOutputBlocks.length === 0) {
|
||||
logger.error('No output blocks selected')
|
||||
setErrorMessage('Please select at least one output block')
|
||||
if (subdomainError) {
|
||||
logger.warn('Blocking submission due to subdomain error:', subdomainError)
|
||||
setChatSubmitting(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (subdomainError) {
|
||||
// Verify output selection if it's set
|
||||
if (selectedOutputBlocks.length === 0) {
|
||||
logger.error('No output blocks selected')
|
||||
setErrorMessage('Please select at least one output block')
|
||||
setChatSubmitting(false)
|
||||
return
|
||||
}
|
||||
@@ -722,6 +675,11 @@ export function ChatDeploy({
|
||||
const result = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
if (result.error === 'Subdomain already in use') {
|
||||
setSubdomainError(result.error)
|
||||
setChatSubmitting(false)
|
||||
return
|
||||
}
|
||||
throw new Error(result.error || `Failed to ${existingChat ? 'update' : 'deploy'} chat`)
|
||||
}
|
||||
|
||||
@@ -743,7 +701,14 @@ export function ChatDeploy({
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error(`Failed to ${existingChat ? 'update' : 'deploy'} chat:`, error)
|
||||
setErrorMessage(error.message || 'An unexpected error occurred')
|
||||
|
||||
const errorMessage = error.message || 'An unexpected error occurred'
|
||||
if (errorMessage.includes('Subdomain already in use') || errorMessage.includes('subdomain')) {
|
||||
setSubdomainError(errorMessage)
|
||||
} else {
|
||||
setErrorMessage(errorMessage)
|
||||
}
|
||||
|
||||
logger.error(`Failed to deploy chat: ${error.message}`, workflowId)
|
||||
} finally {
|
||||
setChatSubmitting(false)
|
||||
@@ -751,26 +716,6 @@ export function ChatDeploy({
|
||||
}
|
||||
}
|
||||
|
||||
// Determine button label based on state
|
||||
const _getSubmitButtonLabel = () => {
|
||||
return existingChat ? 'Update Chat' : 'Deploy Chat'
|
||||
}
|
||||
|
||||
// Check if form submission is possible
|
||||
const _isFormSubmitDisabled = () => {
|
||||
return (
|
||||
chatSubmitting ||
|
||||
isDeleting ||
|
||||
!subdomain ||
|
||||
!title ||
|
||||
!!subdomainError ||
|
||||
isCheckingSubdomain ||
|
||||
(authType === 'password' && !password && !existingChat) ||
|
||||
(authType === 'email' && emails.length === 0) ||
|
||||
(existingChat && !hasChanges)
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className='space-y-4 py-3'>
|
||||
@@ -827,12 +772,13 @@ export function ChatDeploy({
|
||||
const port = url.port || (baseDomain.includes(':') ? baseDomain.split(':')[1] : '3000')
|
||||
domainSuffix = `.${baseHost}:${port}`
|
||||
} else {
|
||||
domainSuffix = '.simstudio.ai'
|
||||
domainSuffix = `.${getEmailDomain()}`
|
||||
}
|
||||
|
||||
const baseDomainForSplit = getEmailDomain()
|
||||
const subdomainPart = isDevelopmentUrl
|
||||
? hostname.split('.')[0]
|
||||
: hostname.split('.simstudio.ai')[0]
|
||||
: hostname.split(`.${baseDomainForSplit}`)[0]
|
||||
|
||||
// Success view - simplified with no buttons
|
||||
return (
|
||||
@@ -996,11 +942,6 @@ export function ChatDeploy({
|
||||
onOutputSelect={(values) => {
|
||||
logger.info(`Output block selection changed to: ${values}`)
|
||||
setSelectedOutputBlocks(values)
|
||||
|
||||
// Mark as changed to enable update button
|
||||
if (existingChat) {
|
||||
setHasChanges(true)
|
||||
}
|
||||
}}
|
||||
placeholder='Select which block outputs to use'
|
||||
disabled={isDeploying}
|
||||
@@ -1306,7 +1247,10 @@ export function ChatDeploy({
|
||||
<AlertDialogTitle>Delete Chat?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently delete your chat deployment at{' '}
|
||||
<span className='font-mono text-destructive'>{subdomain}.simstudio.ai</span>.
|
||||
<span className='font-mono text-destructive'>
|
||||
{subdomain}.{getEmailDomain()}
|
||||
</span>
|
||||
.
|
||||
<p className='mt-2'>
|
||||
All users will lose access immediately, and this action cannot be undone.
|
||||
</p>
|
||||
|
||||
@@ -17,7 +17,7 @@ import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { CopyButton } from '@/components/ui/copy-button'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { env } from '@/lib/env'
|
||||
import { getEnv } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { ChatDeploy } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/chat-deploy'
|
||||
@@ -225,7 +225,7 @@ export function DeployModal({
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const endpoint = `${env.NEXT_PUBLIC_APP_URL}/api/workflows/${workflowId}/execute`
|
||||
const endpoint = `${getEnv('NEXT_PUBLIC_APP_URL')}/api/workflows/${workflowId}/execute`
|
||||
const inputFormatExample = getInputFormatExample()
|
||||
|
||||
setDeploymentInfo({
|
||||
@@ -288,7 +288,7 @@ export function DeployModal({
|
||||
}
|
||||
|
||||
// Update the local deployment info
|
||||
const endpoint = `${env.NEXT_PUBLIC_APP_URL}/api/workflows/${workflowId}/execute`
|
||||
const endpoint = `${getEnv('NEXT_PUBLIC_APP_URL')}/api/workflows/${workflowId}/execute`
|
||||
const inputFormatExample = getInputFormatExample()
|
||||
|
||||
const newDeploymentInfo = {
|
||||
@@ -597,7 +597,7 @@ export function DeployModal({
|
||||
<DeployForm
|
||||
apiKeys={apiKeys}
|
||||
keysLoaded={keysLoaded}
|
||||
endpointUrl={`${env.NEXT_PUBLIC_APP_URL}/api/workflows/${workflowId}/execute`}
|
||||
endpointUrl={`${getEnv('NEXT_PUBLIC_APP_URL')}/api/workflows/${workflowId}/execute`}
|
||||
workflowId={workflowId || ''}
|
||||
onSubmit={onDeploy}
|
||||
getInputFormatExample={getInputFormatExample}
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
useKeyboardShortcuts,
|
||||
} from '../../../hooks/use-keyboard-shortcuts'
|
||||
import { useWorkflowExecution } from '../../hooks/use-workflow-execution'
|
||||
import { WorkflowTextEditorModal } from '../workflow-text-editor/workflow-text-editor-modal'
|
||||
import { DeploymentControls } from './components/deployment-controls/deployment-controls'
|
||||
import { ExportControls } from './components/export-controls/export-controls'
|
||||
import { TemplateModal } from './components/template-modal/template-modal'
|
||||
@@ -508,6 +509,36 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Render YAML editor button
|
||||
*/
|
||||
const renderYamlEditorButton = () => {
|
||||
const canEdit = userPermissions.canEdit
|
||||
const isDisabled = isExecuting || isDebugging || !canEdit
|
||||
|
||||
const getTooltipText = () => {
|
||||
if (!canEdit) return 'Admin permission required to edit YAML'
|
||||
if (isDebugging) return 'Cannot edit YAML while debugging'
|
||||
if (isExecuting) return 'Cannot edit YAML while workflow is running'
|
||||
return 'Edit workflow as YAML/JSON'
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<WorkflowTextEditorModal
|
||||
disabled={isDisabled}
|
||||
className={cn(
|
||||
'h-12 w-12 rounded-[11px] border bg-card text-card-foreground shadow-xs',
|
||||
isDisabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer hover:bg-secondary'
|
||||
)}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{getTooltipText()}</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Render auto-layout button
|
||||
*/
|
||||
@@ -943,6 +974,7 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
|
||||
{renderDisconnectionNotice()}
|
||||
{renderToggleButton()}
|
||||
{isExpanded && <ExportControls />}
|
||||
{isExpanded && renderYamlEditorButton()}
|
||||
{isExpanded && renderAutoLayoutButton()}
|
||||
{isExpanded && renderDuplicateButton()}
|
||||
{renderDeleteButton()}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { type KeyboardEvent, useEffect, useMemo, useRef } from 'react'
|
||||
import { type KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { ArrowUp } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
@@ -41,6 +41,13 @@ export function Chat({ panelWidth, chatMessage, setChatMessage }: ChatProps) {
|
||||
} = useChatStore()
|
||||
const { entries } = useConsoleStore()
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const abortControllerRef = useRef<AbortController | null>(null)
|
||||
|
||||
// Prompt history state
|
||||
const [promptHistory, setPromptHistory] = useState<string[]>([])
|
||||
const [historyIndex, setHistoryIndex] = useState(-1)
|
||||
|
||||
// Use the execution store state to track if a workflow is executing
|
||||
const { isExecuting } = useExecutionStore()
|
||||
@@ -62,6 +69,26 @@ export function Chat({ panelWidth, chatMessage, setChatMessage }: ChatProps) {
|
||||
.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())
|
||||
}, [messages, activeWorkflowId])
|
||||
|
||||
// Memoize user messages for performance
|
||||
const userMessages = useMemo(() => {
|
||||
return workflowMessages
|
||||
.filter((msg) => msg.type === 'user')
|
||||
.map((msg) => msg.content)
|
||||
.filter((content): content is string => typeof content === 'string')
|
||||
}, [workflowMessages])
|
||||
|
||||
// Update prompt history when workflow changes
|
||||
useEffect(() => {
|
||||
if (!activeWorkflowId) {
|
||||
setPromptHistory([])
|
||||
setHistoryIndex(-1)
|
||||
return
|
||||
}
|
||||
|
||||
setPromptHistory(userMessages)
|
||||
setHistoryIndex(-1)
|
||||
}, [activeWorkflowId, userMessages])
|
||||
|
||||
// Get selected workflow outputs
|
||||
const selectedOutputs = useMemo(() => {
|
||||
if (!activeWorkflowId) return []
|
||||
@@ -84,6 +111,31 @@ export function Chat({ panelWidth, chatMessage, setChatMessage }: ChatProps) {
|
||||
return selected
|
||||
}, [selectedWorkflowOutputs, activeWorkflowId, setSelectedWorkflowOutput])
|
||||
|
||||
// Focus input helper with proper cleanup
|
||||
const focusInput = useCallback((delay = 0) => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current)
|
||||
}
|
||||
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
if (inputRef.current && document.contains(inputRef.current)) {
|
||||
inputRef.current.focus({ preventScroll: true })
|
||||
}
|
||||
}, delay)
|
||||
}, [])
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current)
|
||||
}
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort()
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Auto-scroll to bottom when new messages are added
|
||||
useEffect(() => {
|
||||
if (messagesEndRef.current) {
|
||||
@@ -92,12 +144,26 @@ export function Chat({ panelWidth, chatMessage, setChatMessage }: ChatProps) {
|
||||
}, [workflowMessages])
|
||||
|
||||
// Handle send message
|
||||
const handleSendMessage = async () => {
|
||||
const handleSendMessage = useCallback(async () => {
|
||||
if (!chatMessage.trim() || !activeWorkflowId || isExecuting) return
|
||||
|
||||
// Store the message being sent for reference
|
||||
const sentMessage = chatMessage.trim()
|
||||
|
||||
// Add to prompt history if it's not already the most recent
|
||||
if (promptHistory.length === 0 || promptHistory[promptHistory.length - 1] !== sentMessage) {
|
||||
setPromptHistory((prev) => [...prev, sentMessage])
|
||||
}
|
||||
|
||||
// Reset history index
|
||||
setHistoryIndex(-1)
|
||||
|
||||
// Cancel any existing operations
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort()
|
||||
}
|
||||
abortControllerRef.current = new AbortController()
|
||||
|
||||
// Get the conversationId for this workflow before adding the message
|
||||
const conversationId = getConversationId(activeWorkflowId)
|
||||
|
||||
@@ -108,8 +174,9 @@ export function Chat({ panelWidth, chatMessage, setChatMessage }: ChatProps) {
|
||||
type: 'user',
|
||||
})
|
||||
|
||||
// Clear input
|
||||
// Clear input and refocus immediately
|
||||
setChatMessage('')
|
||||
focusInput(10)
|
||||
|
||||
// Execute the workflow to generate a response, passing the chat message and conversationId as input
|
||||
const result = await handleRunWorkflow({
|
||||
@@ -223,7 +290,12 @@ export function Chat({ panelWidth, chatMessage, setChatMessage }: ChatProps) {
|
||||
}
|
||||
}
|
||||
|
||||
processStream().catch((e) => logger.error('Error processing stream:', e))
|
||||
processStream()
|
||||
.catch((e) => logger.error('Error processing stream:', e))
|
||||
.finally(() => {
|
||||
// Restore focus after streaming completes
|
||||
focusInput(100)
|
||||
})
|
||||
} else if (result && 'success' in result && result.success && 'logs' in result) {
|
||||
const finalOutputs: any[] = []
|
||||
|
||||
@@ -287,30 +359,72 @@ export function Chat({ panelWidth, chatMessage, setChatMessage }: ChatProps) {
|
||||
type: 'workflow',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Restore focus after workflow execution completes
|
||||
focusInput(100)
|
||||
}, [
|
||||
chatMessage,
|
||||
activeWorkflowId,
|
||||
isExecuting,
|
||||
promptHistory,
|
||||
getConversationId,
|
||||
addMessage,
|
||||
handleRunWorkflow,
|
||||
selectedOutputs,
|
||||
setSelectedWorkflowOutput,
|
||||
appendMessageContent,
|
||||
finalizeMessageStream,
|
||||
focusInput,
|
||||
])
|
||||
|
||||
// Handle key press
|
||||
const handleKeyPress = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSendMessage()
|
||||
}
|
||||
}
|
||||
const handleKeyPress = useCallback(
|
||||
(e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSendMessage()
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
if (promptHistory.length > 0) {
|
||||
const newIndex =
|
||||
historyIndex === -1 ? promptHistory.length - 1 : Math.max(0, historyIndex - 1)
|
||||
setHistoryIndex(newIndex)
|
||||
setChatMessage(promptHistory[newIndex])
|
||||
}
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
if (historyIndex >= 0) {
|
||||
const newIndex = historyIndex + 1
|
||||
if (newIndex >= promptHistory.length) {
|
||||
setHistoryIndex(-1)
|
||||
setChatMessage('')
|
||||
} else {
|
||||
setHistoryIndex(newIndex)
|
||||
setChatMessage(promptHistory[newIndex])
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[handleSendMessage, promptHistory, historyIndex, setChatMessage]
|
||||
)
|
||||
|
||||
// Handle output selection
|
||||
const handleOutputSelection = (values: string[]) => {
|
||||
// Ensure no duplicates in selection
|
||||
const dedupedValues = [...new Set(values)]
|
||||
const handleOutputSelection = useCallback(
|
||||
(values: string[]) => {
|
||||
// Ensure no duplicates in selection
|
||||
const dedupedValues = [...new Set(values)]
|
||||
|
||||
if (activeWorkflowId) {
|
||||
// If array is empty, explicitly set to empty array to ensure complete reset
|
||||
if (dedupedValues.length === 0) {
|
||||
setSelectedWorkflowOutput(activeWorkflowId, [])
|
||||
} else {
|
||||
setSelectedWorkflowOutput(activeWorkflowId, dedupedValues)
|
||||
if (activeWorkflowId) {
|
||||
// If array is empty, explicitly set to empty array to ensure complete reset
|
||||
if (dedupedValues.length === 0) {
|
||||
setSelectedWorkflowOutput(activeWorkflowId, [])
|
||||
} else {
|
||||
setSelectedWorkflowOutput(activeWorkflowId, dedupedValues)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[activeWorkflowId, setSelectedWorkflowOutput]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className='flex h-full flex-col'>
|
||||
@@ -349,8 +463,12 @@ export function Chat({ panelWidth, chatMessage, setChatMessage }: ChatProps) {
|
||||
<div className='-mt-[1px] relative flex-nonept-3 pb-4'>
|
||||
<div className='flex gap-2'>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={chatMessage}
|
||||
onChange={(e) => setChatMessage(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setChatMessage(e.target.value)
|
||||
setHistoryIndex(-1) // Reset history index when typing
|
||||
}}
|
||||
onKeyDown={handleKeyPress}
|
||||
placeholder='Type a message...'
|
||||
className='h-9 flex-1 rounded-lg border-[#E5E5E5] bg-[#FFFFFF] text-muted-foreground shadow-xs focus-visible:ring-0 focus-visible:ring-offset-0 dark:border-[#414141] dark:bg-[#202020]'
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import { AlertCircle, History, RotateCcw } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { useCopilotStore } from '@/stores/copilot/store'
|
||||
|
||||
export function CheckpointPanel() {
|
||||
const {
|
||||
currentChat,
|
||||
checkpoints,
|
||||
isLoadingCheckpoints,
|
||||
isRevertingCheckpoint,
|
||||
checkpointError,
|
||||
loadCheckpoints,
|
||||
revertToCheckpoint: revertToCheckpointAction,
|
||||
clearCheckpointError,
|
||||
} = useCopilotStore()
|
||||
|
||||
// Load checkpoints when chat changes
|
||||
useEffect(() => {
|
||||
if (currentChat?.id) {
|
||||
loadCheckpoints(currentChat.id)
|
||||
}
|
||||
}, [currentChat?.id, loadCheckpoints])
|
||||
|
||||
if (!currentChat) {
|
||||
return (
|
||||
<div className='p-4 text-center text-muted-foreground'>
|
||||
<History className='mx-auto mb-2 h-8 w-8' />
|
||||
<p>No chat selected</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoadingCheckpoints) {
|
||||
return (
|
||||
<div className='p-4 text-center text-muted-foreground'>
|
||||
<div className='mx-auto mb-2 h-6 w-6 animate-spin rounded-full border-2 border-gray-300 border-t-gray-600' />
|
||||
<p>Loading checkpoints...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (checkpointError) {
|
||||
return (
|
||||
<div className='p-4'>
|
||||
<div className='mb-3 flex items-center gap-2 text-red-600'>
|
||||
<AlertCircle className='h-4 w-4' />
|
||||
<span className='font-medium text-sm'>Error loading checkpoints</span>
|
||||
</div>
|
||||
<p className='mb-3 text-muted-foreground text-xs'>{checkpointError}</p>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='outline'
|
||||
onClick={() => {
|
||||
clearCheckpointError()
|
||||
loadCheckpoints(currentChat.id)
|
||||
}}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (checkpoints.length === 0) {
|
||||
return (
|
||||
<div className='p-4 text-center text-muted-foreground'>
|
||||
<History className='mx-auto mb-2 h-8 w-8' />
|
||||
<p className='text-sm'>No checkpoints yet</p>
|
||||
<p className='mt-1 text-xs'>
|
||||
Checkpoints are created automatically when the agent edits your workflow
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const handleRevert = async (checkpointId: string) => {
|
||||
if (
|
||||
window.confirm(
|
||||
'Are you sure you want to revert to this checkpoint? This will replace your current workflow.'
|
||||
)
|
||||
) {
|
||||
await revertToCheckpointAction(checkpointId)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex h-full flex-col'>
|
||||
<div className='border-b p-4'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<History className='h-4 w-4' />
|
||||
<h3 className='font-medium text-sm'>Workflow Checkpoints</h3>
|
||||
</div>
|
||||
<p className='mt-1 text-muted-foreground text-xs'>
|
||||
Restore your workflow to a previous state
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ScrollArea className='flex-1'>
|
||||
<div className='p-2'>
|
||||
{checkpoints.map((checkpoint, index) => (
|
||||
<div key={checkpoint.id} className='mb-2'>
|
||||
<div className='rounded-lg border bg-card p-3 transition-colors hover:bg-accent/50'>
|
||||
<div className='flex items-start justify-between gap-2'>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<div className='mb-1 flex items-center gap-2'>
|
||||
<div className='h-2 w-2 rounded-full bg-purple-500' />
|
||||
<span className='font-medium text-muted-foreground text-xs'>
|
||||
Checkpoint {checkpoints.length - index}
|
||||
</span>
|
||||
</div>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{formatDistanceToNow(new Date(checkpoint.createdAt), { addSuffix: true })}
|
||||
</p>
|
||||
<p className='mt-1 text-muted-foreground text-xs'>
|
||||
{new Date(checkpoint.createdAt).toLocaleDateString()} at{' '}
|
||||
{new Date(checkpoint.createdAt).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='ghost'
|
||||
className='h-6 px-2 text-xs'
|
||||
onClick={() => handleRevert(checkpoint.id)}
|
||||
disabled={isRevertingCheckpoint}
|
||||
>
|
||||
<RotateCcw className='mr-1 h-3 w-3' />
|
||||
Revert
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{index < checkpoints.length - 1 && <Separator className='mt-2' />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{isRevertingCheckpoint && (
|
||||
<div className='border-t bg-muted/30 p-3'>
|
||||
<div className='flex items-center gap-2 text-muted-foreground text-sm'>
|
||||
<div className='h-4 w-4 animate-spin rounded-full border-2 border-gray-300 border-t-gray-600' />
|
||||
Reverting workflow...
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
'use client'
|
||||
|
||||
import { type KeyboardEvent, useEffect, useRef } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
ArrowUp,
|
||||
Bot,
|
||||
ChevronDown,
|
||||
History,
|
||||
MessageSquarePlus,
|
||||
MoreHorizontal,
|
||||
Trash2,
|
||||
@@ -17,126 +17,34 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import type { CopilotChat } from '@/lib/copilot-api'
|
||||
import type { CopilotChat } from '@/lib/copilot/api'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import type { CopilotMessage } from '@/stores/copilot/types'
|
||||
import { CheckpointPanel } from '../checkpoint-panel'
|
||||
import { ProfessionalInput } from '../professional-input/professional-input'
|
||||
import { ProfessionalMessage } from '../professional-message/professional-message'
|
||||
import { CopilotWelcome } from '../welcome/welcome'
|
||||
|
||||
const logger = createLogger('CopilotModal')
|
||||
|
||||
interface Message {
|
||||
id: string
|
||||
content: string
|
||||
type: 'user' | 'assistant'
|
||||
timestamp: Date
|
||||
citations?: Array<{
|
||||
id: number
|
||||
title: string
|
||||
url: string
|
||||
}>
|
||||
}
|
||||
|
||||
interface CopilotModalMessage {
|
||||
message: Message
|
||||
}
|
||||
|
||||
// Modal-specific message component
|
||||
function ModalCopilotMessage({ message }: CopilotModalMessage) {
|
||||
const renderMarkdown = (text: string) => {
|
||||
let processedText = text
|
||||
|
||||
// Process markdown links: [text](url)
|
||||
processedText = processedText.replace(
|
||||
/\[([^\]]+)\]\(([^)]+)\)/g,
|
||||
'<a href="$2" target="_blank" rel="noopener noreferrer" class="text-blue-600 hover:text-blue-800 font-semibold underline transition-colors">$1</a>'
|
||||
)
|
||||
|
||||
// Handle code blocks
|
||||
processedText = processedText.replace(
|
||||
/```(\w+)?\n([\s\S]*?)\n```/g,
|
||||
'<pre class="bg-muted rounded-md p-3 my-2 overflow-x-auto"><code class="text-sm">$2</code></pre>'
|
||||
)
|
||||
|
||||
// Handle inline code
|
||||
processedText = processedText.replace(
|
||||
/`([^`]+)`/g,
|
||||
'<code class="bg-muted px-1 rounded text-sm">$1</code>'
|
||||
)
|
||||
|
||||
// Handle headers
|
||||
processedText = processedText.replace(
|
||||
/^### (.*$)/gm,
|
||||
'<h3 class="text-lg font-semibold mt-4 mb-2">$1</h3>'
|
||||
)
|
||||
processedText = processedText.replace(
|
||||
/^## (.*$)/gm,
|
||||
'<h2 class="text-xl font-semibold mt-4 mb-2">$1</h2>'
|
||||
)
|
||||
processedText = processedText.replace(
|
||||
/^# (.*$)/gm,
|
||||
'<h1 class="text-2xl font-bold mt-4 mb-2">$1</h1>'
|
||||
)
|
||||
|
||||
// Handle bold
|
||||
processedText = processedText.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
|
||||
|
||||
// Handle lists
|
||||
processedText = processedText.replace(/^- (.*$)/gm, '<li class="ml-4">• $1</li>')
|
||||
|
||||
// Handle line breaks (reduce spacing)
|
||||
processedText = processedText.replace(/\n\n+/g, '</p><p class="mt-2">')
|
||||
processedText = processedText.replace(/\n/g, '<br>')
|
||||
|
||||
return processedText
|
||||
}
|
||||
|
||||
// For user messages (on the right)
|
||||
if (message.type === 'user') {
|
||||
return (
|
||||
<div className='px-4 py-5'>
|
||||
<div className='mx-auto max-w-3xl'>
|
||||
<div className='flex justify-end'>
|
||||
<div className='max-w-[80%] rounded-3xl bg-[#F4F4F4] px-4 py-3 shadow-sm dark:bg-primary/10'>
|
||||
<div className='whitespace-pre-wrap break-words text-[#0D0D0D] text-base leading-relaxed dark:text-white'>
|
||||
{message.content}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// For assistant messages (on the left)
|
||||
return (
|
||||
<div className='px-4 py-5'>
|
||||
<div className='mx-auto max-w-3xl'>
|
||||
<div className='flex'>
|
||||
<div className='max-w-[80%]'>
|
||||
<div
|
||||
className='prose prose-sm dark:prose-invert max-w-none whitespace-pre-wrap break-words text-base leading-normal'
|
||||
dangerouslySetInnerHTML={{ __html: renderMarkdown(message.content) }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface CopilotModalProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
copilotMessage: string
|
||||
setCopilotMessage: (message: string) => void
|
||||
messages: Message[]
|
||||
messages: CopilotMessage[]
|
||||
onSendMessage: (message: string) => Promise<void>
|
||||
isLoading: boolean
|
||||
isLoadingChats: boolean
|
||||
// Chat management props
|
||||
chats: CopilotChat[]
|
||||
currentChat: CopilotChat | null
|
||||
onSelectChat: (chat: CopilotChat) => void
|
||||
onStartNewChat: () => void
|
||||
onDeleteChat: (chatId: string) => void
|
||||
// Mode props
|
||||
mode: 'ask' | 'agent'
|
||||
onModeChange: (mode: 'ask' | 'agent') => void
|
||||
}
|
||||
|
||||
export function CopilotModal({
|
||||
@@ -147,15 +55,22 @@ export function CopilotModal({
|
||||
messages,
|
||||
onSendMessage,
|
||||
isLoading,
|
||||
isLoadingChats,
|
||||
chats,
|
||||
currentChat,
|
||||
onSelectChat,
|
||||
onStartNewChat,
|
||||
onDeleteChat,
|
||||
mode,
|
||||
onModeChange,
|
||||
}: CopilotModalProps) {
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
const messagesContainerRef = useRef<HTMLDivElement>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false)
|
||||
const [showCheckpoints, setShowCheckpoints] = useState(false)
|
||||
|
||||
// Fixed sidebar width for copilot modal positioning
|
||||
const sidebarWidth = 240 // w-60 (sidebar width from staging)
|
||||
|
||||
// Auto-scroll to bottom when new messages are added
|
||||
useEffect(() => {
|
||||
@@ -164,42 +79,13 @@ export function CopilotModal({
|
||||
}
|
||||
}, [messages])
|
||||
|
||||
// Focus input when modal opens
|
||||
useEffect(() => {
|
||||
if (open && inputRef.current) {
|
||||
inputRef.current.focus()
|
||||
}
|
||||
}, [open])
|
||||
|
||||
// Handle send message
|
||||
const handleSendMessage = async () => {
|
||||
if (!copilotMessage.trim() || isLoading) return
|
||||
|
||||
try {
|
||||
await onSendMessage(copilotMessage.trim())
|
||||
setCopilotMessage('')
|
||||
|
||||
// Ensure input stays focused
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus()
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to send message', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle key press
|
||||
const handleKeyPress = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSendMessage()
|
||||
}
|
||||
}
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div className='fixed inset-0 z-[100] flex flex-col bg-background'>
|
||||
<div
|
||||
className='fixed inset-y-0 right-0 z-[100] flex flex-col bg-background'
|
||||
style={{ left: `${sidebarWidth}px` }}
|
||||
>
|
||||
<style jsx>{`
|
||||
@keyframes growShrink {
|
||||
0%,
|
||||
@@ -215,159 +101,220 @@ export function CopilotModal({
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{/* Header with chat title, management, and close button */}
|
||||
<div className='flex items-center justify-between border-b px-4 py-3'>
|
||||
<div className='flex flex-1 items-center gap-2'>
|
||||
{/* Chat Title Dropdown */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant='ghost' className='h-8 max-w-[300px] flex-1 justify-start px-3'>
|
||||
<span className='truncate'>{currentChat?.title || 'New Chat'}</span>
|
||||
<ChevronDown className='ml-2 h-4 w-4 shrink-0' />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='start' className='z-[110] w-64' sideOffset={8}>
|
||||
{chats.map((chat) => (
|
||||
<div key={chat.id} className='flex items-center'>
|
||||
<DropdownMenuItem
|
||||
onClick={() => onSelectChat(chat)}
|
||||
className='flex-1 cursor-pointer'
|
||||
>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<div className='truncate font-medium text-sm'>
|
||||
{chat.title || 'Untitled Chat'}
|
||||
</div>
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
{chat.messageCount} messages •{' '}
|
||||
{new Date(chat.updatedAt).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant='ghost' size='sm' className='h-8 w-8 shrink-0 p-0'>
|
||||
<MoreHorizontal className='h-4 w-4' />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end' className='z-[120]'>
|
||||
<DropdownMenuItem
|
||||
onClick={() => onDeleteChat(chat.id)}
|
||||
className='cursor-pointer text-destructive'
|
||||
>
|
||||
<Trash2 className='mr-2 h-4 w-4' />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* New Chat Button */}
|
||||
{/* Show loading state with centered pulsing agent icon */}
|
||||
{isLoadingChats || isLoading ? (
|
||||
<div className='flex h-full items-center justify-center'>
|
||||
<div className='flex items-center justify-center'>
|
||||
<Bot className='h-16 w-16 animate-pulse text-muted-foreground' />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Close button in top right corner */}
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={onStartNewChat}
|
||||
className='h-8 w-8 p-0'
|
||||
title='New Chat'
|
||||
size='icon'
|
||||
className='absolute top-3 right-4 z-10 h-8 w-8 rounded-md hover:bg-accent/50'
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
<MessageSquarePlus className='h-4 w-4' />
|
||||
<X className='h-4 w-4' />
|
||||
<span className='sr-only'>Close</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-8 w-8 rounded-md hover:bg-accent/50'
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
<X className='h-4 w-4' />
|
||||
<span className='sr-only'>Close</span>
|
||||
</Button>
|
||||
</div>
|
||||
{/* Header with chat title and management */}
|
||||
<div className='border-b py-3'>
|
||||
<div className='mx-auto flex w-full max-w-3xl items-center justify-between px-4'>
|
||||
{/* Chat Title Dropdown */}
|
||||
<DropdownMenu open={isDropdownOpen} onOpenChange={setIsDropdownOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='h-8 max-w-[300px] justify-start px-3 hover:bg-accent/50'
|
||||
>
|
||||
<span className='truncate'>{currentChat?.title || 'New Chat'}</span>
|
||||
<ChevronDown className='ml-2 h-4 w-4 shrink-0' />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align='start'
|
||||
className='z-[110] w-72 border-border/50 bg-background/95 shadow-lg backdrop-blur-sm'
|
||||
sideOffset={8}
|
||||
onMouseLeave={() => setIsDropdownOpen(false)}
|
||||
>
|
||||
{isLoadingChats ? (
|
||||
<div className='px-4 py-3 text-muted-foreground text-sm'>Loading chats...</div>
|
||||
) : chats.length === 0 ? (
|
||||
<div className='px-4 py-3 text-muted-foreground text-sm'>No chats yet</div>
|
||||
) : (
|
||||
// Sort chats by updated date (most recent first) for display
|
||||
[...chats]
|
||||
.sort(
|
||||
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
|
||||
)
|
||||
.map((chat) => (
|
||||
<div key={chat.id} className='group flex items-center gap-2 px-2 py-1'>
|
||||
<DropdownMenuItem asChild>
|
||||
<div
|
||||
onClick={() => {
|
||||
onSelectChat(chat)
|
||||
setIsDropdownOpen(false)
|
||||
}}
|
||||
className={`min-w-0 flex-1 cursor-pointer rounded-lg px-3 py-2.5 transition-all ${
|
||||
currentChat?.id === chat.id
|
||||
? 'bg-accent/80 text-accent-foreground'
|
||||
: 'hover:bg-accent/40'
|
||||
}`}
|
||||
>
|
||||
<div className='min-w-0'>
|
||||
<div className='truncate font-medium text-sm leading-tight'>
|
||||
{chat.title || 'Untitled Chat'}
|
||||
</div>
|
||||
<div className='mt-0.5 truncate text-muted-foreground text-xs'>
|
||||
{new Date(chat.updatedAt).toLocaleDateString()} at{' '}
|
||||
{new Date(chat.updatedAt).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}{' '}
|
||||
• {chat.messageCount}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-7 w-7 shrink-0 p-0 hover:bg-accent/60'
|
||||
>
|
||||
<MoreHorizontal className='h-3.5 w-3.5' />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align='end'
|
||||
className='z-[120] border-border/50 bg-background/95 shadow-lg backdrop-blur-sm'
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onClick={() => onDeleteChat(chat.id)}
|
||||
className='cursor-pointer text-destructive hover:bg-destructive/10 hover:text-destructive focus:bg-destructive/10 focus:text-destructive'
|
||||
>
|
||||
<Trash2 className='mr-2 h-3.5 w-3.5' />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Messages container */}
|
||||
<div ref={messagesContainerRef} className='flex-1 overflow-y-auto'>
|
||||
<div className='mx-auto max-w-3xl'>
|
||||
{messages.length === 0 ? (
|
||||
<div className='flex h-full flex-col items-center justify-center px-4 py-10'>
|
||||
<div className='space-y-4 text-center'>
|
||||
<Bot className='mx-auto h-12 w-12 text-muted-foreground' />
|
||||
<div className='space-y-2'>
|
||||
<h3 className='font-medium text-lg'>Welcome to Documentation Copilot</h3>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
Ask me anything about Sim Studio features, workflows, tools, or how to get
|
||||
started.
|
||||
</p>
|
||||
</div>
|
||||
<div className='mx-auto max-w-xs space-y-2 text-left'>
|
||||
<div className='text-muted-foreground text-xs'>Try asking:</div>
|
||||
<div className='space-y-1'>
|
||||
<div className='rounded bg-muted/50 px-2 py-1 text-xs'>
|
||||
"How do I create a workflow?"
|
||||
</div>
|
||||
<div className='rounded bg-muted/50 px-2 py-1 text-xs'>
|
||||
"What tools are available?"
|
||||
</div>
|
||||
<div className='rounded bg-muted/50 px-2 py-1 text-xs'>
|
||||
"How do I deploy my workflow?"
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Right side action buttons */}
|
||||
<div className='flex items-center gap-2'>
|
||||
{/* Checkpoint Toggle Button */}
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => setShowCheckpoints(!showCheckpoints)}
|
||||
className={`h-8 w-8 p-0 ${
|
||||
showCheckpoints
|
||||
? 'bg-[#802FFF]/20 text-[#802FFF] hover:bg-[#802FFF]/30'
|
||||
: 'hover:bg-accent/50'
|
||||
}`}
|
||||
title='View Checkpoints'
|
||||
>
|
||||
<History className='h-4 w-4' />
|
||||
</Button>
|
||||
|
||||
{/* New Chat Button */}
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={onStartNewChat}
|
||||
className='h-8 w-8 p-0'
|
||||
title='New Chat'
|
||||
>
|
||||
<MessageSquarePlus className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages container or Checkpoint Panel */}
|
||||
{showCheckpoints ? (
|
||||
<div className='flex-1 overflow-hidden'>
|
||||
<CheckpointPanel />
|
||||
</div>
|
||||
) : (
|
||||
messages.map((message) => <ModalCopilotMessage key={message.id} message={message} />)
|
||||
)}
|
||||
|
||||
{/* Loading indicator (shows only when loading) */}
|
||||
{isLoading && (
|
||||
<div className='px-4 py-5'>
|
||||
<div ref={messagesContainerRef} className='flex-1 overflow-y-auto'>
|
||||
<div className='mx-auto max-w-3xl'>
|
||||
<div className='flex'>
|
||||
<div className='max-w-[80%]'>
|
||||
<div className='flex h-6 items-center'>
|
||||
<div className='loading-dot h-3 w-3 rounded-full bg-black dark:bg-black' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{messages.length === 0 ? (
|
||||
<CopilotWelcome onQuestionClick={onSendMessage} mode={mode} />
|
||||
) : (
|
||||
messages.map((message) => (
|
||||
<ProfessionalMessage
|
||||
key={message.id}
|
||||
message={message}
|
||||
isStreaming={isLoading && message.id === messages[messages.length - 1]?.id}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} className='h-1' />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} className='h-1' />
|
||||
</div>
|
||||
</div>
|
||||
{/* Mode Selector and Input */}
|
||||
{!showCheckpoints && (
|
||||
<>
|
||||
{/* Mode Selector */}
|
||||
<div className='pt-6'>
|
||||
<div className='mx-auto max-w-3xl px-4'>
|
||||
<div className='flex items-center gap-1 rounded-md border bg-muted/30 p-0.5'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => onModeChange('ask')}
|
||||
className={`h-6 flex-1 font-medium text-xs ${
|
||||
mode === 'ask'
|
||||
? 'bg-[#802FFF]/20 text-[#802FFF] hover:bg-[#802FFF]/30'
|
||||
: 'hover:bg-muted/50'
|
||||
}`}
|
||||
title='Ask questions and get answers. Cannot edit workflows.'
|
||||
>
|
||||
Ask
|
||||
</Button>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => onModeChange('agent')}
|
||||
className={`h-6 flex-1 font-medium text-xs ${
|
||||
mode === 'agent'
|
||||
? 'bg-[#802FFF]/20 text-[#802FFF] hover:bg-[#802FFF]/30'
|
||||
: 'hover:bg-muted/50'
|
||||
}`}
|
||||
title='Full agent with workflow editing capabilities.'
|
||||
>
|
||||
Agent
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Input area (fixed at bottom) */}
|
||||
<div className='bg-background p-4'>
|
||||
<div className='mx-auto max-w-3xl'>
|
||||
<div className='relative rounded-2xl border bg-background shadow-sm'>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={copilotMessage}
|
||||
onChange={(e) => setCopilotMessage(e.target.value)}
|
||||
onKeyDown={handleKeyPress}
|
||||
placeholder='Ask about Sim Studio documentation...'
|
||||
className='min-h-[50px] flex-1 rounded-2xl border-0 bg-transparent py-7 pr-16 pl-6 text-base focus-visible:ring-0 focus-visible:ring-offset-0'
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleSendMessage}
|
||||
size='icon'
|
||||
disabled={!copilotMessage.trim() || isLoading}
|
||||
className='-translate-y-1/2 absolute top-1/2 right-3 h-10 w-10 rounded-xl bg-black p-0 text-white hover:bg-gray-800 dark:bg-primary dark:hover:bg-primary/80'
|
||||
>
|
||||
<ArrowUp className='h-4 w-4 dark:text-black' />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className='mt-2 text-center text-muted-foreground text-xs'>
|
||||
<p>Ask questions about Sim Studio documentation and features</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Input area */}
|
||||
<ProfessionalInput
|
||||
onSubmit={async (message) => {
|
||||
await onSendMessage(message)
|
||||
setCopilotMessage('')
|
||||
}}
|
||||
disabled={false}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
'use client'
|
||||
|
||||
import { type FC, type KeyboardEvent, useRef, useState } from 'react'
|
||||
import { ArrowUp, Loader2 } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface ProfessionalInputProps {
|
||||
onSubmit: (message: string) => void
|
||||
disabled?: boolean
|
||||
isLoading?: boolean
|
||||
placeholder?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
const ProfessionalInput: FC<ProfessionalInputProps> = ({
|
||||
onSubmit,
|
||||
disabled = false,
|
||||
isLoading = false,
|
||||
placeholder = 'How can I help you today?',
|
||||
className,
|
||||
}) => {
|
||||
const [message, setMessage] = useState('')
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
const handleSubmit = () => {
|
||||
const trimmedMessage = message.trim()
|
||||
if (!trimmedMessage || disabled || isLoading) return
|
||||
|
||||
onSubmit(trimmedMessage)
|
||||
setMessage('')
|
||||
|
||||
// Reset textarea height
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto'
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSubmit()
|
||||
}
|
||||
}
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setMessage(e.target.value)
|
||||
|
||||
// Auto-resize textarea
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto'
|
||||
textareaRef.current.style.height = `${Math.min(textareaRef.current.scrollHeight, 120)}px`
|
||||
}
|
||||
}
|
||||
|
||||
const canSubmit = message.trim().length > 0 && !disabled && !isLoading
|
||||
|
||||
return (
|
||||
<div className={cn('w-full max-w-full overflow-hidden bg-background p-4', className)}>
|
||||
<div className='mx-auto w-full max-w-3xl'>
|
||||
<div className='relative w-full max-w-full'>
|
||||
<div className='relative flex w-full max-w-full items-end rounded-2xl border border-border bg-background shadow-sm transition-all focus-within:border-primary focus-within:ring-1 focus-within:ring-primary'>
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={message}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled || isLoading}
|
||||
className='max-h-[120px] min-h-[50px] w-full max-w-full resize-none border-0 bg-transparent px-4 py-3 pr-12 text-sm placeholder:text-muted-foreground focus-visible:ring-0 focus-visible:ring-offset-0'
|
||||
rows={1}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSubmit}
|
||||
size='icon'
|
||||
className={cn(
|
||||
'absolute right-2 bottom-2 h-8 w-8 rounded-xl transition-all',
|
||||
canSubmit
|
||||
? 'bg-[#802FFF] text-white shadow-sm hover:bg-[#7028E6]'
|
||||
: 'cursor-not-allowed bg-muted text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className='h-4 w-4 animate-spin' />
|
||||
) : (
|
||||
<ArrowUp className='h-4 w-4' />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { ProfessionalInput }
|
||||
@@ -0,0 +1,403 @@
|
||||
'use client'
|
||||
|
||||
import { type FC, memo, useMemo } from 'react'
|
||||
import { Bot, Copy, User } from 'lucide-react'
|
||||
import { useTheme } from 'next-themes'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
|
||||
import { oneDark, oneLight } from 'react-syntax-highlighter/dist/esm/styles/prism'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ToolCallCompletion, ToolCallExecution } from '@/components/ui/tool-call'
|
||||
import { parseMessageContent, stripToolCallIndicators } from '@/lib/tool-call-parser'
|
||||
import type { CopilotMessage } from '@/stores/copilot/types'
|
||||
|
||||
interface ProfessionalMessageProps {
|
||||
message: CopilotMessage
|
||||
isStreaming?: boolean
|
||||
}
|
||||
|
||||
const ProfessionalMessage: FC<ProfessionalMessageProps> = memo(({ message, isStreaming }) => {
|
||||
const { theme } = useTheme()
|
||||
const isUser = message.role === 'user'
|
||||
const isAssistant = message.role === 'assistant'
|
||||
|
||||
const handleCopyContent = () => {
|
||||
// Copy clean text content without tool call indicators
|
||||
const contentToCopy = isAssistant ? stripToolCallIndicators(message.content) : message.content
|
||||
navigator.clipboard.writeText(contentToCopy)
|
||||
}
|
||||
|
||||
const formatTimestamp = (timestamp: string) => {
|
||||
return new Date(timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
// Parse message content to separate text and tool calls
|
||||
const parsedContent = useMemo(() => {
|
||||
if (isAssistant && message.content) {
|
||||
return parseMessageContent(message.content)
|
||||
}
|
||||
return null
|
||||
}, [isAssistant, message.content])
|
||||
|
||||
// Get clean text content without tool call indicators
|
||||
const cleanTextContent = useMemo(() => {
|
||||
if (isAssistant && message.content) {
|
||||
return stripToolCallIndicators(message.content)
|
||||
}
|
||||
return message.content
|
||||
}, [isAssistant, message.content])
|
||||
|
||||
// Custom components for react-markdown
|
||||
const markdownComponents = {
|
||||
code: ({ inline, className, children, ...props }: any) => {
|
||||
const match = /language-(\w+)/.exec(className || '')
|
||||
const language = match ? match[1] : ''
|
||||
|
||||
if (!inline && language) {
|
||||
return (
|
||||
<div className='group relative my-3 w-full max-w-full overflow-hidden rounded-lg border bg-muted/30'>
|
||||
<div
|
||||
className='w-full max-w-full overflow-x-auto'
|
||||
style={{ maxWidth: '100%', width: '100%' }}
|
||||
>
|
||||
<div style={{ maxWidth: '100%', overflow: 'hidden' }}>
|
||||
<SyntaxHighlighter
|
||||
style={theme === 'dark' ? oneDark : oneLight}
|
||||
language={language}
|
||||
PreTag='div'
|
||||
className='!m-0 !bg-transparent !max-w-full !w-full'
|
||||
showLineNumbers={language !== 'bash' && language !== 'shell'}
|
||||
wrapLines={true}
|
||||
wrapLongLines={true}
|
||||
customStyle={{
|
||||
margin: '0 !important',
|
||||
padding: '1rem',
|
||||
fontSize: '0.875rem',
|
||||
maxWidth: '100% !important',
|
||||
width: '100% !important',
|
||||
overflow: 'auto',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-all',
|
||||
}}
|
||||
codeTagProps={{
|
||||
style: {
|
||||
maxWidth: '100%',
|
||||
overflow: 'hidden',
|
||||
wordBreak: 'break-all',
|
||||
whiteSpace: 'pre-wrap',
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{String(children).replace(/\n$/, '')}
|
||||
</SyntaxHighlighter>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='absolute top-2 right-2 h-8 w-8 p-0 opacity-0 transition-opacity group-hover:opacity-100'
|
||||
onClick={() => navigator.clipboard.writeText(String(children))}
|
||||
>
|
||||
<Copy className='h-3 w-3' />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<code
|
||||
className='break-all rounded border bg-muted/80 px-1.5 py-0.5 font-mono text-sm'
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
)
|
||||
},
|
||||
pre: ({ children }: any) => (
|
||||
<div className='my-3 w-full max-w-full overflow-x-auto rounded-lg border bg-muted/30'>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
h1: ({ children }: any) => (
|
||||
<h1 className='mt-6 mb-3 break-words border-b pb-2 font-bold text-foreground text-xl'>
|
||||
{children}
|
||||
</h1>
|
||||
),
|
||||
h2: ({ children }: any) => (
|
||||
<h2 className='mt-5 mb-2 break-words font-semibold text-foreground text-lg'>{children}</h2>
|
||||
),
|
||||
h3: ({ children }: any) => (
|
||||
<h3 className='mt-4 mb-2 break-words font-semibold text-base text-foreground'>{children}</h3>
|
||||
),
|
||||
p: ({ children }: any) => (
|
||||
<p className='mb-3 break-words text-foreground text-sm leading-relaxed last:mb-0'>
|
||||
{children}
|
||||
</p>
|
||||
),
|
||||
a: ({ href, children }: any) => (
|
||||
<a
|
||||
href={href}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='break-all font-medium text-blue-600 underline decoration-blue-600/30 underline-offset-2 transition-colors hover:text-blue-800 hover:decoration-blue-600/60 dark:text-blue-400 dark:hover:text-blue-300'
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
ul: ({ children }: any) => (
|
||||
<ul className='mb-3 ml-4 list-outside list-disc space-y-1 break-words'>{children}</ul>
|
||||
),
|
||||
ol: ({ children }: any) => (
|
||||
<ol className='mb-3 ml-4 list-outside list-decimal space-y-1 break-words'>{children}</ol>
|
||||
),
|
||||
li: ({ children }: any) => (
|
||||
<li className='break-words text-foreground text-sm leading-relaxed'>{children}</li>
|
||||
),
|
||||
blockquote: ({ children }: any) => (
|
||||
<blockquote className='my-3 break-words rounded-r-lg border-muted-foreground/20 border-l-4 bg-muted/30 py-2 pl-4 text-muted-foreground italic'>
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
table: ({ children }: any) => (
|
||||
<div className='my-3 w-full max-w-full overflow-x-auto rounded-lg border'>
|
||||
<table className='w-full text-sm'>{children}</table>
|
||||
</div>
|
||||
),
|
||||
th: ({ children }: any) => (
|
||||
<th className='break-words border-b bg-muted/50 px-3 py-2 text-left font-semibold'>
|
||||
{children}
|
||||
</th>
|
||||
),
|
||||
td: ({ children }: any) => (
|
||||
<td className='break-words border-muted/30 border-b px-3 py-2'>{children}</td>
|
||||
),
|
||||
}
|
||||
|
||||
if (isUser) {
|
||||
return (
|
||||
<div className='group flex w-full max-w-full justify-end overflow-hidden px-4 py-3'>
|
||||
<div className='flex max-w-[85%] items-start gap-3'>
|
||||
<div className='flex flex-col items-end space-y-1'>
|
||||
<div className='max-w-full overflow-hidden rounded-2xl rounded-tr-md bg-primary px-4 py-3 text-primary-foreground shadow-sm'>
|
||||
<div className='overflow-hidden whitespace-pre-wrap break-words text-sm leading-relaxed'>
|
||||
{message.content}
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex items-center gap-2 opacity-0 transition-opacity group-hover:opacity-100'>
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{formatTimestamp(message.timestamp)}
|
||||
</span>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={handleCopyContent}
|
||||
className='h-6 w-6 p-0 text-muted-foreground hover:text-foreground'
|
||||
>
|
||||
<Copy className='h-3 w-3' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex h-8 w-8 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-sm'>
|
||||
<User className='h-4 w-4' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isAssistant) {
|
||||
return (
|
||||
<>
|
||||
<style>{`
|
||||
.message-container .prose pre {
|
||||
max-width: 100% !important;
|
||||
overflow-x: auto !important;
|
||||
}
|
||||
.message-container .prose code {
|
||||
max-width: 100% !important;
|
||||
word-break: break-all !important;
|
||||
white-space: pre-wrap !important;
|
||||
}
|
||||
.message-container div[class*="language-"] {
|
||||
max-width: 100% !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
.message-container div[class*="language-"] > div {
|
||||
max-width: 100% !important;
|
||||
overflow-x: auto !important;
|
||||
}
|
||||
.message-container div[class*="language-"] pre {
|
||||
max-width: 100% !important;
|
||||
overflow-x: auto !important;
|
||||
white-space: pre-wrap !important;
|
||||
word-break: break-all !important;
|
||||
}
|
||||
.aggressive-pulse {
|
||||
animation: aggressivePulse 1s ease-in-out infinite;
|
||||
}
|
||||
@keyframes aggressivePulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.4;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
<div className='message-container group flex w-full max-w-full justify-start overflow-hidden px-4 py-3'>
|
||||
<div className='flex w-full max-w-[85%] flex-col'>
|
||||
{/* Main message content with icon */}
|
||||
<div className='mb-3 flex items-end gap-3'>
|
||||
{/* Bot icon aligned with bottom of message bubble */}
|
||||
<div className='flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-blue-500 to-purple-600 text-white shadow-sm'>
|
||||
<Bot className={`h-4 w-4 ${isStreaming ? 'aggressive-pulse' : ''}`} />
|
||||
</div>
|
||||
|
||||
{/* Message content */}
|
||||
<div className='flex min-w-0 flex-1 flex-col items-start space-y-2'>
|
||||
{/* Inline content rendering - tool calls and text in order */}
|
||||
{parsedContent?.inlineContent && parsedContent.inlineContent.length > 0 ? (
|
||||
<div className='w-full max-w-full space-y-2'>
|
||||
{parsedContent.inlineContent.map((item, index) => {
|
||||
if (item.type === 'tool_call' && item.toolCall) {
|
||||
const toolCall = item.toolCall
|
||||
return (
|
||||
<div key={`${toolCall.id}-${index}`}>
|
||||
{toolCall.state === 'detecting' && (
|
||||
<div className='flex items-center gap-2 rounded-lg border border-blue-200 bg-blue-50 px-3 py-2 text-sm dark:border-blue-800 dark:bg-blue-950'>
|
||||
<div className='h-4 w-4 animate-spin rounded-full border-2 border-blue-600 border-t-transparent dark:border-blue-400' />
|
||||
<span className='text-blue-800 dark:text-blue-200'>
|
||||
Detecting {toolCall.displayName || toolCall.name}...
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{toolCall.state === 'executing' && (
|
||||
<ToolCallExecution toolCall={toolCall} isCompact={false} />
|
||||
)}
|
||||
{(toolCall.state === 'completed' || toolCall.state === 'error') && (
|
||||
<ToolCallCompletion toolCall={toolCall} isCompact={false} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (item.type === 'text' && item.content.trim()) {
|
||||
return (
|
||||
<div
|
||||
key={`text-${index}`}
|
||||
className='w-full max-w-full overflow-hidden rounded-2xl rounded-tl-md border bg-muted/50 px-4 py-3 shadow-sm'
|
||||
>
|
||||
<div
|
||||
className='prose prose-sm dark:prose-invert w-full max-w-none overflow-hidden'
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
width: '100%',
|
||||
overflow: 'hidden',
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={markdownComponents}
|
||||
>
|
||||
{item.content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
/* Fallback for empty content or streaming */
|
||||
<div className='w-full max-w-full overflow-hidden rounded-2xl rounded-tl-md border bg-muted/50 px-4 py-3 shadow-sm'>
|
||||
{cleanTextContent ? (
|
||||
<div
|
||||
className='prose prose-sm dark:prose-invert w-full max-w-none overflow-hidden'
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
width: '100%',
|
||||
overflow: 'hidden',
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
|
||||
{cleanTextContent}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
) : isStreaming ? (
|
||||
<div className='flex items-center gap-2 py-1 text-muted-foreground'>
|
||||
<div className='flex space-x-1'>
|
||||
<div
|
||||
className='h-2 w-2 animate-bounce rounded-full bg-current'
|
||||
style={{ animationDelay: '0ms' }}
|
||||
/>
|
||||
<div
|
||||
className='h-2 w-2 animate-bounce rounded-full bg-current'
|
||||
style={{ animationDelay: '150ms' }}
|
||||
/>
|
||||
<div
|
||||
className='h-2 w-2 animate-bounce rounded-full bg-current'
|
||||
style={{ animationDelay: '300ms' }}
|
||||
/>
|
||||
</div>
|
||||
<span className='text-sm'>Thinking...</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timestamp and actions - separate from main content */}
|
||||
<div className='ml-11 flex items-center gap-2 opacity-0 transition-opacity group-hover:opacity-100'>
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{formatTimestamp(message.timestamp)}
|
||||
</span>
|
||||
{cleanTextContent && (
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={handleCopyContent}
|
||||
className='h-6 w-6 p-0 text-muted-foreground hover:text-foreground'
|
||||
>
|
||||
<Copy className='h-3 w-3' />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Citations if available */}
|
||||
{message.citations && message.citations.length > 0 && (
|
||||
<div className='mt-2 ml-11 max-w-full space-y-1'>
|
||||
<div className='font-medium text-muted-foreground text-xs'>Sources:</div>
|
||||
<div className='flex flex-wrap gap-1'>
|
||||
{message.citations.map((citation) => (
|
||||
<a
|
||||
key={citation.id}
|
||||
href={citation.url}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='inline-flex max-w-full items-center break-all rounded-md border bg-muted/50 px-2 py-1 text-muted-foreground text-xs transition-colors hover:bg-muted hover:text-foreground'
|
||||
>
|
||||
{citation.title}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
ProfessionalMessage.displayName = 'ProfessionalMessage'
|
||||
|
||||
export { ProfessionalMessage }
|
||||
@@ -0,0 +1,58 @@
|
||||
'use client'
|
||||
|
||||
import { Bot } from 'lucide-react'
|
||||
|
||||
interface CopilotWelcomeProps {
|
||||
onQuestionClick?: (question: string) => void
|
||||
mode?: 'ask' | 'agent'
|
||||
}
|
||||
|
||||
export function CopilotWelcome({ onQuestionClick, mode = 'ask' }: CopilotWelcomeProps) {
|
||||
const askQuestions = [
|
||||
'How do I create a workflow?',
|
||||
'What tools are available?',
|
||||
'What does my workflow do?',
|
||||
]
|
||||
|
||||
const agentQuestions = [
|
||||
'Help me build a workflow',
|
||||
'I want to edit my workflow',
|
||||
'Build me a small sample workflow',
|
||||
]
|
||||
|
||||
const exampleQuestions = mode === 'ask' ? askQuestions : agentQuestions
|
||||
|
||||
const handleQuestionClick = (question: string) => {
|
||||
onQuestionClick?.(question)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex h-full flex-col items-center justify-center px-4 py-10'>
|
||||
<div className='space-y-6 text-center'>
|
||||
<Bot className='mx-auto h-12 w-12 text-muted-foreground' />
|
||||
<div className='space-y-2'>
|
||||
<h3 className='font-medium text-lg'>How can I help you today?</h3>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
{mode === 'ask'
|
||||
? 'Ask me anything about your workflows, available tools, or how to get started.'
|
||||
: 'I can help you build, edit, and create workflows. What would you like to do?'}
|
||||
</p>
|
||||
</div>
|
||||
<div className='mx-auto max-w-sm space-y-3'>
|
||||
<div className='font-medium text-muted-foreground text-xs'>Try asking:</div>
|
||||
<div className='flex flex-wrap justify-center gap-2'>
|
||||
{exampleQuestions.map((question, index) => (
|
||||
<button
|
||||
key={index}
|
||||
className='inline-flex cursor-pointer items-center rounded-full bg-muted/60 px-3 py-1.5 font-medium text-muted-foreground text-xs transition-all hover:scale-105 hover:bg-muted hover:text-foreground active:scale-95'
|
||||
onClick={() => handleQuestionClick(question)}
|
||||
>
|
||||
{question}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,16 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef } from 'react'
|
||||
import {
|
||||
Bot,
|
||||
ChevronDown,
|
||||
Loader2,
|
||||
MessageSquarePlus,
|
||||
MoreHorizontal,
|
||||
Send,
|
||||
Trash2,
|
||||
User,
|
||||
} from 'lucide-react'
|
||||
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react'
|
||||
import { Bot, ChevronDown, History, MessageSquarePlus, MoreHorizontal, Trash2 } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -18,13 +9,15 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { useCopilotStore } from '@/stores/copilot/store'
|
||||
import type { CopilotMessage } from '@/stores/copilot/types'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { CheckpointPanel } from './components/checkpoint-panel'
|
||||
import { CopilotModal } from './components/copilot-modal/copilot-modal'
|
||||
import { ProfessionalInput } from './components/professional-input/professional-input'
|
||||
import { ProfessionalMessage } from './components/professional-message/professional-message'
|
||||
import { CopilotWelcome } from './components/welcome/welcome'
|
||||
|
||||
const logger = createLogger('Copilot')
|
||||
|
||||
@@ -52,8 +45,9 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const scrollAreaRef = useRef<HTMLDivElement>(null)
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false)
|
||||
const [showCheckpoints, setShowCheckpoints] = useState(false)
|
||||
|
||||
const { activeWorkflowId } = useWorkflowRegistry()
|
||||
|
||||
@@ -67,13 +61,16 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(
|
||||
isSendingMessage,
|
||||
error,
|
||||
workflowId,
|
||||
mode,
|
||||
setWorkflowId,
|
||||
validateCurrentChat,
|
||||
selectChat,
|
||||
createNewChat,
|
||||
deleteChat,
|
||||
sendMessage,
|
||||
clearMessages,
|
||||
clearError,
|
||||
setMode,
|
||||
} = useCopilotStore()
|
||||
|
||||
// Sync workflow ID with store
|
||||
@@ -83,6 +80,14 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(
|
||||
}
|
||||
}, [activeWorkflowId, workflowId, setWorkflowId])
|
||||
|
||||
// Safety check: Clear any chat that doesn't belong to current workflow
|
||||
useEffect(() => {
|
||||
if (activeWorkflowId && workflowId === activeWorkflowId) {
|
||||
// Validate that current chat belongs to this workflow
|
||||
validateCurrentChat()
|
||||
}
|
||||
}, [currentChat, chats, activeWorkflowId, workflowId, validateCurrentChat])
|
||||
|
||||
// Auto-scroll to bottom when new messages are added
|
||||
useEffect(() => {
|
||||
if (scrollAreaRef.current) {
|
||||
@@ -126,17 +131,9 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(
|
||||
|
||||
// Handle message submission
|
||||
const handleSubmit = useCallback(
|
||||
async (e?: React.FormEvent, message?: string) => {
|
||||
e?.preventDefault()
|
||||
|
||||
const query = message || inputRef.current?.value?.trim() || ''
|
||||
async (query: string) => {
|
||||
if (!query || isSendingMessage || !activeWorkflowId) return
|
||||
|
||||
// Clear input if using the form input
|
||||
if (!message && inputRef.current) {
|
||||
inputRef.current.value = ''
|
||||
}
|
||||
|
||||
try {
|
||||
await sendMessage(query, { stream: true })
|
||||
logger.info('Sent message:', query)
|
||||
@@ -147,253 +144,236 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(
|
||||
[isSendingMessage, activeWorkflowId, sendMessage]
|
||||
)
|
||||
|
||||
// Format timestamp for display
|
||||
const formatTimestamp = (timestamp: string) => {
|
||||
return new Date(timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
// Function to render content with basic markdown (including direct links from LLM)
|
||||
const renderMarkdownContent = (content: string) => {
|
||||
if (!content) return content
|
||||
|
||||
let processedContent = content
|
||||
|
||||
// Process markdown links: [text](url)
|
||||
processedContent = processedContent.replace(
|
||||
/\[([^\]]+)\]\(([^)]+)\)/g,
|
||||
'<a href="$2" target="_blank" rel="noopener noreferrer" class="text-blue-600 hover:text-blue-800 font-semibold underline transition-colors">$1</a>'
|
||||
)
|
||||
|
||||
// Basic markdown processing
|
||||
processedContent = processedContent
|
||||
.replace(
|
||||
/```(\w+)?\n([\s\S]*?)```/g,
|
||||
'<pre class="bg-muted p-3 rounded-lg overflow-x-auto my-3 text-sm"><code>$2</code></pre>'
|
||||
)
|
||||
.replace(
|
||||
/`([^`]+)`/g,
|
||||
'<code class="bg-muted px-1.5 py-0.5 rounded text-sm font-mono">$1</code>'
|
||||
)
|
||||
.replace(/\*\*(.*?)\*\*/g, '<strong class="font-semibold">$1</strong>')
|
||||
.replace(/\*(.*?)\*/g, '<em class="italic">$1</em>')
|
||||
.replace(/^### (.*$)/gm, '<h3 class="font-semibold text-base mt-4 mb-2">$1</h3>')
|
||||
.replace(/^## (.*$)/gm, '<h2 class="font-semibold text-lg mt-4 mb-2">$1</h2>')
|
||||
.replace(/^# (.*$)/gm, '<h1 class="font-bold text-xl mt-4 mb-3">$1</h1>')
|
||||
.replace(/^\* (.*$)/gm, '<li class="ml-4">• $1</li>')
|
||||
.replace(/^- (.*$)/gm, '<li class="ml-4">• $1</li>')
|
||||
.replace(/\n\n+/g, '</p><p class="mt-2">')
|
||||
.replace(/\n/g, '<br>')
|
||||
|
||||
// Wrap in paragraph tags if needed
|
||||
if (
|
||||
!processedContent.includes('<p>') &&
|
||||
!processedContent.includes('<h1>') &&
|
||||
!processedContent.includes('<h2>') &&
|
||||
!processedContent.includes('<h3>')
|
||||
) {
|
||||
processedContent = `<p>${processedContent}</p>`
|
||||
}
|
||||
|
||||
return processedContent
|
||||
}
|
||||
|
||||
// Render individual message
|
||||
const renderMessage = (message: CopilotMessage) => {
|
||||
return (
|
||||
<div key={message.id} className='group flex gap-3 p-4 hover:bg-muted/30'>
|
||||
<div
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-full ${
|
||||
message.role === 'user' ? 'bg-muted' : 'bg-primary'
|
||||
}`}
|
||||
>
|
||||
{message.role === 'user' ? (
|
||||
<User className='h-4 w-4 text-muted-foreground' />
|
||||
) : (
|
||||
<Bot className='h-4 w-4 text-primary-foreground' />
|
||||
)}
|
||||
</div>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<div className='mb-3 flex items-center gap-2'>
|
||||
<span className='font-medium text-sm'>
|
||||
{message.role === 'user' ? 'You' : 'Copilot'}
|
||||
</span>
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{formatTimestamp(message.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Enhanced content rendering with markdown links */}
|
||||
<div className='prose prose-sm dark:prose-invert max-w-none'>
|
||||
<div
|
||||
className='text-foreground text-sm leading-normal'
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: renderMarkdownContent(message.content),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Streaming cursor */}
|
||||
{!message.content && (
|
||||
<div className='flex items-center gap-2 text-muted-foreground'>
|
||||
<Loader2 className='h-4 w-4 animate-spin' />
|
||||
<span className='text-sm'>Thinking...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Convert messages for modal (role -> type)
|
||||
const modalMessages = messages.map((msg) => ({
|
||||
id: msg.id,
|
||||
content: msg.content,
|
||||
type: msg.role as 'user' | 'assistant',
|
||||
timestamp: new Date(msg.timestamp),
|
||||
citations: msg.citations,
|
||||
}))
|
||||
|
||||
// Handle modal message sending
|
||||
const handleModalSendMessage = useCallback(
|
||||
async (message: string) => {
|
||||
await handleSubmit(undefined, message)
|
||||
await handleSubmit(message)
|
||||
},
|
||||
[handleSubmit]
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='flex h-full flex-col'>
|
||||
{/* Header with Chat Title and Management */}
|
||||
<div className='border-b p-4'>
|
||||
<div className='flex items-center justify-between'>
|
||||
{/* Chat Title Dropdown */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant='ghost' className='h-8 min-w-0 flex-1 justify-start px-3'>
|
||||
<span className='truncate'>{currentChat?.title || 'New Chat'}</span>
|
||||
<ChevronDown className='ml-2 h-4 w-4 shrink-0' />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='start' className='z-[110] w-64' sideOffset={8}>
|
||||
{chats.map((chat) => (
|
||||
<div key={chat.id} className='flex items-center'>
|
||||
<DropdownMenuItem
|
||||
onClick={() => selectChat(chat)}
|
||||
className='flex-1 cursor-pointer'
|
||||
>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<div className='truncate font-medium text-sm'>
|
||||
{chat.title || 'Untitled Chat'}
|
||||
</div>
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
{chat.messageCount} messages •{' '}
|
||||
{new Date(chat.updatedAt).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant='ghost' size='sm' className='h-8 w-8 shrink-0 p-0'>
|
||||
<MoreHorizontal className='h-4 w-4' />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end' className='z-[120]'>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleDeleteChat(chat.id)}
|
||||
className='cursor-pointer text-destructive'
|
||||
>
|
||||
<Trash2 className='mr-2 h-4 w-4' />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* New Chat Button */}
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={handleStartNewChat}
|
||||
className='h-8 w-8 p-0'
|
||||
title='New Chat'
|
||||
>
|
||||
<MessageSquarePlus className='h-4 w-4' />
|
||||
</Button>
|
||||
<div
|
||||
className='flex h-full max-w-full flex-col overflow-hidden'
|
||||
style={{ width: `${panelWidth}px`, maxWidth: `${panelWidth}px` }}
|
||||
>
|
||||
{/* Show loading state with centered pulsing agent icon */}
|
||||
{isLoadingChats || isLoading ? (
|
||||
<div className='flex h-full items-center justify-center'>
|
||||
<div className='flex items-center justify-center'>
|
||||
<Bot className='h-16 w-16 animate-pulse text-muted-foreground' />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Header with Chat Title and Management */}
|
||||
<div className='border-b p-4'>
|
||||
<div className='flex items-center justify-between'>
|
||||
{/* Chat Title Dropdown */}
|
||||
<DropdownMenu open={isDropdownOpen} onOpenChange={setIsDropdownOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='h-8 min-w-0 flex-1 justify-start px-3 hover:bg-accent/50'
|
||||
>
|
||||
<span className='truncate'>
|
||||
{/* Only show chat title if we have verified workflow match */}
|
||||
{currentChat &&
|
||||
workflowId === activeWorkflowId &&
|
||||
chats.some((chat) => chat.id === currentChat.id)
|
||||
? currentChat.title || 'New Chat'
|
||||
: 'New Chat'}
|
||||
</span>
|
||||
<ChevronDown className='ml-2 h-4 w-4 shrink-0' />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align='start'
|
||||
className='z-[110] w-72 border-border/50 bg-background/95 shadow-lg backdrop-blur-sm'
|
||||
sideOffset={8}
|
||||
onMouseLeave={() => setIsDropdownOpen(false)}
|
||||
>
|
||||
{isLoadingChats ? (
|
||||
<div className='px-4 py-3 text-muted-foreground text-sm'>
|
||||
Loading chats...
|
||||
</div>
|
||||
) : chats.length === 0 ? (
|
||||
<div className='px-4 py-3 text-muted-foreground text-sm'>No chats yet</div>
|
||||
) : (
|
||||
// Sort chats by updated date (most recent first) for display
|
||||
[...chats]
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
|
||||
)
|
||||
.map((chat) => (
|
||||
<div key={chat.id} className='group flex items-center gap-2 px-2 py-1'>
|
||||
<DropdownMenuItem asChild>
|
||||
<div
|
||||
onClick={() => {
|
||||
selectChat(chat)
|
||||
setIsDropdownOpen(false)
|
||||
}}
|
||||
className={`min-w-0 flex-1 cursor-pointer rounded-lg px-3 py-2.5 transition-all ${
|
||||
currentChat?.id === chat.id
|
||||
? 'bg-accent/80 text-accent-foreground'
|
||||
: 'hover:bg-accent/40'
|
||||
}`}
|
||||
>
|
||||
<div className='min-w-0'>
|
||||
<div className='truncate font-medium text-sm leading-tight'>
|
||||
{chat.title || 'Untitled Chat'}
|
||||
</div>
|
||||
<div className='mt-0.5 truncate text-muted-foreground text-xs'>
|
||||
{new Date(chat.updatedAt).toLocaleDateString()} at{' '}
|
||||
{new Date(chat.updatedAt).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}{' '}
|
||||
• {chat.messageCount}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-7 w-7 shrink-0 p-0 hover:bg-accent/60'
|
||||
>
|
||||
<MoreHorizontal className='h-3.5 w-3.5' />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align='end'
|
||||
className='z-[120] border-border/50 bg-background/95 shadow-lg backdrop-blur-sm'
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleDeleteChat(chat.id)}
|
||||
className='cursor-pointer text-destructive hover:bg-destructive/10 hover:text-destructive focus:bg-destructive/10 focus:text-destructive'
|
||||
>
|
||||
<Trash2 className='mr-2 h-3.5 w-3.5' />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Error display */}
|
||||
{error && (
|
||||
<div className='mt-2 rounded-md bg-destructive/10 p-2 text-destructive text-sm'>
|
||||
{error}
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={clearError}
|
||||
className='ml-2 h-auto p-1 text-destructive'
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Checkpoint Toggle Button */}
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => setShowCheckpoints(!showCheckpoints)}
|
||||
className={`h-8 w-8 p-0 ${
|
||||
showCheckpoints
|
||||
? 'bg-[#802FFF]/20 text-[#802FFF] hover:bg-[#802FFF]/30'
|
||||
: 'hover:bg-accent/50'
|
||||
}`}
|
||||
title='View Checkpoints'
|
||||
>
|
||||
<History className='h-4 w-4' />
|
||||
</Button>
|
||||
|
||||
{/* Messages area */}
|
||||
<ScrollArea ref={scrollAreaRef} className='flex-1'>
|
||||
{messages.length === 0 ? (
|
||||
<div className='flex h-full flex-col items-center justify-center px-4 py-10'>
|
||||
<div className='space-y-4 text-center'>
|
||||
<Bot className='mx-auto h-12 w-12 text-muted-foreground' />
|
||||
<div className='space-y-2'>
|
||||
<h3 className='font-medium text-lg'>Welcome to Documentation Copilot</h3>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
Ask me anything about Sim Studio features, workflows, tools, or how to get
|
||||
started.
|
||||
</p>
|
||||
{/* New Chat Button */}
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={handleStartNewChat}
|
||||
className='h-8 w-8 p-0'
|
||||
title='New Chat'
|
||||
>
|
||||
<MessageSquarePlus className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Error display */}
|
||||
{error && (
|
||||
<div className='mt-2 rounded-md bg-destructive/10 p-2 text-destructive text-sm'>
|
||||
{error}
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={clearError}
|
||||
className='ml-2 h-auto p-1 text-destructive'
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
</div>
|
||||
<div className='mx-auto max-w-xs space-y-2 text-left'>
|
||||
<div className='text-muted-foreground text-xs'>Try asking:</div>
|
||||
<div className='space-y-1'>
|
||||
<div className='rounded bg-muted/50 px-2 py-1 text-xs'>
|
||||
"How do I create a workflow?"
|
||||
</div>
|
||||
<div className='rounded bg-muted/50 px-2 py-1 text-xs'>
|
||||
"What tools are available?"
|
||||
</div>
|
||||
<div className='rounded bg-muted/50 px-2 py-1 text-xs'>
|
||||
"How do I deploy my workflow?"
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Messages area or Checkpoint Panel */}
|
||||
{showCheckpoints ? (
|
||||
<CheckpointPanel />
|
||||
) : (
|
||||
<ScrollArea ref={scrollAreaRef} className='max-w-full flex-1 overflow-hidden'>
|
||||
{messages.length === 0 ? (
|
||||
<CopilotWelcome onQuestionClick={handleSubmit} mode={mode} />
|
||||
) : (
|
||||
messages.map((message) => (
|
||||
<ProfessionalMessage
|
||||
key={message.id}
|
||||
message={message}
|
||||
isStreaming={
|
||||
isSendingMessage && message.id === messages[messages.length - 1]?.id
|
||||
}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</ScrollArea>
|
||||
)}
|
||||
|
||||
{/* Mode Selector and Input */}
|
||||
{!showCheckpoints && (
|
||||
<>
|
||||
{/* Mode Selector */}
|
||||
<div className='border-t px-4 pt-2 pb-1'>
|
||||
<div className='flex items-center gap-1 rounded-md border bg-muted/30 p-0.5'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => setMode('ask')}
|
||||
className={`h-6 flex-1 font-medium text-xs ${
|
||||
mode === 'ask'
|
||||
? 'bg-[#802FFF]/20 text-[#802FFF] hover:bg-[#802FFF]/30'
|
||||
: 'hover:bg-muted/50'
|
||||
}`}
|
||||
title='Ask questions and get answers. Cannot edit workflows.'
|
||||
>
|
||||
Ask
|
||||
</Button>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => setMode('agent')}
|
||||
className={`h-6 flex-1 font-medium text-xs ${
|
||||
mode === 'agent'
|
||||
? 'bg-[#802FFF]/20 text-[#802FFF] hover:bg-[#802FFF]/30'
|
||||
: 'hover:bg-muted/50'
|
||||
}`}
|
||||
title='Full agent with workflow editing capabilities.'
|
||||
>
|
||||
Agent
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
messages.map(renderMessage)
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
{/* Input area */}
|
||||
<div className='border-t p-4'>
|
||||
<form onSubmit={handleSubmit} className='flex gap-2'>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
placeholder='Ask about Sim Studio documentation...'
|
||||
disabled={isSendingMessage}
|
||||
className='flex-1'
|
||||
autoComplete='off'
|
||||
/>
|
||||
<Button type='submit' size='icon' disabled={isSendingMessage} className='h-10 w-10'>
|
||||
{isSendingMessage ? (
|
||||
<Loader2 className='h-4 w-4 animate-spin' />
|
||||
) : (
|
||||
<Send className='h-4 w-4' />
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
{/* Input area */}
|
||||
<ProfessionalInput
|
||||
onSubmit={handleSubmit}
|
||||
disabled={!activeWorkflowId}
|
||||
isLoading={isSendingMessage}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Fullscreen Modal */}
|
||||
@@ -402,14 +382,17 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(
|
||||
onOpenChange={(open) => onFullscreenToggle?.(open)}
|
||||
copilotMessage={fullscreenInput}
|
||||
setCopilotMessage={(message) => onFullscreenInputChange?.(message)}
|
||||
messages={modalMessages}
|
||||
messages={messages}
|
||||
onSendMessage={handleModalSendMessage}
|
||||
isLoading={isSendingMessage}
|
||||
isLoadingChats={isLoadingChats}
|
||||
chats={chats}
|
||||
currentChat={currentChat}
|
||||
onSelectChat={selectChat}
|
||||
onStartNewChat={handleStartNewChat}
|
||||
onDeleteChat={handleDeleteChat}
|
||||
mode={mode}
|
||||
onModeChange={setMode}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -17,11 +17,14 @@ import {
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { validateName } from '@/lib/utils'
|
||||
import { useVariablesStore } from '@/stores/panel/variables/store'
|
||||
import type { Variable, VariableType } from '@/stores/panel/variables/types'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
|
||||
const logger = createLogger('Variables')
|
||||
|
||||
export function Variables() {
|
||||
const { activeWorkflowId, workflows } = useWorkflowRegistry()
|
||||
const {
|
||||
@@ -190,7 +193,7 @@ export function Variables() {
|
||||
|
||||
return undefined // Valid object
|
||||
} catch (e) {
|
||||
console.log('Object parsing error:', e)
|
||||
logger.info('Object parsing error:', e)
|
||||
return 'Invalid object syntax'
|
||||
}
|
||||
case 'array':
|
||||
@@ -215,7 +218,7 @@ export function Variables() {
|
||||
|
||||
return undefined // Valid array
|
||||
} catch (e) {
|
||||
console.log('Array parsing error:', e)
|
||||
logger.info('Array parsing error:', e)
|
||||
return 'Invalid array syntax'
|
||||
}
|
||||
default:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { ArrowDownToLine, CircleSlash, X } from 'lucide-react'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { useChatStore } from '@/stores/panel/chat/store'
|
||||
@@ -10,15 +10,18 @@ import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { Chat } from './components/chat/chat'
|
||||
import { ChatModal } from './components/chat/components/chat-modal/chat-modal'
|
||||
import { Console } from './components/console/console'
|
||||
import { Copilot } from './components/copilot/copilot'
|
||||
import { Variables } from './components/variables/variables'
|
||||
|
||||
export function Panel() {
|
||||
const [chatMessage, setChatMessage] = useState<string>('')
|
||||
const [copilotMessage, setCopilotMessage] = useState<string>('')
|
||||
const [isChatModalOpen, setIsChatModalOpen] = useState(false)
|
||||
const [isCopilotModalOpen, setIsCopilotModalOpen] = useState(false)
|
||||
const [isResizing, setIsResizing] = useState(false)
|
||||
const [resizeStartX, setResizeStartX] = useState(0)
|
||||
const [resizeStartWidth, setResizeStartWidth] = useState(0)
|
||||
const copilotRef = useRef<{ clearMessages: () => void; startNewChat: () => void }>(null)
|
||||
|
||||
const isOpen = usePanelStore((state) => state.isOpen)
|
||||
const togglePanel = usePanelStore((state) => state.togglePanel)
|
||||
@@ -33,8 +36,13 @@ export function Panel() {
|
||||
const exportChatCSV = useChatStore((state) => state.exportChatCSV)
|
||||
const { activeWorkflowId } = useWorkflowRegistry()
|
||||
|
||||
const handleTabClick = (tab: 'chat' | 'console' | 'variables') => {
|
||||
setActiveTab(tab)
|
||||
const handleTabClick = (tab: 'chat' | 'console' | 'variables' | 'copilot') => {
|
||||
// Redirect copilot tab clicks to console since copilot is hidden
|
||||
if (tab === 'copilot') {
|
||||
setActiveTab('console')
|
||||
} else {
|
||||
setActiveTab(tab)
|
||||
}
|
||||
if (!isOpen) {
|
||||
togglePanel()
|
||||
}
|
||||
@@ -107,6 +115,15 @@ export function Panel() {
|
||||
>
|
||||
Console
|
||||
</button>
|
||||
{/* Temporarily hiding copilot tab */}
|
||||
{/* <button
|
||||
onClick={() => handleTabClick('copilot')}
|
||||
className={`panel-tab-base inline-flex flex-1 cursor-pointer items-center justify-center rounded-[10px] border border-transparent py-1 font-[450] text-sm outline-none transition-colors duration-200 ${
|
||||
isOpen && activeTab === 'copilot' ? 'panel-tab-active' : 'panel-tab-inactive'
|
||||
}`}
|
||||
>
|
||||
Copilot
|
||||
</button> */}
|
||||
<button
|
||||
onClick={() => handleTabClick('variables')}
|
||||
className={`panel-tab-base inline-flex flex-1 cursor-pointer items-center justify-center rounded-[10px] border border-transparent py-1 font-[450] text-sm outline-none transition-colors duration-200 ${
|
||||
@@ -161,15 +178,19 @@ export function Panel() {
|
||||
<TooltipContent side='bottom'>Export chat data</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{(activeTab === 'console' || activeTab === 'chat') && (
|
||||
{(activeTab === 'console' || activeTab === 'chat' || activeTab === 'copilot') && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() =>
|
||||
activeTab === 'console'
|
||||
? clearConsole(activeWorkflowId)
|
||||
: clearChat(activeWorkflowId)
|
||||
}
|
||||
onClick={() => {
|
||||
if (activeTab === 'console') {
|
||||
clearConsole(activeWorkflowId)
|
||||
} else if (activeTab === 'chat') {
|
||||
clearChat(activeWorkflowId)
|
||||
} else if (activeTab === 'copilot') {
|
||||
copilotRef.current?.clearMessages()
|
||||
}
|
||||
}}
|
||||
className='font-medium text-md leading-normal transition-all hover:brightness-75 dark:hover:brightness-125'
|
||||
style={{ color: 'var(--base-muted-foreground)' }}
|
||||
>
|
||||
@@ -199,6 +220,15 @@ export function Panel() {
|
||||
/>
|
||||
) : activeTab === 'console' ? (
|
||||
<Console panelWidth={panelWidth} />
|
||||
) : activeTab === 'copilot' ? (
|
||||
<Copilot
|
||||
ref={copilotRef}
|
||||
panelWidth={panelWidth}
|
||||
isFullscreen={isCopilotModalOpen}
|
||||
onFullscreenToggle={setIsCopilotModalOpen}
|
||||
fullscreenInput={copilotMessage}
|
||||
onFullscreenInputChange={setCopilotMessage}
|
||||
/>
|
||||
) : (
|
||||
<Variables />
|
||||
)}
|
||||
|
||||
@@ -52,7 +52,7 @@ export function ConditionInput({
|
||||
disabled = false,
|
||||
}: ConditionInputProps) {
|
||||
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlockId)
|
||||
const editorRef = useRef<HTMLDivElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [visualLineHeights, setVisualLineHeights] = useState<{
|
||||
[key: string]: number[]
|
||||
}>({})
|
||||
@@ -231,52 +231,12 @@ export function ConditionInput({
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Update block value with trigger checks - handle both tag and env var triggers consistently
|
||||
const updateBlockValue = (
|
||||
blockId: string,
|
||||
newValue: string,
|
||||
textarea: HTMLTextAreaElement | null
|
||||
) => {
|
||||
if (isPreview || disabled) return
|
||||
|
||||
try {
|
||||
setConditionalBlocks((blocks) =>
|
||||
blocks.map((block) => {
|
||||
if (block.id === blockId) {
|
||||
const pos = textarea?.selectionStart ?? 0
|
||||
const tagTrigger = checkTagTrigger(newValue, pos)
|
||||
const envVarTrigger = checkEnvVarTrigger(newValue, pos)
|
||||
|
||||
// Check triggers for both tags and env vars
|
||||
const lastCharTyped = newValue.charAt(pos - 1)
|
||||
const shouldShowTags = tagTrigger.show || lastCharTyped === '<'
|
||||
const shouldShowEnvVars = envVarTrigger.show || lastCharTyped === '$'
|
||||
|
||||
return {
|
||||
...block,
|
||||
value: newValue,
|
||||
showTags: shouldShowTags,
|
||||
showEnvVars: shouldShowEnvVars,
|
||||
searchTerm: shouldShowEnvVars ? envVarTrigger.searchTerm : '',
|
||||
cursorPosition: pos,
|
||||
// Maintain activeSourceBlockId only when tags are showing
|
||||
activeSourceBlockId: shouldShowTags ? block.activeSourceBlockId : null,
|
||||
}
|
||||
}
|
||||
return block
|
||||
})
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error('Error updating block value:', { error, blockId, newValue })
|
||||
}
|
||||
}
|
||||
|
||||
// Update the line counting logic to be block-specific
|
||||
useEffect(() => {
|
||||
if (!editorRef.current || conditionalBlocks.length === 0) return
|
||||
if (!containerRef.current || conditionalBlocks.length === 0) return
|
||||
|
||||
const calculateVisualLines = () => {
|
||||
const preElement = editorRef.current?.querySelector('pre')
|
||||
const preElement = containerRef.current?.querySelector('pre')
|
||||
if (!preElement) return
|
||||
|
||||
const newVisualLineHeights: { [key: string]: number[] } = {}
|
||||
@@ -336,7 +296,7 @@ export function ConditionInput({
|
||||
calculateVisualLines()
|
||||
|
||||
const resizeObserver = new ResizeObserver(calculateVisualLines)
|
||||
resizeObserver.observe(editorRef.current)
|
||||
resizeObserver.observe(containerRef.current)
|
||||
|
||||
return () => resizeObserver.disconnect()
|
||||
}, [conditionalBlocks])
|
||||
@@ -372,7 +332,7 @@ export function ConditionInput({
|
||||
const data = JSON.parse(e.dataTransfer.getData('application/json'))
|
||||
if (data.type !== 'connectionBlock') return
|
||||
|
||||
const textarea: any = editorRef.current?.querySelector(
|
||||
const textarea: any = containerRef.current?.querySelector(
|
||||
`[data-block-id="${blockId}"] textarea`
|
||||
)
|
||||
const dropPosition = textarea?.selectionStart ?? 0
|
||||
@@ -474,7 +434,7 @@ export function ConditionInput({
|
||||
|
||||
// Focus the new block's editor after a short delay
|
||||
setTimeout(() => {
|
||||
const textarea: any = editorRef.current?.querySelector(
|
||||
const textarea: any = containerRef.current?.querySelector(
|
||||
`[data-block-id="${newBlock.id}"] textarea`
|
||||
)
|
||||
if (textarea) {
|
||||
@@ -519,7 +479,7 @@ export function ConditionInput({
|
||||
// Add useEffect to handle keyboard events for both dropdowns
|
||||
useEffect(() => {
|
||||
conditionalBlocks.forEach((block) => {
|
||||
const textarea = editorRef.current?.querySelector(`[data-block-id="${block.id}"] textarea`)
|
||||
const textarea = containerRef.current?.querySelector(`[data-block-id="${block.id}"] textarea`)
|
||||
if (textarea) {
|
||||
textarea.addEventListener('keydown', (e: Event) => {
|
||||
if ((e as KeyboardEvent).key === 'Escape') {
|
||||
@@ -541,9 +501,6 @@ export function ConditionInput({
|
||||
})
|
||||
}, [conditionalBlocks.length])
|
||||
|
||||
// Use preview value when in preview mode, otherwise use store value
|
||||
const value = isPreview ? previewValue : storeValue
|
||||
|
||||
// Show loading or empty state if not ready or no blocks
|
||||
if (!isReady || conditionalBlocks.length === 0) {
|
||||
return (
|
||||
@@ -554,7 +511,7 @@ export function ConditionInput({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
<div className='space-y-4' ref={containerRef}>
|
||||
{conditionalBlocks.map((block, index) => (
|
||||
<div
|
||||
key={block.id}
|
||||
@@ -685,11 +642,7 @@ export function ConditionInput({
|
||||
{renderLineNumbers(block.id)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className='relative mt-0 pt-0 pl-[30px]'
|
||||
ref={editorRef}
|
||||
data-block-id={block.id}
|
||||
>
|
||||
<div className='relative mt-0 pt-0 pl-[30px]' data-block-id={block.id}>
|
||||
{block.value.length === 0 && (
|
||||
<div className='pointer-events-none absolute top-[12px] left-[42px] select-none text-muted-foreground/50'>
|
||||
{'<response> === true'}
|
||||
@@ -698,11 +651,33 @@ export function ConditionInput({
|
||||
<Editor
|
||||
value={block.value}
|
||||
onValueChange={(newCode) => {
|
||||
if (!isPreview) {
|
||||
const textarea = editorRef.current?.querySelector(
|
||||
if (!isPreview && !disabled) {
|
||||
const textarea = containerRef.current?.querySelector(
|
||||
`[data-block-id="${block.id}"] textarea`
|
||||
)
|
||||
updateBlockValue(block.id, newCode, textarea as HTMLTextAreaElement | null)
|
||||
) as HTMLTextAreaElement | null
|
||||
if (textarea) {
|
||||
const pos = textarea.selectionStart ?? 0
|
||||
|
||||
const tagTrigger = checkTagTrigger(newCode, pos)
|
||||
const envVarTrigger = checkEnvVarTrigger(newCode, pos)
|
||||
|
||||
setConditionalBlocks((blocks) =>
|
||||
blocks.map((b) => {
|
||||
if (b.id === block.id) {
|
||||
return {
|
||||
...b,
|
||||
value: newCode,
|
||||
showTags: tagTrigger.show,
|
||||
showEnvVars: envVarTrigger.show,
|
||||
searchTerm: envVarTrigger.show ? envVarTrigger.searchTerm : '',
|
||||
cursorPosition: pos,
|
||||
activeSourceBlockId: tagTrigger.show ? b.activeSourceBlockId : null,
|
||||
}
|
||||
}
|
||||
return b
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
CommandList,
|
||||
} from '@/components/ui/command'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import {
|
||||
type Credential,
|
||||
getProviderIdFromServiceId,
|
||||
@@ -21,6 +22,8 @@ import {
|
||||
} from '@/lib/oauth'
|
||||
import { OAuthRequiredModal } from '../../credential-selector/components/oauth-required-modal'
|
||||
|
||||
const logger = createLogger('ConfluenceFileSelector')
|
||||
|
||||
export interface ConfluenceFileInfo {
|
||||
id: string
|
||||
name: string
|
||||
@@ -138,7 +141,7 @@ export function ConfluenceFileSelector({
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching credentials:', error)
|
||||
logger.error('Error fetching credentials:', error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
@@ -205,7 +208,7 @@ export function ConfluenceFileSelector({
|
||||
onFileInfoChange?.(data.file)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching page info:', error)
|
||||
logger.error('Error fetching page info:', error)
|
||||
setError((error as Error).message)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
@@ -247,7 +250,7 @@ export function ConfluenceFileSelector({
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
const errorData = await tokenResponse.json()
|
||||
console.error('Access token error:', errorData)
|
||||
logger.error('Access token error:', errorData)
|
||||
|
||||
// If there's a token error, we might need to reconnect the account
|
||||
setError('Authentication failed. Please reconnect your Confluence account.')
|
||||
@@ -259,7 +262,7 @@ export function ConfluenceFileSelector({
|
||||
const accessToken = tokenData.accessToken
|
||||
|
||||
if (!accessToken) {
|
||||
console.error('No access token returned')
|
||||
logger.error('No access token returned')
|
||||
setError('Authentication failed. Please reconnect your Confluence account.')
|
||||
setIsLoading(false)
|
||||
return
|
||||
@@ -281,12 +284,12 @@ export function ConfluenceFileSelector({
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
console.error('Confluence API error:', errorData)
|
||||
logger.error('Confluence API error:', errorData)
|
||||
throw new Error(errorData.error || 'Failed to fetch pages')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
console.log(`Received ${data.files?.length || 0} files from API`)
|
||||
logger.info(`Received ${data.files?.length || 0} files from API`)
|
||||
setFiles(data.files || [])
|
||||
|
||||
// If we have a selected file ID, find the file info
|
||||
@@ -301,7 +304,7 @@ export function ConfluenceFileSelector({
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching pages:', error)
|
||||
logger.error('Error fetching pages:', error)
|
||||
setError((error as Error).message)
|
||||
setFiles([])
|
||||
} finally {
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
CommandList,
|
||||
} from '@/components/ui/command'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { env } from '@/lib/env'
|
||||
import { getEnv } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import {
|
||||
type Credential,
|
||||
@@ -301,7 +301,7 @@ export function GoogleDrivePicker({
|
||||
showUploadFolders: true,
|
||||
supportDrives: true,
|
||||
multiselect: false,
|
||||
appId: env.NEXT_PUBLIC_GOOGLE_PROJECT_NUMBER,
|
||||
appId: getEnv('NEXT_PUBLIC_GOOGLE_PROJECT_NUMBER'),
|
||||
// Enable folder selection when mimeType is folder
|
||||
setSelectFolderEnabled: !!mimeTypeFilter?.includes('folder'),
|
||||
callbackFunction: (data) => {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { env } from '@/lib/env'
|
||||
import { getEnv } from '@/lib/env'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
@@ -169,8 +169,8 @@ export function FileSelectorInput({
|
||||
}
|
||||
|
||||
// For Google Drive
|
||||
const clientId = env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || ''
|
||||
const apiKey = env.NEXT_PUBLIC_GOOGLE_API_KEY || ''
|
||||
const clientId = getEnv('NEXT_PUBLIC_GOOGLE_CLIENT_ID') || ''
|
||||
const apiKey = getEnv('NEXT_PUBLIC_GOOGLE_API_KEY') || ''
|
||||
|
||||
// Render Google Calendar selector
|
||||
if (isGoogleCalendar) {
|
||||
|
||||
@@ -48,7 +48,31 @@ export function Table({
|
||||
},
|
||||
]
|
||||
}
|
||||
return value as TableRow[]
|
||||
|
||||
// Validate and fix each row to ensure proper structure
|
||||
const validatedRows = value.map((row) => {
|
||||
// Ensure row has an id
|
||||
if (!row.id) {
|
||||
row.id = crypto.randomUUID()
|
||||
}
|
||||
|
||||
// Ensure row has cells object with proper structure
|
||||
if (!row.cells || typeof row.cells !== 'object') {
|
||||
console.warn('Fixing malformed table row:', row)
|
||||
row.cells = Object.fromEntries(columns.map((col) => [col, '']))
|
||||
} else {
|
||||
// Ensure all required columns exist in cells
|
||||
columns.forEach((col) => {
|
||||
if (!(col in row.cells)) {
|
||||
row.cells[col] = ''
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return row
|
||||
})
|
||||
|
||||
return validatedRows as TableRow[]
|
||||
}, [value, columns])
|
||||
|
||||
// Add state for managing dropdowns
|
||||
@@ -86,14 +110,21 @@ export function Table({
|
||||
const handleCellChange = (rowIndex: number, column: string, value: string) => {
|
||||
if (isPreview || disabled) return
|
||||
|
||||
const updatedRows = [...rows].map((row, idx) =>
|
||||
idx === rowIndex
|
||||
? {
|
||||
...row,
|
||||
cells: { ...row.cells, [column]: value },
|
||||
}
|
||||
: row
|
||||
)
|
||||
const updatedRows = [...rows].map((row, idx) => {
|
||||
if (idx === rowIndex) {
|
||||
// Ensure the row has a proper cells object
|
||||
if (!row.cells || typeof row.cells !== 'object') {
|
||||
console.warn('Fixing malformed row cells during cell change:', row)
|
||||
row.cells = Object.fromEntries(columns.map((col) => [col, '']))
|
||||
}
|
||||
|
||||
return {
|
||||
...row,
|
||||
cells: { ...row.cells, [column]: value },
|
||||
}
|
||||
}
|
||||
return row
|
||||
})
|
||||
|
||||
if (rowIndex === rows.length - 1 && value !== '') {
|
||||
updatedRows.push({
|
||||
@@ -129,6 +160,16 @@ export function Table({
|
||||
)
|
||||
|
||||
const renderCell = (row: TableRow, rowIndex: number, column: string, cellIndex: number) => {
|
||||
// Defensive programming: ensure row.cells exists and has the expected structure
|
||||
if (!row.cells || typeof row.cells !== 'object') {
|
||||
console.warn('Table row has malformed cells data:', row)
|
||||
// Create a fallback cells object
|
||||
row = {
|
||||
...row,
|
||||
cells: Object.fromEntries(columns.map((col) => [col, ''])),
|
||||
}
|
||||
}
|
||||
|
||||
const cellValue = row.cells[column] || ''
|
||||
const cellKey = `${rowIndex}-${column}`
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { logger } from '@trigger.dev/sdk/v3'
|
||||
import { PlusIcon, WrenchIcon, XIcon } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
@@ -684,34 +685,25 @@ export function ToolInput({
|
||||
}
|
||||
|
||||
const handleOperationChange = (toolIndex: number, operation: string) => {
|
||||
console.log('🔄 handleOperationChange called:', { toolIndex, operation, isPreview, disabled })
|
||||
|
||||
if (isPreview || disabled) {
|
||||
console.log('❌ Early return: preview or disabled')
|
||||
logger.info('❌ Early return: preview or disabled')
|
||||
return
|
||||
}
|
||||
|
||||
const tool = selectedTools[toolIndex]
|
||||
console.log('🔧 Current tool:', tool)
|
||||
|
||||
const newToolId = getToolIdForOperation(tool.type, operation)
|
||||
console.log('🆔 getToolIdForOperation result:', { toolType: tool.type, operation, newToolId })
|
||||
|
||||
if (!newToolId) {
|
||||
console.log('❌ Early return: no newToolId')
|
||||
logger.info('❌ Early return: no newToolId')
|
||||
return
|
||||
}
|
||||
|
||||
// Get parameters for the new tool
|
||||
const toolParams = getToolParametersConfig(newToolId, tool.type)
|
||||
console.log('📋 getToolParametersConfig result:', {
|
||||
newToolId,
|
||||
toolType: tool.type,
|
||||
toolParams,
|
||||
})
|
||||
|
||||
if (!toolParams) {
|
||||
console.log('❌ Early return: no toolParams')
|
||||
logger.info('❌ Early return: no toolParams')
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { Shield, Terminal } from 'lucide-react'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { CodeBlock } from '@/components/ui/code-block'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { ConfigField } from '../ui/config-field'
|
||||
import { ConfigSection } from '../ui/config-section'
|
||||
import { InstructionsSection } from '../ui/instructions-section'
|
||||
import { TestResultDisplay } from '../ui/test-result'
|
||||
|
||||
interface MicrosoftTeamsConfigProps {
|
||||
hmacSecret: string
|
||||
setHmacSecret: (secret: string) => void
|
||||
isLoadingToken: boolean
|
||||
testResult: {
|
||||
success: boolean
|
||||
message?: string
|
||||
test?: any
|
||||
} | null
|
||||
copied: string | null
|
||||
copyToClipboard: (text: string, type: string) => void
|
||||
testWebhook: () => Promise<void>
|
||||
}
|
||||
|
||||
const teamsWebhookExample = JSON.stringify(
|
||||
{
|
||||
type: 'message',
|
||||
id: '1234567890',
|
||||
timestamp: '2023-01-01T00:00:00.000Z',
|
||||
localTimestamp: '2023-01-01T00:00:00.000Z',
|
||||
serviceUrl: 'https://smba.trafficmanager.net/amer/',
|
||||
channelId: 'msteams',
|
||||
from: {
|
||||
id: '29:1234567890abcdef',
|
||||
name: 'John Doe',
|
||||
},
|
||||
conversation: {
|
||||
id: '19:meeting_abcdef@thread.v2',
|
||||
},
|
||||
text: 'Hello Sim Studio Bot!',
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
|
||||
export function MicrosoftTeamsConfig({
|
||||
hmacSecret,
|
||||
setHmacSecret,
|
||||
isLoadingToken,
|
||||
testResult,
|
||||
copied,
|
||||
copyToClipboard,
|
||||
testWebhook,
|
||||
}: MicrosoftTeamsConfigProps) {
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
<ConfigSection title='Microsoft Teams Configuration'>
|
||||
<ConfigField
|
||||
id='teams-hmac-secret'
|
||||
label='HMAC Secret'
|
||||
description='The security token provided by Teams when creating an outgoing webhook. Used to verify request authenticity.'
|
||||
>
|
||||
<Input
|
||||
id='teams-hmac-secret'
|
||||
value={hmacSecret}
|
||||
onChange={(e) => setHmacSecret(e.target.value)}
|
||||
placeholder='Enter HMAC secret from Teams'
|
||||
disabled={isLoadingToken}
|
||||
type='password'
|
||||
/>
|
||||
</ConfigField>
|
||||
</ConfigSection>
|
||||
|
||||
<TestResultDisplay
|
||||
testResult={testResult}
|
||||
copied={copied}
|
||||
copyToClipboard={copyToClipboard}
|
||||
showCurlCommand={true}
|
||||
/>
|
||||
|
||||
<InstructionsSection
|
||||
title='Setting up Outgoing Webhook in Microsoft Teams'
|
||||
tip='Create an outgoing webhook in Teams to receive messages from Teams in Sim Studio.'
|
||||
>
|
||||
<ol className='list-inside list-decimal space-y-1'>
|
||||
<li>Open Microsoft Teams and go to the team where you want to add the webhook.</li>
|
||||
<li>Click the three dots (•••) next to the team name and select "Manage team".</li>
|
||||
<li>Go to the "Apps" tab and click "Create an outgoing webhook".</li>
|
||||
<li>Provide a name, description, and optionally a profile picture.</li>
|
||||
<li>Set the callback URL to your Sim Studio webhook URL (shown above).</li>
|
||||
<li>Copy the HMAC security token and paste it into the "HMAC Secret" field above.</li>
|
||||
<li>Click "Create" to finish setup.</li>
|
||||
</ol>
|
||||
</InstructionsSection>
|
||||
|
||||
<InstructionsSection title='Receiving Messages from Teams'>
|
||||
<p>
|
||||
When users mention your webhook in Teams (using @mention), Teams will send a POST request
|
||||
to your Sim Studio webhook URL with a payload like this:
|
||||
</p>
|
||||
<CodeBlock language='json' code={teamsWebhookExample} className='mt-2 text-sm' />
|
||||
<ul className='mt-3 list-outside list-disc space-y-1 pl-4'>
|
||||
<li>Messages are triggered by @mentioning the webhook name in Teams.</li>
|
||||
<li>Requests include HMAC signature for authentication.</li>
|
||||
<li>You have 5 seconds to respond to the webhook request.</li>
|
||||
</ul>
|
||||
</InstructionsSection>
|
||||
|
||||
<Alert>
|
||||
<Shield className='h-4 w-4' />
|
||||
<AlertTitle>Security</AlertTitle>
|
||||
<AlertDescription>
|
||||
The HMAC secret is used to verify that requests are actually coming from Microsoft Teams.
|
||||
Keep it secure and never share it publicly.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Alert>
|
||||
<Terminal className='h-4 w-4' />
|
||||
<AlertTitle>Requirements</AlertTitle>
|
||||
<AlertDescription>
|
||||
<ul className='mt-1 list-outside list-disc space-y-1 pl-4'>
|
||||
<li>Your Sim Studio webhook URL must use HTTPS and be publicly accessible.</li>
|
||||
<li>Self-signed SSL certificates are not supported by Microsoft Teams.</li>
|
||||
<li>For local testing, use a tunneling service like ngrok or Cloudflare Tunnel.</li>
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import { DiscordConfig } from './providers/discord'
|
||||
import { GenericConfig } from './providers/generic'
|
||||
import { GithubConfig } from './providers/github'
|
||||
import { GmailConfig } from './providers/gmail'
|
||||
import { MicrosoftTeamsConfig } from './providers/microsoftteams'
|
||||
import { SlackConfig } from './providers/slack'
|
||||
import { StripeConfig } from './providers/stripe'
|
||||
import { TelegramConfig } from './providers/telegram'
|
||||
@@ -79,6 +80,8 @@ export function WebhookModal({
|
||||
const [discordAvatarUrl, setDiscordAvatarUrl] = useState('')
|
||||
const [slackSigningSecret, setSlackSigningSecret] = useState('')
|
||||
const [telegramBotToken, setTelegramBotToken] = useState('')
|
||||
// Microsoft Teams-specific state
|
||||
const [microsoftTeamsHmacSecret, setMicrosoftTeamsHmacSecret] = useState('')
|
||||
// Airtable-specific state
|
||||
const [airtableWebhookSecret, _setAirtableWebhookSecret] = useState('')
|
||||
const [airtableBaseId, setAirtableBaseId] = useState('')
|
||||
@@ -103,6 +106,7 @@ export function WebhookModal({
|
||||
airtableTableId: '',
|
||||
airtableIncludeCellValues: false,
|
||||
telegramBotToken: '',
|
||||
microsoftTeamsHmacSecret: '',
|
||||
selectedLabels: ['INBOX'] as string[],
|
||||
labelFilterBehavior: 'INCLUDE',
|
||||
markAsRead: false,
|
||||
@@ -259,6 +263,15 @@ export function WebhookModal({
|
||||
includeRawEmail: config.includeRawEmail,
|
||||
}))
|
||||
}
|
||||
} else if (webhookProvider === 'microsoftteams') {
|
||||
const hmacSecret = config.hmacSecret || ''
|
||||
|
||||
setMicrosoftTeamsHmacSecret(hmacSecret)
|
||||
|
||||
setOriginalValues((prev) => ({
|
||||
...prev,
|
||||
microsoftTeamsHmacSecret: hmacSecret,
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -303,7 +316,9 @@ export function WebhookModal({
|
||||
!originalValues.selectedLabels.every((label) => selectedLabels.includes(label)) ||
|
||||
labelFilterBehavior !== originalValues.labelFilterBehavior ||
|
||||
markAsRead !== originalValues.markAsRead ||
|
||||
includeRawEmail !== originalValues.includeRawEmail))
|
||||
includeRawEmail !== originalValues.includeRawEmail)) ||
|
||||
(webhookProvider === 'microsoftteams' &&
|
||||
microsoftTeamsHmacSecret !== originalValues.microsoftTeamsHmacSecret)
|
||||
|
||||
setHasUnsavedChanges(hasChanges)
|
||||
}, [
|
||||
@@ -327,6 +342,7 @@ export function WebhookModal({
|
||||
labelFilterBehavior,
|
||||
markAsRead,
|
||||
includeRawEmail,
|
||||
microsoftTeamsHmacSecret,
|
||||
])
|
||||
|
||||
// Validate required fields for current provider
|
||||
@@ -354,6 +370,9 @@ export function WebhookModal({
|
||||
case 'gmail':
|
||||
isValid = selectedLabels.length > 0
|
||||
break
|
||||
case 'microsoftteams':
|
||||
isValid = microsoftTeamsHmacSecret.trim() !== ''
|
||||
break
|
||||
}
|
||||
setIsCurrentConfigValid(isValid)
|
||||
}, [
|
||||
@@ -364,6 +383,7 @@ export function WebhookModal({
|
||||
whatsappVerificationToken,
|
||||
telegramBotToken,
|
||||
selectedLabels,
|
||||
microsoftTeamsHmacSecret,
|
||||
])
|
||||
|
||||
// Use the provided path or generate a UUID-based path
|
||||
@@ -433,6 +453,10 @@ export function WebhookModal({
|
||||
return {
|
||||
botToken: telegramBotToken || undefined,
|
||||
}
|
||||
case 'microsoftteams':
|
||||
return {
|
||||
hmacSecret: microsoftTeamsHmacSecret,
|
||||
}
|
||||
default:
|
||||
return {}
|
||||
}
|
||||
@@ -482,6 +506,7 @@ export function WebhookModal({
|
||||
airtableTableId,
|
||||
airtableIncludeCellValues,
|
||||
telegramBotToken,
|
||||
microsoftTeamsHmacSecret,
|
||||
selectedLabels,
|
||||
labelFilterBehavior,
|
||||
markAsRead,
|
||||
@@ -727,6 +752,18 @@ export function WebhookModal({
|
||||
webhookUrl={webhookUrl}
|
||||
/>
|
||||
)
|
||||
case 'microsoftteams':
|
||||
return (
|
||||
<MicrosoftTeamsConfig
|
||||
hmacSecret={microsoftTeamsHmacSecret}
|
||||
setHmacSecret={setMicrosoftTeamsHmacSecret}
|
||||
isLoadingToken={isLoadingToken}
|
||||
testResult={testResult}
|
||||
copied={copied}
|
||||
copyToClipboard={copyToClipboard}
|
||||
testWebhook={testWebhook}
|
||||
/>
|
||||
)
|
||||
default:
|
||||
return (
|
||||
<GenericConfig
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
DiscordIcon,
|
||||
GithubIcon,
|
||||
GmailIcon,
|
||||
MicrosoftTeamsIcon,
|
||||
SlackIcon,
|
||||
StripeIcon,
|
||||
TelegramIcon,
|
||||
@@ -85,6 +86,10 @@ export interface TelegramConfig {
|
||||
botToken?: string
|
||||
}
|
||||
|
||||
export interface MicrosoftTeamsConfig {
|
||||
hmacSecret: string
|
||||
}
|
||||
|
||||
// Union type for all provider configurations
|
||||
export type ProviderConfig =
|
||||
| WhatsAppConfig
|
||||
@@ -96,6 +101,7 @@ export type ProviderConfig =
|
||||
| AirtableWebhookConfig
|
||||
| TelegramConfig
|
||||
| GmailConfig
|
||||
| MicrosoftTeamsConfig
|
||||
| Record<string, never>
|
||||
|
||||
// Define available webhook providers
|
||||
@@ -280,6 +286,20 @@ export const WEBHOOK_PROVIDERS: { [key: string]: WebhookProvider } = {
|
||||
},
|
||||
},
|
||||
},
|
||||
microsoftteams: {
|
||||
id: 'microsoftteams',
|
||||
name: 'Microsoft Teams',
|
||||
icon: (props) => <MicrosoftTeamsIcon {...props} />,
|
||||
configFields: {
|
||||
hmacSecret: {
|
||||
type: 'string',
|
||||
label: 'HMAC Secret',
|
||||
placeholder: 'Enter HMAC secret from Teams outgoing webhook',
|
||||
description:
|
||||
'The security token provided by Teams when creating an outgoing webhook. Used to verify request authenticity.',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
interface WebhookConfigProps {
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
import { importWorkflowFromYaml } from '@/stores/workflows/yaml/importer'
|
||||
import type { EditorFormat } from './workflow-text-editor'
|
||||
|
||||
const logger = createLogger('WorkflowApplier')
|
||||
|
||||
export type EditorFormat = 'json' | 'yaml'
|
||||
|
||||
export interface ApplyResult {
|
||||
success: boolean
|
||||
errors: string[]
|
||||
@@ -15,13 +13,15 @@ export interface ApplyResult {
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply workflow changes by using the existing importer for YAML
|
||||
* or direct state replacement for JSON
|
||||
* Apply workflow changes by using the new consolidated YAML endpoint
|
||||
* for YAML format or direct state replacement for JSON
|
||||
*/
|
||||
export async function applyWorkflowDiff(
|
||||
content: string,
|
||||
format: EditorFormat
|
||||
): Promise<ApplyResult> {
|
||||
console.log('🔥 applyWorkflowDiff called!', { format, contentLength: content.length })
|
||||
|
||||
try {
|
||||
const { activeWorkflowId } = useWorkflowRegistry.getState()
|
||||
|
||||
@@ -34,155 +34,161 @@ export async function applyWorkflowDiff(
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('Starting applyWorkflowDiff', {
|
||||
format,
|
||||
activeWorkflowId,
|
||||
contentLength: content.length,
|
||||
})
|
||||
|
||||
if (format === 'yaml') {
|
||||
// Use the existing YAML importer which handles ID mapping and complete state replacement
|
||||
const workflowActions = {
|
||||
addBlock: () => {}, // Not used in this path
|
||||
addEdge: () => {}, // Not used in this path
|
||||
applyAutoLayout: () => {
|
||||
// Trigger auto layout after import
|
||||
window.dispatchEvent(new CustomEvent('trigger-auto-layout'))
|
||||
},
|
||||
setSubBlockValue: () => {}, // Not used in this path
|
||||
getExistingBlocks: () => useWorkflowStore.getState().blocks,
|
||||
}
|
||||
console.log('🔥 Processing YAML format!')
|
||||
|
||||
const result = await importWorkflowFromYaml(content, workflowActions)
|
||||
logger.info('Processing YAML format - calling consolidated YAML endpoint')
|
||||
|
||||
return {
|
||||
success: result.success,
|
||||
errors: result.errors,
|
||||
warnings: result.warnings,
|
||||
appliedOperations: result.success ? 1 : 0, // One complete import operation
|
||||
}
|
||||
}
|
||||
// Handle JSON format - complete state replacement
|
||||
let parsedData: any
|
||||
try {
|
||||
parsedData = JSON.parse(content)
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
errors: [`Invalid JSON: ${error instanceof Error ? error.message : 'Parse error'}`],
|
||||
warnings: [],
|
||||
appliedOperations: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// Validate JSON structure
|
||||
if (!parsedData.state || !parsedData.state.blocks) {
|
||||
return {
|
||||
success: false,
|
||||
errors: ['Invalid JSON structure: missing state.blocks'],
|
||||
warnings: [],
|
||||
appliedOperations: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// Extract workflow state and subblock values
|
||||
const newWorkflowState = {
|
||||
blocks: parsedData.state.blocks,
|
||||
edges: parsedData.state.edges || [],
|
||||
loops: parsedData.state.loops || {},
|
||||
parallels: parsedData.state.parallels || {},
|
||||
lastSaved: Date.now(),
|
||||
isDeployed: parsedData.state.isDeployed || false,
|
||||
deployedAt: parsedData.state.deployedAt,
|
||||
deploymentStatuses: parsedData.state.deploymentStatuses || {},
|
||||
hasActiveWebhook: parsedData.state.hasActiveWebhook || false,
|
||||
}
|
||||
|
||||
// Atomically update local state with rollback on failure
|
||||
const previousWorkflowState = useWorkflowStore.getState()
|
||||
const previousSubBlockState = useSubBlockStore.getState()
|
||||
|
||||
try {
|
||||
// Update workflow state first
|
||||
useWorkflowStore.setState(newWorkflowState)
|
||||
|
||||
// Update subblock values if provided
|
||||
if (parsedData.subBlockValues) {
|
||||
useSubBlockStore.setState((state: any) => ({
|
||||
workflowValues: {
|
||||
...state.workflowValues,
|
||||
[activeWorkflowId]: parsedData.subBlockValues,
|
||||
try {
|
||||
// Use the new consolidated YAML endpoint
|
||||
const response = await fetch(`/api/workflows/${activeWorkflowId}/yaml`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}))
|
||||
}
|
||||
} catch (error) {
|
||||
// Rollback state changes on any failure
|
||||
logger.error('State update failed, rolling back:', error)
|
||||
useWorkflowStore.setState(previousWorkflowState)
|
||||
useSubBlockStore.setState(previousSubBlockState)
|
||||
body: JSON.stringify({
|
||||
yamlContent: content,
|
||||
source: 'editor',
|
||||
applyAutoLayout: true,
|
||||
createCheckpoint: false,
|
||||
}),
|
||||
})
|
||||
|
||||
return {
|
||||
success: false,
|
||||
errors: [
|
||||
`State update failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
],
|
||||
warnings: [],
|
||||
appliedOperations: 0,
|
||||
}
|
||||
}
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
logger.error('Failed to save YAML workflow:', errorData)
|
||||
return {
|
||||
success: false,
|
||||
errors: [errorData.message || `HTTP ${response.status}: ${response.statusText}`],
|
||||
warnings: [],
|
||||
appliedOperations: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// Update workflow metadata if provided
|
||||
if (parsedData.workflow) {
|
||||
const { updateWorkflow } = useWorkflowRegistry.getState()
|
||||
const metadata = parsedData.workflow
|
||||
const result = await response.json()
|
||||
|
||||
updateWorkflow(activeWorkflowId, {
|
||||
name: metadata.name,
|
||||
description: metadata.description,
|
||||
color: metadata.color,
|
||||
})
|
||||
}
|
||||
logger.info('YAML workflow save completed', {
|
||||
success: result.success,
|
||||
errors: result.errors || [],
|
||||
warnings: result.warnings || [],
|
||||
})
|
||||
|
||||
// Save to database
|
||||
try {
|
||||
const response = await fetch(`/api/workflows/${activeWorkflowId}/state`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(newWorkflowState),
|
||||
})
|
||||
// Trigger auto layout after successful save
|
||||
window.dispatchEvent(new CustomEvent('trigger-auto-layout'))
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
logger.error('Failed to save workflow state:', errorData.error)
|
||||
// Calculate applied operations (blocks + edges)
|
||||
const appliedOperations = (result.data?.blocksCount || 0) + (result.data?.edgesCount || 0)
|
||||
|
||||
return {
|
||||
success: result.success,
|
||||
errors: result.errors || [],
|
||||
warnings: result.warnings || [],
|
||||
appliedOperations,
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('YAML processing failed:', error)
|
||||
return {
|
||||
success: false,
|
||||
errors: [`Database save failed: ${errorData.error || 'Unknown error'}`],
|
||||
errors: [
|
||||
`YAML processing failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
],
|
||||
warnings: [],
|
||||
appliedOperations: 0,
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to save workflow state:', error)
|
||||
return {
|
||||
success: false,
|
||||
errors: [
|
||||
`Failed to save workflow state: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
],
|
||||
warnings: [],
|
||||
appliedOperations: 0,
|
||||
}
|
||||
|
||||
if (format === 'json') {
|
||||
logger.info('Processing JSON format - direct state replacement')
|
||||
|
||||
try {
|
||||
const workflowState = JSON.parse(content)
|
||||
|
||||
// Validate that this looks like a workflow state
|
||||
if (!workflowState.blocks || !workflowState.edges) {
|
||||
return {
|
||||
success: false,
|
||||
errors: ['Invalid workflow state: missing blocks or edges'],
|
||||
warnings: [],
|
||||
appliedOperations: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// Use the existing workflow state endpoint for JSON
|
||||
const response = await fetch(`/api/workflows/${activeWorkflowId}/state`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...workflowState,
|
||||
lastSaved: Date.now(),
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
logger.error('Failed to save JSON workflow state:', errorData)
|
||||
return {
|
||||
success: false,
|
||||
errors: [errorData.error || `HTTP ${response.status}: ${response.statusText}`],
|
||||
warnings: [],
|
||||
appliedOperations: 0,
|
||||
}
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
logger.info('JSON workflow state save completed', {
|
||||
success: result.success,
|
||||
blocksCount: result.blocksCount,
|
||||
edgesCount: result.edgesCount,
|
||||
})
|
||||
|
||||
// Trigger auto layout after successful save
|
||||
window.dispatchEvent(new CustomEvent('trigger-auto-layout'))
|
||||
|
||||
// Calculate applied operations
|
||||
const appliedOperations = (result.blocksCount || 0) + (result.edgesCount || 0)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
errors: [],
|
||||
warnings: [],
|
||||
appliedOperations,
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('JSON processing failed:', error)
|
||||
return {
|
||||
success: false,
|
||||
errors: [
|
||||
`JSON processing failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
],
|
||||
warnings: [],
|
||||
appliedOperations: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger auto layout
|
||||
window.dispatchEvent(new CustomEvent('trigger-auto-layout'))
|
||||
|
||||
return {
|
||||
success: true,
|
||||
errors: [],
|
||||
warnings: [],
|
||||
appliedOperations: 1, // One complete state replacement
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to apply workflow changes:', error)
|
||||
return {
|
||||
success: false,
|
||||
errors: [`Apply failed: ${error instanceof Error ? error.message : 'Unknown error'}`],
|
||||
errors: [`Unsupported format: ${format}`],
|
||||
warnings: [],
|
||||
appliedOperations: 0,
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('applyWorkflowDiff failed:', error)
|
||||
return {
|
||||
success: false,
|
||||
errors: [
|
||||
`Failed to apply workflow changes: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
],
|
||||
warnings: [],
|
||||
appliedOperations: 0,
|
||||
}
|
||||
|
||||
@@ -61,6 +61,12 @@ export function WorkflowTextEditorModal({
|
||||
// Handle save operation
|
||||
const handleSave = useCallback(
|
||||
async (content: string, contentFormat: EditorFormat) => {
|
||||
console.log('🔥 WorkflowTextEditorModal.handleSave called!', {
|
||||
contentFormat,
|
||||
contentLength: content.length,
|
||||
activeWorkflowId,
|
||||
})
|
||||
|
||||
if (!activeWorkflowId) {
|
||||
return { success: false, errors: ['No active workflow'] }
|
||||
}
|
||||
@@ -68,9 +74,13 @@ export function WorkflowTextEditorModal({
|
||||
try {
|
||||
logger.info('Applying workflow changes from text editor', { format: contentFormat })
|
||||
|
||||
console.log('🔥 About to call applyWorkflowDiff!', { contentFormat })
|
||||
|
||||
// Apply changes using the simplified approach
|
||||
const applyResult = await applyWorkflowDiff(content, contentFormat)
|
||||
|
||||
console.log('🔥 applyWorkflowDiff returned!', { success: applyResult.success })
|
||||
|
||||
if (applyResult.success) {
|
||||
logger.info('Successfully applied workflow changes', {
|
||||
appliedOperations: applyResult.appliedOperations,
|
||||
|
||||
@@ -605,9 +605,9 @@ export function useWorkflowExecution() {
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('Executing debug step with blocks:', pendingBlocks)
|
||||
logger.info('Executing debug step with blocks:', pendingBlocks)
|
||||
const result = await executor!.continueExecution(pendingBlocks, debugContext!)
|
||||
console.log('Debug step execution result:', result)
|
||||
logger.info('Debug step execution result:', result)
|
||||
|
||||
if (isDebugSessionComplete(result)) {
|
||||
await handleDebugSessionComplete(result)
|
||||
@@ -660,7 +660,7 @@ export function useWorkflowExecution() {
|
||||
let currentContext = { ...debugContext! }
|
||||
let currentPendingBlocks = [...pendingBlocks]
|
||||
|
||||
console.log('Starting resume execution with blocks:', currentPendingBlocks)
|
||||
logger.info('Starting resume execution with blocks:', currentPendingBlocks)
|
||||
|
||||
// Continue execution until there are no more pending blocks
|
||||
let iterationCount = 0
|
||||
|
||||
@@ -327,6 +327,10 @@ const WorkflowContent = React.memo(() => {
|
||||
const handleAutoLayoutEvent = () => {
|
||||
if (cleanup) cleanup()
|
||||
|
||||
// Call auto layout directly without debounce for copilot events
|
||||
handleAutoLayout()
|
||||
|
||||
// Also set up debounced version as backup
|
||||
cleanup = debouncedAutoLayout()
|
||||
}
|
||||
|
||||
@@ -402,7 +406,6 @@ const WorkflowContent = React.memo(() => {
|
||||
}
|
||||
|
||||
const { type } = event.detail
|
||||
console.log('🛠️ Adding block from toolbar:', type)
|
||||
|
||||
if (!type) return
|
||||
if (type === 'connectionBlock') return
|
||||
|
||||
@@ -5,10 +5,8 @@ import { logger } from '@sentry/nextjs'
|
||||
import { File, Folder, Plus, Upload } from 'lucide-react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { generateFolderName } from '@/lib/naming'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/w/components/providers/workspace-permissions-provider'
|
||||
import { useFolderStore } from '@/stores/folders/store'
|
||||
@@ -25,8 +23,6 @@ export function CreateMenu({
|
||||
isCollapsed,
|
||||
isCreatingWorkflow = false,
|
||||
}: CreateMenuProps) {
|
||||
const [showFolderDialog, setShowFolderDialog] = useState(false)
|
||||
const [folderName, setFolderName] = useState('')
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [pressTimer, setPressTimer] = useState<NodeJS.Timeout | null>(null)
|
||||
@@ -61,10 +57,37 @@ export function CreateMenu({
|
||||
}
|
||||
}, [onCreateWorkflow, isCreatingWorkflow, router, workspaceId])
|
||||
|
||||
const handleCreateFolder = useCallback(() => {
|
||||
const handleCreateFolder = useCallback(async () => {
|
||||
setIsOpen(false)
|
||||
setShowFolderDialog(true)
|
||||
}, [])
|
||||
|
||||
if (isCreating) {
|
||||
logger.info('Folder creation already in progress, ignoring request')
|
||||
return
|
||||
}
|
||||
|
||||
if (!workspaceId) {
|
||||
logger.error('No workspaceId available for folder creation')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setIsCreating(true)
|
||||
|
||||
// Generate folder name using fresh data from API
|
||||
const folderName = await generateFolderName(workspaceId)
|
||||
|
||||
await createFolder({
|
||||
name: folderName,
|
||||
workspaceId: workspaceId,
|
||||
})
|
||||
|
||||
logger.info(`Created folder: ${folderName}`)
|
||||
} catch (error) {
|
||||
logger.error('Failed to create folder:', { error })
|
||||
} finally {
|
||||
setIsCreating(false)
|
||||
}
|
||||
}, [createFolder, workspaceId])
|
||||
|
||||
const handleImportWorkflow = useCallback(() => {
|
||||
setIsOpen(false)
|
||||
@@ -146,30 +169,6 @@ export function CreateMenu({
|
||||
}
|
||||
}, [pressTimer])
|
||||
|
||||
const handleFolderSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!folderName.trim() || !workspaceId) return
|
||||
|
||||
setIsCreating(true)
|
||||
try {
|
||||
await createFolder({
|
||||
name: folderName.trim(),
|
||||
workspaceId: workspaceId,
|
||||
})
|
||||
setFolderName('')
|
||||
setShowFolderDialog(false)
|
||||
} catch (error) {
|
||||
logger.error('Failed to create folder:', { error })
|
||||
} finally {
|
||||
setIsCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
setFolderName('')
|
||||
setShowFolderDialog(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
||||
@@ -221,11 +220,15 @@ export function CreateMenu({
|
||||
</button>
|
||||
|
||||
<button
|
||||
className='flex w-full cursor-pointer items-center gap-2 rounded-md px-3 py-2 font-[380] text-card-foreground text-sm outline-none hover:bg-secondary/50 focus:bg-secondary/50'
|
||||
className={cn(
|
||||
'flex w-full cursor-pointer items-center gap-2 rounded-md px-3 py-2 font-[380] text-card-foreground text-sm outline-none hover:bg-secondary/50 focus:bg-secondary/50',
|
||||
isCreating && 'cursor-not-allowed opacity-50'
|
||||
)}
|
||||
onClick={handleCreateFolder}
|
||||
disabled={isCreating}
|
||||
>
|
||||
<Folder className='h-4 w-4' />
|
||||
New folder
|
||||
{isCreating ? 'Creating...' : 'New folder'}
|
||||
</button>
|
||||
|
||||
{userPermissions.canEdit && (
|
||||
@@ -246,36 +249,6 @@ export function CreateMenu({
|
||||
disabled={!userPermissions.canEdit}
|
||||
onClose={() => setIsOpen(false)}
|
||||
/>
|
||||
|
||||
{/* Folder creation dialog */}
|
||||
<Dialog open={showFolderDialog} onOpenChange={setShowFolderDialog}>
|
||||
<DialogContent className='sm:max-w-[425px]'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Folder</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleFolderSubmit} className='space-y-4'>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='folder-name'>Folder Name</Label>
|
||||
<Input
|
||||
id='folder-name'
|
||||
value={folderName}
|
||||
onChange={(e) => setFolderName(e.target.value)}
|
||||
placeholder='Enter folder name...'
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className='flex justify-end space-x-2'>
|
||||
<Button type='button' variant='outline' onClick={handleCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type='submit' disabled={!folderName.trim() || isCreating}>
|
||||
{isCreating ? 'Creating...' : 'Create Folder'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,9 +3,8 @@
|
||||
import { forwardRef, useImperativeHandle, useRef, useState } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { importWorkflowFromYaml, parseWorkflowYaml } from '@/stores/workflows/yaml/importer'
|
||||
import { parseWorkflowYaml } from '@/stores/workflows/yaml/importer'
|
||||
|
||||
const logger = createLogger('ImportControls')
|
||||
|
||||
@@ -36,8 +35,6 @@ export const ImportControls = forwardRef<ImportControlsRef, ImportControlsProps>
|
||||
|
||||
// Stores and hooks
|
||||
const { createWorkflow } = useWorkflowRegistry()
|
||||
const { collaborativeAddBlock, collaborativeAddEdge, collaborativeSetSubblockValue } =
|
||||
useCollaborativeWorkflow()
|
||||
|
||||
// Expose methods to parent component
|
||||
useImperativeHandle(ref, () => ({
|
||||
@@ -107,27 +104,32 @@ export const ImportControls = forwardRef<ImportControlsRef, ImportControlsProps>
|
||||
workspaceId,
|
||||
})
|
||||
|
||||
// Import the YAML into the new workflow BEFORE navigation (creates complete state and saves directly to DB)
|
||||
// This avoids timing issues with workflow reload during navigation
|
||||
const result = await importWorkflowFromYaml(
|
||||
content,
|
||||
{
|
||||
addBlock: collaborativeAddBlock,
|
||||
addEdge: collaborativeAddEdge,
|
||||
applyAutoLayout: () => {
|
||||
// Do nothing - auto layout should not run during import
|
||||
},
|
||||
setSubBlockValue: (blockId: string, subBlockId: string, value: unknown) => {
|
||||
// Use the collaborative function - the same one called when users type into fields
|
||||
collaborativeSetSubblockValue(blockId, subBlockId, value)
|
||||
},
|
||||
getExistingBlocks: () => {
|
||||
// For a new workflow, we'll get the starter block from the server
|
||||
return {}
|
||||
},
|
||||
// Use the new consolidated YAML endpoint to import the workflow
|
||||
const response = await fetch(`/api/workflows/${newWorkflowId}/yaml`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
newWorkflowId
|
||||
) // Pass the new workflow ID to import into
|
||||
body: JSON.stringify({
|
||||
yamlContent: content,
|
||||
description: 'Workflow imported from YAML',
|
||||
source: 'import',
|
||||
applyAutoLayout: true,
|
||||
createCheckpoint: false,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
setImportResult({
|
||||
success: false,
|
||||
errors: [errorData.message || `HTTP ${response.status}: ${response.statusText}`],
|
||||
warnings: errorData.warnings || [],
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
// Navigate to the new workflow AFTER import is complete
|
||||
if (result.success) {
|
||||
@@ -135,7 +137,12 @@ export const ImportControls = forwardRef<ImportControlsRef, ImportControlsProps>
|
||||
router.push(`/workspace/${workspaceId}/w/${newWorkflowId}`)
|
||||
}
|
||||
|
||||
setImportResult(result)
|
||||
setImportResult({
|
||||
success: result.success,
|
||||
errors: result.errors || [],
|
||||
warnings: result.warnings || [],
|
||||
summary: result.summary,
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
setYamlContent('')
|
||||
|
||||
@@ -4,17 +4,15 @@ import { useState } from 'react'
|
||||
import { File, Folder, MoreHorizontal, Pencil, Trash2 } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { generateSubfolderName } from '@/lib/naming'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/w/components/providers/workspace-permissions-provider'
|
||||
import { useFolderStore } from '@/stores/folders/store'
|
||||
|
||||
@@ -37,8 +35,6 @@ export function FolderContextMenu({
|
||||
onStartEdit,
|
||||
level,
|
||||
}: FolderContextMenuProps) {
|
||||
const [showSubfolderDialog, setShowSubfolderDialog] = useState(false)
|
||||
const [subfolderName, setSubfolderName] = useState('')
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
@@ -46,14 +42,46 @@ export function FolderContextMenu({
|
||||
// Get user permissions for the workspace
|
||||
const userPermissions = useUserPermissionsContext()
|
||||
|
||||
const { createFolder, deleteFolder } = useFolderStore()
|
||||
const { createFolder, deleteFolder, setExpanded } = useFolderStore()
|
||||
|
||||
const handleCreateWorkflow = () => {
|
||||
// Ensure folder is expanded so user can see the new workflow
|
||||
setExpanded(folderId, true)
|
||||
onCreateWorkflow(folderId)
|
||||
}
|
||||
|
||||
const handleCreateSubfolder = () => {
|
||||
setShowSubfolderDialog(true)
|
||||
const handleCreateSubfolder = async () => {
|
||||
if (isCreating) {
|
||||
logger.info('Subfolder creation already in progress, ignoring request')
|
||||
return
|
||||
}
|
||||
|
||||
if (!workspaceId) {
|
||||
logger.error('No workspaceId available for subfolder creation')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setIsCreating(true)
|
||||
|
||||
// Ensure parent folder is expanded so user can see the new subfolder
|
||||
setExpanded(folderId, true)
|
||||
|
||||
// Generate subfolder name using fresh data from API
|
||||
const subfolderName = await generateSubfolderName(workspaceId, folderId)
|
||||
|
||||
await createFolder({
|
||||
name: subfolderName,
|
||||
workspaceId: workspaceId,
|
||||
parentId: folderId,
|
||||
})
|
||||
|
||||
logger.info(`Created subfolder: ${subfolderName}`)
|
||||
} catch (error) {
|
||||
logger.error('Failed to create subfolder:', { error })
|
||||
} finally {
|
||||
setIsCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRename = () => {
|
||||
@@ -76,31 +104,6 @@ export function FolderContextMenu({
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubfolderSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!subfolderName.trim() || !workspaceId) return
|
||||
|
||||
setIsCreating(true)
|
||||
try {
|
||||
await createFolder({
|
||||
name: subfolderName.trim(),
|
||||
workspaceId: workspaceId,
|
||||
parentId: folderId,
|
||||
})
|
||||
setSubfolderName('')
|
||||
setShowSubfolderDialog(false)
|
||||
} catch (error) {
|
||||
logger.error('Failed to create subfolder:', { error })
|
||||
} finally {
|
||||
setIsCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
setSubfolderName('')
|
||||
setShowSubfolderDialog(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
@@ -175,37 +178,6 @@ export function FolderContextMenu({
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Subfolder creation dialog */}
|
||||
<Dialog open={showSubfolderDialog} onOpenChange={setShowSubfolderDialog}>
|
||||
<DialogContent className='sm:max-w-[425px]' onClick={(e) => e.stopPropagation()}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Subfolder</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubfolderSubmit} className='space-y-4'>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='subfolder-name'>Folder Name</Label>
|
||||
<Input
|
||||
id='subfolder-name'
|
||||
value={subfolderName}
|
||||
onChange={(e) => setSubfolderName(e.target.value)}
|
||||
placeholder='Enter folder name...'
|
||||
maxLength={50}
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className='flex justify-end space-x-2'>
|
||||
<Button type='button' variant='outline' onClick={handleCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type='submit' disabled={!subfolderName.trim() || isCreating}>
|
||||
{isCreating ? 'Creating...' : 'Create Folder'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -304,7 +304,7 @@ export function FolderItem({
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleInputBlur}
|
||||
className='flex-1 border-0 bg-transparent p-0 text-muted-foreground text-sm outline-none focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0'
|
||||
className='min-w-0 flex-1 border-0 bg-transparent p-0 text-muted-foreground text-sm outline-none focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0'
|
||||
maxLength={50}
|
||||
disabled={isRenaming}
|
||||
onClick={(e) => e.stopPropagation()} // Prevent folder toggle when clicking input
|
||||
@@ -314,7 +314,9 @@ export function FolderItem({
|
||||
spellCheck='false'
|
||||
/>
|
||||
) : (
|
||||
<span className='flex-1 select-none truncate text-muted-foreground'>{folder.name}</span>
|
||||
<span className='min-w-0 flex-1 select-none truncate text-muted-foreground'>
|
||||
{folder.name}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{!isEditing && (
|
||||
|
||||
@@ -225,7 +225,7 @@ export function WorkflowItem({
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleInputBlur}
|
||||
className={`flex-1 border-0 bg-transparent p-0 font-medium text-sm outline-none focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 ${
|
||||
className={`min-w-0 flex-1 border-0 bg-transparent p-0 font-medium text-sm outline-none focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 ${
|
||||
active && !isDragOver ? 'text-foreground' : 'text-muted-foreground'
|
||||
}`}
|
||||
maxLength={100}
|
||||
@@ -237,7 +237,7 @@ export function WorkflowItem({
|
||||
spellCheck='false'
|
||||
/>
|
||||
) : (
|
||||
<span className='flex-1 select-none truncate'>
|
||||
<span className='min-w-0 flex-1 select-none truncate'>
|
||||
{workflow.name}
|
||||
{isMarketplace && ' (Preview)'}
|
||||
</span>
|
||||
|
||||
@@ -4,12 +4,15 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { useParams, usePathname } from 'next/navigation'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { type FolderTreeNode, useFolderStore } from '@/stores/folders/store'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
|
||||
import { FolderItem } from './components/folder-item'
|
||||
import { WorkflowItem } from './components/workflow-item'
|
||||
|
||||
const logger = createLogger('FolderTree')
|
||||
|
||||
interface FolderSectionProps {
|
||||
folder: FolderTreeNode
|
||||
level: number
|
||||
@@ -282,9 +285,9 @@ function useDragHandlers(
|
||||
for (const workflowId of workflowIds) {
|
||||
await updateWorkflow(workflowId, { folderId: targetFolderId })
|
||||
}
|
||||
console.log(logMessage || `Moved ${workflowIds.length} workflow(s)`)
|
||||
logger.info(logMessage || `Moved ${workflowIds.length} workflow(s)`)
|
||||
} catch (error) {
|
||||
console.error('Failed to move workflows:', error)
|
||||
logger.error('Failed to move workflows:', error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -298,7 +301,7 @@ function useDragHandlers(
|
||||
|
||||
// Prevent circular references - don't allow dropping a folder into itself or its descendants
|
||||
if (targetFolderId === folderIdData) {
|
||||
console.log('Cannot move folder into itself')
|
||||
logger.info('Cannot move folder into itself')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -308,21 +311,21 @@ function useDragHandlers(
|
||||
targetFolderId &&
|
||||
draggedFolderPath.some((ancestor) => ancestor.id === targetFolderId)
|
||||
) {
|
||||
console.log('Cannot move folder into its own descendant')
|
||||
logger.info('Cannot move folder into its own descendant')
|
||||
return
|
||||
}
|
||||
|
||||
// If target folder is already at level 1 (has 1 parent), we can't nest another folder
|
||||
if (targetFolderPath.length >= 1) {
|
||||
console.log('Cannot nest folder: Maximum 2 levels of nesting allowed. Drop prevented.')
|
||||
logger.info('Cannot nest folder: Maximum 2 levels of nesting allowed. Drop prevented.')
|
||||
return // Prevent the drop entirely
|
||||
}
|
||||
|
||||
// Target folder is at root level, safe to nest
|
||||
await updateFolder(folderIdData, { parentId: targetFolderId })
|
||||
console.log(`Moved folder to ${targetFolderId ? `folder ${targetFolderId}` : 'root'}`)
|
||||
logger.info(`Moved folder to ${targetFolderId ? `folder ${targetFolderId}` : 'root'}`)
|
||||
} catch (error) {
|
||||
console.error('Failed to move folder:', error)
|
||||
logger.error('Failed to move folder:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -416,9 +419,9 @@ export function FolderTree({
|
||||
for (const folder of deepFolders) {
|
||||
try {
|
||||
await updateFolderAPI(folder.id, { parentId: null })
|
||||
console.log(`Moved deeply nested folder "${folder.name}" to root level`)
|
||||
logger.info(`Moved deeply nested folder "${folder.name}" to root level`)
|
||||
} catch (error) {
|
||||
console.error(`Failed to move folder "${folder.name}":`, error)
|
||||
logger.error(`Failed to move folder "${folder.name}":`, error)
|
||||
}
|
||||
}
|
||||
}, [workspaceId])
|
||||
@@ -564,7 +567,7 @@ export function FolderTree({
|
||||
marketplaceWorkflows.length === 0 &&
|
||||
folderTree.length === 0 &&
|
||||
!isCollapsed && (
|
||||
<div className='break-words px-2 py-1.5 text-muted-foreground text-xs'>
|
||||
<div className='break-words px-2 py-1.5 pr-12 text-muted-foreground text-xs'>
|
||||
No workflows or folders in {workspaceId ? 'this workspace' : 'your account'}. Create one
|
||||
to get started.
|
||||
</div>
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { isDev } from '@/lib/environment'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useUserPermissionsContext } from '../../../providers/workspace-permissions-provider'
|
||||
@@ -251,20 +252,22 @@ export function WorkspaceSelector({
|
||||
|
||||
{/* Bottom Actions */}
|
||||
<div className='mt-2 flex items-center gap-2 border-t pt-2'>
|
||||
{/* Send Invite */}
|
||||
<Button
|
||||
variant='secondary'
|
||||
size='sm'
|
||||
onClick={userPermissions.canAdmin ? () => setShowInviteMembers(true) : undefined}
|
||||
disabled={!userPermissions.canAdmin}
|
||||
className={cn(
|
||||
'h-8 flex-1 justify-center gap-2 rounded-[8px] font-medium text-muted-foreground text-xs transition-colors hover:bg-muted-foreground/10 hover:text-muted-foreground',
|
||||
!userPermissions.canAdmin && 'cursor-not-allowed opacity-50'
|
||||
)}
|
||||
>
|
||||
<Send className='h-3 w-3' />
|
||||
<span>Invite</span>
|
||||
</Button>
|
||||
{/* Send Invite - Hide in development */}
|
||||
{!isDev && (
|
||||
<Button
|
||||
variant='secondary'
|
||||
size='sm'
|
||||
onClick={userPermissions.canAdmin ? () => setShowInviteMembers(true) : undefined}
|
||||
disabled={!userPermissions.canAdmin}
|
||||
className={cn(
|
||||
'h-8 flex-1 justify-center gap-2 rounded-[8px] font-medium text-muted-foreground text-xs transition-colors hover:bg-muted-foreground/10 hover:text-muted-foreground',
|
||||
!userPermissions.canAdmin && 'cursor-not-allowed opacity-50'
|
||||
)}
|
||||
>
|
||||
<Send className='h-3 w-3' />
|
||||
<span>Invite</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Create Workspace */}
|
||||
<Button
|
||||
|
||||
@@ -8,6 +8,7 @@ import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { useSession } from '@/lib/auth-client'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { generateWorkspaceName } from '@/lib/naming'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
getKeyboardShortcutText,
|
||||
@@ -287,13 +288,18 @@ export function Sidebar() {
|
||||
setIsCreatingWorkspace(true)
|
||||
logger.info('Creating new workspace')
|
||||
|
||||
// Generate workspace name using utility function
|
||||
const workspaceName = await generateWorkspaceName()
|
||||
|
||||
logger.info(`Generated workspace name: ${workspaceName}`)
|
||||
|
||||
const response = await fetch('/api/workspaces', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: 'Untitled workspace',
|
||||
name: workspaceName,
|
||||
}),
|
||||
})
|
||||
|
||||
|
||||
1
apps/sim/blocks/blocks/index.ts
Normal file
1
apps/sim/blocks/blocks/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './qdrant'
|
||||
214
apps/sim/blocks/blocks/qdrant.ts
Normal file
214
apps/sim/blocks/blocks/qdrant.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
import { QdrantIcon } from '@/components/icons'
|
||||
import type { QdrantResponse } from '@/tools/qdrant/types'
|
||||
import type { BlockConfig } from '../types'
|
||||
|
||||
export const QdrantBlock: BlockConfig<QdrantResponse> = {
|
||||
type: 'qdrant',
|
||||
name: 'Qdrant',
|
||||
description: 'Use Qdrant vector database',
|
||||
longDescription:
|
||||
'Store, search, and retrieve vector embeddings using Qdrant. Perform semantic similarity searches and manage your vector collections.',
|
||||
docsLink: 'https://qdrant.tech/documentation/',
|
||||
category: 'tools',
|
||||
bgColor: '#1A223F',
|
||||
icon: QdrantIcon,
|
||||
|
||||
subBlocks: [
|
||||
{
|
||||
id: 'operation',
|
||||
title: 'Operation',
|
||||
type: 'dropdown',
|
||||
layout: 'full',
|
||||
options: [
|
||||
{ label: 'Upsert', id: 'upsert' },
|
||||
{ label: 'Search', id: 'search' },
|
||||
{ label: 'Fetch', id: 'fetch' },
|
||||
],
|
||||
},
|
||||
// Upsert fields
|
||||
{
|
||||
id: 'url',
|
||||
title: 'Qdrant URL',
|
||||
type: 'short-input',
|
||||
layout: 'full',
|
||||
placeholder: 'http://localhost:6333',
|
||||
condition: { field: 'operation', value: 'upsert' },
|
||||
},
|
||||
{
|
||||
id: 'apiKey',
|
||||
title: 'API Key',
|
||||
type: 'short-input',
|
||||
layout: 'full',
|
||||
placeholder: 'Your Qdrant API key (optional)',
|
||||
password: true,
|
||||
condition: { field: 'operation', value: 'upsert' },
|
||||
},
|
||||
{
|
||||
id: 'collection',
|
||||
title: 'Collection',
|
||||
type: 'short-input',
|
||||
layout: 'full',
|
||||
placeholder: 'my-collection',
|
||||
condition: { field: 'operation', value: 'upsert' },
|
||||
},
|
||||
{
|
||||
id: 'points',
|
||||
title: 'Points',
|
||||
type: 'long-input',
|
||||
layout: 'full',
|
||||
placeholder: '[{"id": 1, "vector": [0.1, 0.2], "payload": {"category": "a"}}]',
|
||||
condition: { field: 'operation', value: 'upsert' },
|
||||
},
|
||||
// Search fields
|
||||
{
|
||||
id: 'url',
|
||||
title: 'Qdrant URL',
|
||||
type: 'short-input',
|
||||
layout: 'full',
|
||||
placeholder: 'http://localhost:6333',
|
||||
condition: { field: 'operation', value: 'search' },
|
||||
},
|
||||
{
|
||||
id: 'apiKey',
|
||||
title: 'API Key',
|
||||
type: 'short-input',
|
||||
layout: 'full',
|
||||
placeholder: 'Your Qdrant API key (optional)',
|
||||
password: true,
|
||||
condition: { field: 'operation', value: 'search' },
|
||||
},
|
||||
{
|
||||
id: 'collection',
|
||||
title: 'Collection',
|
||||
type: 'short-input',
|
||||
layout: 'full',
|
||||
placeholder: 'my-collection',
|
||||
condition: { field: 'operation', value: 'search' },
|
||||
},
|
||||
{
|
||||
id: 'vector',
|
||||
title: 'Query Vector',
|
||||
type: 'long-input',
|
||||
layout: 'full',
|
||||
placeholder: '[0.1, 0.2]',
|
||||
condition: { field: 'operation', value: 'search' },
|
||||
},
|
||||
{
|
||||
id: 'limit',
|
||||
title: 'Limit',
|
||||
type: 'short-input',
|
||||
layout: 'full',
|
||||
placeholder: '10',
|
||||
condition: { field: 'operation', value: 'search' },
|
||||
},
|
||||
{
|
||||
id: 'filter',
|
||||
title: 'Filter',
|
||||
type: 'long-input',
|
||||
layout: 'full',
|
||||
placeholder: '{"must":[{"key":"city","match":{"value":"London"}}]}',
|
||||
condition: { field: 'operation', value: 'search' },
|
||||
},
|
||||
{
|
||||
id: 'with_payload',
|
||||
title: 'With Payload',
|
||||
type: 'switch',
|
||||
layout: 'full',
|
||||
condition: { field: 'operation', value: 'search' },
|
||||
},
|
||||
{
|
||||
id: 'with_vector',
|
||||
title: 'With Vector',
|
||||
type: 'switch',
|
||||
layout: 'full',
|
||||
condition: { field: 'operation', value: 'search' },
|
||||
},
|
||||
// Fetch fields
|
||||
{
|
||||
id: 'url',
|
||||
title: 'Qdrant URL',
|
||||
type: 'short-input',
|
||||
layout: 'full',
|
||||
placeholder: 'http://localhost:6333',
|
||||
condition: { field: 'operation', value: 'fetch' },
|
||||
},
|
||||
{
|
||||
id: 'apiKey',
|
||||
title: 'API Key',
|
||||
type: 'short-input',
|
||||
layout: 'full',
|
||||
placeholder: 'Your Qdrant API key (optional)',
|
||||
password: true,
|
||||
condition: { field: 'operation', value: 'fetch' },
|
||||
},
|
||||
{
|
||||
id: 'collection',
|
||||
title: 'Collection',
|
||||
type: 'short-input',
|
||||
layout: 'full',
|
||||
placeholder: 'my-collection',
|
||||
condition: { field: 'operation', value: 'fetch' },
|
||||
},
|
||||
{
|
||||
id: 'ids',
|
||||
title: 'IDs',
|
||||
type: 'long-input',
|
||||
layout: 'full',
|
||||
placeholder: '["370446a3-310f-58db-8ce7-31db947c6c1e"]',
|
||||
condition: { field: 'operation', value: 'fetch' },
|
||||
},
|
||||
{
|
||||
id: 'with_payload',
|
||||
title: 'With Payload',
|
||||
type: 'switch',
|
||||
layout: 'full',
|
||||
condition: { field: 'operation', value: 'fetch' },
|
||||
},
|
||||
{
|
||||
id: 'with_vector',
|
||||
title: 'With Vector',
|
||||
type: 'switch',
|
||||
layout: 'full',
|
||||
condition: { field: 'operation', value: 'fetch' },
|
||||
},
|
||||
],
|
||||
|
||||
tools: {
|
||||
access: ['qdrant_upsert_points', 'qdrant_search_vector', 'qdrant_fetch_points'],
|
||||
config: {
|
||||
tool: (params: Record<string, any>) => {
|
||||
switch (params.operation) {
|
||||
case 'upsert':
|
||||
return 'qdrant_upsert_points'
|
||||
case 'search':
|
||||
return 'qdrant_search_vector'
|
||||
case 'fetch':
|
||||
return 'qdrant_fetch_points'
|
||||
default:
|
||||
throw new Error('Invalid operation selected')
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
inputs: {
|
||||
operation: { type: 'string', required: true },
|
||||
url: { type: 'string', required: true },
|
||||
apiKey: { type: 'string', required: false },
|
||||
collection: { type: 'string', required: true },
|
||||
points: { type: 'json', required: false },
|
||||
vector: { type: 'json', required: false },
|
||||
limit: { type: 'number', required: false },
|
||||
filter: { type: 'json', required: false },
|
||||
ids: { type: 'json', required: false },
|
||||
with_payload: { type: 'boolean', required: false },
|
||||
with_vector: { type: 'boolean', required: false },
|
||||
},
|
||||
|
||||
outputs: {
|
||||
matches: 'any',
|
||||
upsertedCount: 'any',
|
||||
data: 'any',
|
||||
status: 'any',
|
||||
},
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
DiscordIcon,
|
||||
GithubIcon,
|
||||
GmailIcon,
|
||||
MicrosoftTeamsIcon,
|
||||
SignalIcon,
|
||||
SlackIcon,
|
||||
StripeIcon,
|
||||
@@ -23,6 +24,7 @@ const getWebhookProviderIcon = (provider: string) => {
|
||||
github: GithubIcon,
|
||||
discord: DiscordIcon,
|
||||
stripe: StripeIcon,
|
||||
microsoftteams: MicrosoftTeamsIcon,
|
||||
}
|
||||
|
||||
return iconMap[provider.toLowerCase()]
|
||||
@@ -52,6 +54,7 @@ export const WebhookBlock: BlockConfig = {
|
||||
'github',
|
||||
'discord',
|
||||
'stripe',
|
||||
'microsoftteams',
|
||||
].map((provider) => {
|
||||
const providerLabels = {
|
||||
slack: 'Slack',
|
||||
@@ -63,6 +66,7 @@ export const WebhookBlock: BlockConfig = {
|
||||
github: 'GitHub',
|
||||
discord: 'Discord',
|
||||
stripe: 'Stripe',
|
||||
microsoftteams: 'Microsoft Teams',
|
||||
}
|
||||
|
||||
const icon = getWebhookProviderIcon(provider)
|
||||
|
||||
@@ -41,6 +41,7 @@ import { OpenAIBlock } from '@/blocks/blocks/openai'
|
||||
import { OutlookBlock } from '@/blocks/blocks/outlook'
|
||||
import { PerplexityBlock } from '@/blocks/blocks/perplexity'
|
||||
import { PineconeBlock } from '@/blocks/blocks/pinecone'
|
||||
import { QdrantBlock } from '@/blocks/blocks/qdrant'
|
||||
import { RedditBlock } from '@/blocks/blocks/reddit'
|
||||
import { ResponseBlock } from '@/blocks/blocks/response'
|
||||
import { RouterBlock } from '@/blocks/blocks/router'
|
||||
@@ -98,7 +99,6 @@ export const registry: Record<string, BlockConfig> = {
|
||||
linear: LinearBlock,
|
||||
linkup: LinkupBlock,
|
||||
mem0: Mem0Block,
|
||||
memory: MemoryBlock,
|
||||
microsoft_excel: MicrosoftExcelBlock,
|
||||
microsoft_teams: MicrosoftTeamsBlock,
|
||||
mistral_parse: MistralParseBlock,
|
||||
@@ -107,6 +107,8 @@ export const registry: Record<string, BlockConfig> = {
|
||||
outlook: OutlookBlock,
|
||||
perplexity: PerplexityBlock,
|
||||
pinecone: PineconeBlock,
|
||||
qdrant: QdrantBlock,
|
||||
memory: MemoryBlock,
|
||||
reddit: RedditBlock,
|
||||
response: ResponseBlock,
|
||||
router: RouterBlock,
|
||||
|
||||
@@ -3026,3 +3026,77 @@ export function ScheduleIcon(props: SVGProps<SVGSVGElement>) {
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function QdrantIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...props} fill='none' viewBox='0 0 49 56' xmlns='http://www.w3.org/2000/svg'>
|
||||
<g clipPath='url(#b)'>
|
||||
<path
|
||||
d='m38.489 51.477-1.1167-30.787-2.0223-8.1167 13.498 1.429v37.242l-8.2456 4.7589-2.1138-4.5259z'
|
||||
clipRule='evenodd'
|
||||
fill='#24386C'
|
||||
fillRule='evenodd'
|
||||
/>
|
||||
<path
|
||||
d='m48.847 14-8.2457 4.7622-17.016-3.7326-19.917 8.1094-3.3183-9.139 12.122-7 12.126-7 12.123 7 12.126 7z'
|
||||
clipRule='evenodd'
|
||||
fill='#7589BE'
|
||||
fillRule='evenodd'
|
||||
/>
|
||||
<path
|
||||
d='m0.34961 13.999 8.2457 4.7622 4.7798 14.215 16.139 12.913-4.9158 10.109-12.126-7.0004-12.123-7v-28z'
|
||||
clipRule='evenodd'
|
||||
fill='#B2BFE8'
|
||||
fillRule='evenodd'
|
||||
/>
|
||||
<path
|
||||
d='m30.066 38.421-5.4666 8.059v9.5207l7.757-4.4756 3.9968-5.9681'
|
||||
clipRule='evenodd'
|
||||
fill='#24386C'
|
||||
fillRule='evenodd'
|
||||
/>
|
||||
<path
|
||||
d='m24.602 36.962-7.7603-13.436 1.6715-4.4531 6.3544-3.0809 7.488 7.5343-7.7536 13.436z'
|
||||
clipRule='evenodd'
|
||||
fill='#7589BE'
|
||||
fillRule='evenodd'
|
||||
/>
|
||||
<path
|
||||
d='m16.843 23.525 7.7569 4.4756v8.9585l-7.1741 0.3087-4.3397-5.5412 3.7569-8.2016z'
|
||||
clipRule='evenodd'
|
||||
fill='#B2BFE8'
|
||||
fillRule='evenodd'
|
||||
/>
|
||||
<path
|
||||
d='m24.6 28 7.757-4.4752 5.2792 8.7903-6.3886 5.2784-6.6476-0.6346v-8.9589z'
|
||||
clipRule='evenodd'
|
||||
fill='#24386C'
|
||||
fillRule='evenodd'
|
||||
/>
|
||||
<path
|
||||
d='m32.355 51.524 8.2457 4.476v-37.238l-8.0032-4.6189-7.9995-4.6189-8.0031 4.6189-7.9995 4.6189v18.479l7.9995 4.6189 8.0031 4.6193 7.757-4.4797v9.5244zm0-19.045-7.757 4.4793-7.7569-4.4793v-8.9549l7.7569-4.4792 7.757 4.4792v8.9549z'
|
||||
clipRule='evenodd'
|
||||
fill='#DC244C'
|
||||
fillRule='evenodd'
|
||||
/>
|
||||
<path d='m24.603 46.483v-9.5222l-7.7166-4.4411v9.5064l7.7166 4.4569z' fill='url(#a)' />
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id='a'
|
||||
x1='23.18'
|
||||
x2='15.491'
|
||||
y1='38.781'
|
||||
y2='38.781'
|
||||
gradientUnits='userSpaceOnUse'
|
||||
>
|
||||
<stop stopColor='#FF3364' offset='0' />
|
||||
<stop stopColor='#C91540' stopOpacity='0' offset='1' />
|
||||
</linearGradient>
|
||||
<clipPath id='b'>
|
||||
<rect transform='translate(.34961)' width='48.3' height='56' fill='#fff' />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
302
apps/sim/components/ui/tool-call.tsx
Normal file
302
apps/sim/components/ui/tool-call.tsx
Normal file
@@ -0,0 +1,302 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { CheckCircle, ChevronDown, ChevronRight, Loader2, Settings, XCircle } from 'lucide-react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
|
||||
import { getToolDisplayName } from '@/lib/tool-call-parser'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { ToolCallGroup, ToolCallState } from '@/types/tool-call'
|
||||
|
||||
interface ToolCallProps {
|
||||
toolCall: ToolCallState
|
||||
isCompact?: boolean
|
||||
}
|
||||
|
||||
interface ToolCallGroupProps {
|
||||
group: ToolCallGroup
|
||||
isCompact?: boolean
|
||||
}
|
||||
|
||||
interface ToolCallIndicatorProps {
|
||||
type: 'status' | 'thinking' | 'execution'
|
||||
content: string
|
||||
toolNames?: string[]
|
||||
}
|
||||
|
||||
// Detection State Component
|
||||
export function ToolCallDetection({ content }: { content: string }) {
|
||||
return (
|
||||
<div className='flex min-w-0 items-center gap-2 rounded-lg border border-blue-200 bg-blue-50 px-3 py-2 text-sm dark:border-blue-800 dark:bg-blue-950'>
|
||||
<Loader2 className='h-4 w-4 shrink-0 animate-spin text-blue-600 dark:text-blue-400' />
|
||||
<span className='min-w-0 truncate text-blue-800 dark:text-blue-200'>{content}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Execution State Component
|
||||
export function ToolCallExecution({ toolCall, isCompact = false }: ToolCallProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(!isCompact)
|
||||
|
||||
return (
|
||||
<div className='min-w-0 rounded-lg border border-amber-200 bg-amber-50 dark:border-amber-800 dark:bg-amber-950'>
|
||||
<Collapsible open={isExpanded} onOpenChange={setIsExpanded}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='w-full min-w-0 justify-between px-3 py-4 hover:bg-amber-100 dark:hover:bg-amber-900'
|
||||
>
|
||||
<div className='flex min-w-0 items-center gap-2 overflow-hidden'>
|
||||
<Settings className='h-4 w-4 shrink-0 animate-pulse text-amber-600 dark:text-amber-400' />
|
||||
<span className='min-w-0 truncate font-mono text-amber-800 text-xs dark:text-amber-200'>
|
||||
{toolCall.displayName || toolCall.name}
|
||||
</span>
|
||||
{toolCall.progress && (
|
||||
<Badge
|
||||
variant='outline'
|
||||
className='shrink-0 text-amber-700 text-xs dark:text-amber-300'
|
||||
>
|
||||
{toolCall.progress}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className='h-4 w-4 shrink-0 text-amber-600 dark:text-amber-400' />
|
||||
) : (
|
||||
<ChevronRight className='h-4 w-4 shrink-0 text-amber-600 dark:text-amber-400' />
|
||||
)}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className='min-w-0 max-w-full px-3 pb-3'>
|
||||
<div className='min-w-0 max-w-full space-y-2'>
|
||||
<div className='flex items-center gap-2 text-amber-700 text-xs dark:text-amber-300'>
|
||||
<Loader2 className='h-3 w-3 shrink-0 animate-spin' />
|
||||
<span>Executing...</span>
|
||||
</div>
|
||||
{toolCall.parameters && Object.keys(toolCall.parameters).length > 0 && (
|
||||
<div className='min-w-0 max-w-full rounded bg-amber-100 p-2 dark:bg-amber-900'>
|
||||
<div className='mb-1 font-medium text-amber-800 text-xs dark:text-amber-200'>
|
||||
Parameters:
|
||||
</div>
|
||||
<div className='min-w-0 max-w-full break-all font-mono text-amber-700 text-xs dark:text-amber-300'>
|
||||
{JSON.stringify(toolCall.parameters, null, 2)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Completion State Component
|
||||
export function ToolCallCompletion({ toolCall, isCompact = false }: ToolCallProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
const isSuccess = toolCall.state === 'completed'
|
||||
const isError = toolCall.state === 'error'
|
||||
|
||||
const formatDuration = (duration?: number) => {
|
||||
if (!duration) return ''
|
||||
return duration < 1000 ? `${duration}ms` : `${(duration / 1000).toFixed(1)}s`
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'min-w-0 rounded-lg border',
|
||||
isSuccess && 'border-green-200 bg-green-50 dark:border-green-800 dark:bg-green-950',
|
||||
isError && 'border-red-200 bg-red-50 dark:border-red-800 dark:bg-red-950'
|
||||
)}
|
||||
>
|
||||
<Collapsible open={isExpanded} onOpenChange={setIsExpanded}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className={cn(
|
||||
'w-full min-w-0 justify-between px-3 py-4',
|
||||
isSuccess && 'hover:bg-green-100 dark:hover:bg-green-900',
|
||||
isError && 'hover:bg-red-100 dark:hover:bg-red-900'
|
||||
)}
|
||||
>
|
||||
<div className='flex min-w-0 items-center gap-2 overflow-hidden'>
|
||||
{isSuccess && (
|
||||
<CheckCircle className='h-4 w-4 shrink-0 text-green-600 dark:text-green-400' />
|
||||
)}
|
||||
{isError && <XCircle className='h-4 w-4 shrink-0 text-red-600 dark:text-red-400' />}
|
||||
<span
|
||||
className={cn(
|
||||
'min-w-0 truncate font-mono text-xs',
|
||||
isSuccess && 'text-green-800 dark:text-green-200',
|
||||
isError && 'text-red-800 dark:text-red-200'
|
||||
)}
|
||||
>
|
||||
{getToolDisplayName(toolCall.name, true)}
|
||||
</span>
|
||||
{toolCall.duration && (
|
||||
<Badge
|
||||
variant='outline'
|
||||
className={cn(
|
||||
'shrink-0 text-xs',
|
||||
isSuccess && 'text-green-700 dark:text-green-300',
|
||||
isError && 'text-red-700 dark:text-red-300'
|
||||
)}
|
||||
style={{ fontSize: '0.625rem' }}
|
||||
>
|
||||
{formatDuration(toolCall.duration)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className='flex shrink-0 items-center'>
|
||||
{isExpanded ? (
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'h-4 w-4',
|
||||
isSuccess && 'text-green-600 dark:text-green-400',
|
||||
isError && 'text-red-600 dark:text-red-400'
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'h-4 w-4',
|
||||
isSuccess && 'text-green-600 dark:text-green-400',
|
||||
isError && 'text-red-600 dark:text-red-400'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className='min-w-0 max-w-full px-3 pb-3'>
|
||||
<div className='min-w-0 max-w-full space-y-2'>
|
||||
{toolCall.parameters && Object.keys(toolCall.parameters).length > 0 && (
|
||||
<div
|
||||
className={cn(
|
||||
'min-w-0 max-w-full rounded p-2',
|
||||
isSuccess && 'bg-green-100 dark:bg-green-900',
|
||||
isError && 'bg-red-100 dark:bg-red-900'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'mb-1 font-medium text-xs',
|
||||
isSuccess && 'text-green-800 dark:text-green-200',
|
||||
isError && 'text-red-800 dark:text-red-200'
|
||||
)}
|
||||
>
|
||||
Parameters:
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'min-w-0 max-w-full break-all font-mono text-xs',
|
||||
isSuccess && 'text-green-700 dark:text-green-300',
|
||||
isError && 'text-red-700 dark:text-red-300'
|
||||
)}
|
||||
>
|
||||
{JSON.stringify(toolCall.parameters, null, 2)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{toolCall.error && (
|
||||
<div className='min-w-0 max-w-full rounded bg-red-100 p-2 dark:bg-red-900'>
|
||||
<div className='mb-1 font-medium text-red-800 text-xs dark:text-red-200'>
|
||||
Error:
|
||||
</div>
|
||||
<div className='min-w-0 max-w-full break-all font-mono text-red-700 text-xs dark:text-red-300'>
|
||||
{toolCall.error}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Group Component for Multiple Tool Calls
|
||||
export function ToolCallGroupComponent({ group, isCompact = false }: ToolCallGroupProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(true)
|
||||
|
||||
const completedCount = group.toolCalls.filter((t) => t.state === 'completed').length
|
||||
const totalCount = group.toolCalls.length
|
||||
const isAllCompleted = completedCount === totalCount
|
||||
const hasErrors = group.toolCalls.some((t) => t.state === 'error')
|
||||
|
||||
return (
|
||||
<div className='min-w-0 space-y-2'>
|
||||
{group.summary && (
|
||||
<div className='flex min-w-0 items-center gap-2 rounded-lg border border-blue-200 bg-blue-50 px-3 py-2 text-sm dark:border-blue-800 dark:bg-blue-950'>
|
||||
<Settings className='h-4 w-4 shrink-0 text-blue-600 dark:text-blue-400' />
|
||||
<span className='min-w-0 truncate text-blue-800 dark:text-blue-200'>{group.summary}</span>
|
||||
{!isAllCompleted && (
|
||||
<Badge variant='outline' className='shrink-0 text-blue-700 text-xs dark:text-blue-300'>
|
||||
{completedCount}/{totalCount}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Collapsible open={isExpanded} onOpenChange={setIsExpanded}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='w-full min-w-0 justify-between px-3 py-3 text-sm hover:bg-muted'
|
||||
>
|
||||
<div className='flex min-w-0 items-center gap-2 overflow-hidden'>
|
||||
<span className='min-w-0 truncate text-muted-foreground'>
|
||||
{isAllCompleted ? 'Completed' : 'In Progress'} ({completedCount}/{totalCount})
|
||||
</span>
|
||||
{hasErrors && (
|
||||
<Badge variant='destructive' className='shrink-0 text-xs'>
|
||||
Errors
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className='h-4 w-4 shrink-0 text-muted-foreground' />
|
||||
) : (
|
||||
<ChevronRight className='h-4 w-4 shrink-0 text-muted-foreground' />
|
||||
)}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className='min-w-0 max-w-full space-y-2'>
|
||||
{group.toolCalls.map((toolCall) => (
|
||||
<div key={toolCall.id} className='min-w-0 max-w-full'>
|
||||
{toolCall.state === 'executing' && (
|
||||
<ToolCallExecution toolCall={toolCall} isCompact={isCompact} />
|
||||
)}
|
||||
{(toolCall.state === 'completed' || toolCall.state === 'error') && (
|
||||
<ToolCallCompletion toolCall={toolCall} isCompact={isCompact} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Status Indicator Component
|
||||
export function ToolCallIndicator({ type, content, toolNames }: ToolCallIndicatorProps) {
|
||||
if (type === 'status' && toolNames) {
|
||||
return (
|
||||
<div className='flex min-w-0 items-center gap-2 rounded-lg border border-blue-200 bg-blue-50 px-3 py-2 text-sm dark:border-blue-800 dark:bg-blue-950'>
|
||||
<Loader2 className='h-4 w-4 shrink-0 animate-spin text-blue-600 dark:text-blue-400' />
|
||||
<span className='min-w-0 truncate text-blue-800 dark:text-blue-200'>
|
||||
🔄 {toolNames.join(' • ')}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex min-w-0 items-center gap-2 rounded-lg border border-blue-200 bg-blue-50 px-3 py-2 text-sm dark:border-blue-800 dark:bg-blue-950'>
|
||||
<Loader2 className='h-4 w-4 shrink-0 animate-spin text-blue-600 dark:text-blue-400' />
|
||||
<span className='min-w-0 truncate text-blue-800 dark:text-blue-200'>{content}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { io, type Socket } from 'socket.io-client'
|
||||
import { env } from '@/lib/env'
|
||||
import { getEnv } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
|
||||
const logger = createLogger('SocketContext')
|
||||
@@ -154,7 +154,7 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
|
||||
// Generate initial token for socket authentication
|
||||
const token = await generateSocketToken()
|
||||
|
||||
const socketUrl = env.NEXT_PUBLIC_SOCKET_URL || 'http://localhost:3002'
|
||||
const socketUrl = getEnv('NEXT_PUBLIC_SOCKET_URL') || 'http://localhost:3002'
|
||||
|
||||
logger.info('Attempting to connect to Socket.IO server', {
|
||||
url: socketUrl,
|
||||
@@ -306,6 +306,94 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
|
||||
eventHandlers.current.workflowReverted?.(data)
|
||||
})
|
||||
|
||||
// Workflow update events (external changes like LLM edits)
|
||||
socketInstance.on('workflow-updated', (data) => {
|
||||
logger.info(`Workflow ${data.workflowId} has been updated externally - requesting sync`)
|
||||
// Request fresh workflow state to sync with external changes
|
||||
if (data.workflowId === urlWorkflowId) {
|
||||
socketInstance.emit('request-sync', { workflowId: data.workflowId })
|
||||
}
|
||||
})
|
||||
|
||||
// Copilot workflow edit events (database has been updated, rehydrate stores)
|
||||
socketInstance.on('copilot-workflow-edit', async (data) => {
|
||||
logger.info(
|
||||
`Copilot edited workflow ${data.workflowId} - rehydrating stores from database`
|
||||
)
|
||||
|
||||
if (data.workflowId === urlWorkflowId) {
|
||||
try {
|
||||
// Fetch fresh workflow state directly from API
|
||||
const response = await fetch(`/api/workflows/${data.workflowId}`)
|
||||
if (response.ok) {
|
||||
const responseData = await response.json()
|
||||
const workflowData = responseData.data
|
||||
|
||||
if (workflowData?.state) {
|
||||
logger.info('Rehydrating stores with fresh workflow state from database')
|
||||
|
||||
// Import stores dynamically to avoid import issues
|
||||
Promise.all([
|
||||
import('@/stores/workflows/workflow/store'),
|
||||
import('@/stores/workflows/subblock/store'),
|
||||
])
|
||||
.then(([{ useWorkflowStore }, { useSubBlockStore }]) => {
|
||||
const workflowState = workflowData.state
|
||||
|
||||
// Extract subblock values from blocks
|
||||
const subblockValues: Record<string, Record<string, any>> = {}
|
||||
Object.entries(workflowState.blocks || {}).forEach(([blockId, block]) => {
|
||||
const blockState = block as any
|
||||
subblockValues[blockId] = {}
|
||||
Object.entries(blockState.subBlocks || {}).forEach(
|
||||
([subblockId, subblock]) => {
|
||||
subblockValues[blockId][subblockId] = (subblock as any).value
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
// Update workflow store with fresh state from database
|
||||
const newWorkflowState = {
|
||||
blocks: workflowState.blocks || {},
|
||||
edges: workflowState.edges || [],
|
||||
loops: workflowState.loops || {},
|
||||
parallels: workflowState.parallels || {},
|
||||
lastSaved: workflowState.lastSaved || Date.now(),
|
||||
isDeployed: workflowState.isDeployed || false,
|
||||
deployedAt: workflowState.deployedAt,
|
||||
deploymentStatuses: workflowState.deploymentStatuses || {},
|
||||
hasActiveSchedule: workflowState.hasActiveSchedule || false,
|
||||
hasActiveWebhook: workflowState.hasActiveWebhook || false,
|
||||
}
|
||||
|
||||
useWorkflowStore.setState(newWorkflowState)
|
||||
|
||||
// Update subblock store with fresh values
|
||||
useSubBlockStore.setState((state: any) => ({
|
||||
workflowValues: {
|
||||
...state.workflowValues,
|
||||
[data.workflowId]: subblockValues,
|
||||
},
|
||||
}))
|
||||
|
||||
// Note: Auto-layout is already handled by the copilot backend before saving
|
||||
// No need to trigger additional auto-layout here to avoid ID conflicts
|
||||
|
||||
logger.info('Successfully rehydrated stores from database after copilot edit')
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error('Failed to import stores for copilot rehydration:', error)
|
||||
})
|
||||
}
|
||||
} else {
|
||||
logger.error('Failed to fetch fresh workflow state:', response.statusText)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to rehydrate stores after copilot edit:', error)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Operation confirmation events
|
||||
socketInstance.on('operation-confirmed', (data) => {
|
||||
logger.debug('Operation confirmed', { operationId: data.operationId })
|
||||
@@ -356,9 +444,71 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
|
||||
logger.debug('Operation confirmed:', data)
|
||||
})
|
||||
|
||||
socketInstance.on('workflow-state', (state) => {
|
||||
// logger.info('Received workflow state from server:', state)
|
||||
// This will be used to sync initial state when joining a workflow
|
||||
socketInstance.on('workflow-state', (workflowData) => {
|
||||
logger.info('Received workflow state from server:', workflowData)
|
||||
|
||||
// Update local stores with the fresh workflow state (same logic as YAML editor)
|
||||
if (workflowData?.state && workflowData.id === urlWorkflowId) {
|
||||
logger.info('Updating local stores with fresh workflow state from server')
|
||||
|
||||
try {
|
||||
// Import stores dynamically to avoid import issues
|
||||
Promise.all([
|
||||
import('@/stores/workflows/workflow/store'),
|
||||
import('@/stores/workflows/subblock/store'),
|
||||
import('@/stores/workflows/registry/store'),
|
||||
])
|
||||
.then(([{ useWorkflowStore }, { useSubBlockStore }, { useWorkflowRegistry }]) => {
|
||||
const workflowState = workflowData.state
|
||||
|
||||
// Extract subblock values from blocks before updating workflow store
|
||||
const subblockValues: Record<string, Record<string, any>> = {}
|
||||
Object.entries(workflowState.blocks || {}).forEach(([blockId, block]) => {
|
||||
const blockState = block as any
|
||||
subblockValues[blockId] = {}
|
||||
Object.entries(blockState.subBlocks || {}).forEach(([subblockId, subblock]) => {
|
||||
subblockValues[blockId][subblockId] = (subblock as any).value
|
||||
})
|
||||
})
|
||||
|
||||
// Update workflow store with new state
|
||||
const newWorkflowState = {
|
||||
blocks: workflowState.blocks || {},
|
||||
edges: workflowState.edges || [],
|
||||
loops: workflowState.loops || {},
|
||||
parallels: workflowState.parallels || {},
|
||||
lastSaved: workflowState.lastSaved || Date.now(),
|
||||
isDeployed: workflowState.isDeployed || false,
|
||||
deployedAt: workflowState.deployedAt,
|
||||
deploymentStatuses: workflowState.deploymentStatuses || {},
|
||||
hasActiveSchedule: workflowState.hasActiveSchedule || false,
|
||||
hasActiveWebhook: workflowState.hasActiveWebhook || false,
|
||||
}
|
||||
|
||||
useWorkflowStore.setState(newWorkflowState)
|
||||
|
||||
// Update subblock store with fresh values
|
||||
useSubBlockStore.setState((state: any) => ({
|
||||
workflowValues: {
|
||||
...state.workflowValues,
|
||||
[workflowData.id]: subblockValues,
|
||||
},
|
||||
}))
|
||||
|
||||
// Note: Auto layout is not triggered here because:
|
||||
// 1. For copilot edits: positions are already optimized by the backend
|
||||
// 2. For other syncs: the existing positions should be preserved
|
||||
// This prevents ID conflicts and unnecessary position updates
|
||||
|
||||
logger.info('Successfully updated local stores with fresh workflow state')
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error('Failed to import stores for workflow state update:', error)
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to update local stores with workflow state:', error)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
setSocket(socketInstance)
|
||||
|
||||
20
apps/sim/db/migrations/0058_clean_shiva.sql
Normal file
20
apps/sim/db/migrations/0058_clean_shiva.sql
Normal file
@@ -0,0 +1,20 @@
|
||||
CREATE TABLE "copilot_checkpoints" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"workflow_id" text NOT NULL,
|
||||
"chat_id" uuid NOT NULL,
|
||||
"yaml" text NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "copilot_checkpoints" ADD CONSTRAINT "copilot_checkpoints_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "copilot_checkpoints" ADD CONSTRAINT "copilot_checkpoints_workflow_id_workflow_id_fk" FOREIGN KEY ("workflow_id") REFERENCES "public"."workflow"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "copilot_checkpoints" ADD CONSTRAINT "copilot_checkpoints_chat_id_copilot_chats_id_fk" FOREIGN KEY ("chat_id") REFERENCES "public"."copilot_chats"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "copilot_checkpoints_user_id_idx" ON "copilot_checkpoints" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "copilot_checkpoints_workflow_id_idx" ON "copilot_checkpoints" USING btree ("workflow_id");--> statement-breakpoint
|
||||
CREATE INDEX "copilot_checkpoints_chat_id_idx" ON "copilot_checkpoints" USING btree ("chat_id");--> statement-breakpoint
|
||||
CREATE INDEX "copilot_checkpoints_user_workflow_idx" ON "copilot_checkpoints" USING btree ("user_id","workflow_id");--> statement-breakpoint
|
||||
CREATE INDEX "copilot_checkpoints_workflow_chat_idx" ON "copilot_checkpoints" USING btree ("workflow_id","chat_id");--> statement-breakpoint
|
||||
CREATE INDEX "copilot_checkpoints_created_at_idx" ON "copilot_checkpoints" USING btree ("created_at");--> statement-breakpoint
|
||||
CREATE INDEX "copilot_checkpoints_chat_created_at_idx" ON "copilot_checkpoints" USING btree ("chat_id","created_at");
|
||||
5865
apps/sim/db/migrations/meta/0058_snapshot.json
Normal file
5865
apps/sim/db/migrations/meta/0058_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -400,6 +400,13 @@
|
||||
"when": 1752980338632,
|
||||
"tag": "0057_charming_star_brand",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 58,
|
||||
"version": "7",
|
||||
"when": 1753211027120,
|
||||
"tag": "0058_clean_shiva",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user