mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-09 23:17:59 -05:00
Compare commits
79 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c47cf4161 | ||
|
|
db1cf8a6db | ||
|
|
c6912095f7 | ||
|
|
154d9eef6a | ||
|
|
c2ded1f3e1 | ||
|
|
ff43528d35 | ||
|
|
692ba69864 | ||
|
|
cb7ce8659b | ||
|
|
5caef3a37d | ||
|
|
a6888da124 | ||
|
|
07b0597f4f | ||
|
|
71e2994f9d | ||
|
|
9973b2c165 | ||
|
|
d9e5777538 | ||
|
|
dd74267313 | ||
|
|
1db72dc823 | ||
|
|
da707fa491 | ||
|
|
9ffaf305bd | ||
|
|
26e6286fda | ||
|
|
c795fc83aa | ||
|
|
cea42f5135 | ||
|
|
6fd6f921dc | ||
|
|
7530fb9a4e | ||
|
|
9a5b035822 | ||
|
|
0c0b6bf967 | ||
|
|
5d74db53ff | ||
|
|
b39bdfd55e | ||
|
|
6b185be9a4 | ||
|
|
214a0358b6 | ||
|
|
bbb5e53e43 | ||
|
|
79e932fed9 | ||
|
|
9ad36c0e34 | ||
|
|
2771c688ff | ||
|
|
d58ceb4bce | ||
|
|
69773c3174 | ||
|
|
1619d63f2a | ||
|
|
9aa1fe8037 | ||
|
|
1b7c111c46 | ||
|
|
bdfb56b262 | ||
|
|
4a7de31eee | ||
|
|
adfe56c720 | ||
|
|
72e3efa875 | ||
|
|
b40fa3aa6e | ||
|
|
f924edde3a | ||
|
|
073030bfaa | ||
|
|
871f4e8e18 | ||
|
|
091343a132 | ||
|
|
63c66bfc31 | ||
|
|
445ca78395 | ||
|
|
d75cc1ed84 | ||
|
|
5a8a703ecb | ||
|
|
6f64188b8d | ||
|
|
60a9a25553 | ||
|
|
52fa388f81 | ||
|
|
5c56cbd558 | ||
|
|
dc19525a6f | ||
|
|
3873f44875 | ||
|
|
09b95f41ea | ||
|
|
af60ccd188 | ||
|
|
eb75afd115 | ||
|
|
fdb8256468 | ||
|
|
570c07bf2a | ||
|
|
5c16e7d390 | ||
|
|
bd38062705 | ||
|
|
d7fd4a9618 | ||
|
|
d972bab206 | ||
|
|
f254d70624 | ||
|
|
8748e1d5f9 | ||
|
|
133a32e6d3 | ||
|
|
97b6bcc43d | ||
|
|
42917ce641 | ||
|
|
5f6d219223 | ||
|
|
bab74307f4 | ||
|
|
16aaa37dad | ||
|
|
c6166a9483 | ||
|
|
0258a1b4ce | ||
|
|
4d4aefa346 | ||
|
|
a0cf003abf | ||
|
|
2e027dd77d |
44
.github/workflows/trigger-deploy.yml
vendored
Normal file
44
.github/workflows/trigger-deploy.yml
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
name: Trigger.dev Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- staging
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Trigger.dev Deploy
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: trigger-deploy-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
env:
|
||||
TRIGGER_ACCESS_TOKEN: ${{ secrets.TRIGGER_ACCESS_TOKEN }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 'lts/*'
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
|
||||
- name: Deploy to Staging
|
||||
if: github.ref == 'refs/heads/staging'
|
||||
working-directory: ./apps/sim
|
||||
run: npx --yes trigger.dev@4.0.0 deploy -e staging
|
||||
|
||||
- name: Deploy to Production
|
||||
if: github.ref == 'refs/heads/main'
|
||||
working-directory: ./apps/sim
|
||||
run: npx --yes trigger.dev@4.0.0 deploy
|
||||
|
||||
58
README.md
58
README.md
@@ -1,50 +1,46 @@
|
||||
<p align="center">
|
||||
<img src="apps/sim/public/static/sim.png" alt="Sim Logo" width="500"/>
|
||||
<a href="https://sim.ai" target="_blank" rel="noopener noreferrer">
|
||||
<img src="apps/sim/public/logo/reverse/text/large.png" alt="Sim Logo" width="500"/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.apache.org/licenses/LICENSE-2.0"><img src="https://img.shields.io/badge/License-Apache%202.0-blue.svg" alt="License: Apache-2.0"></a>
|
||||
<a href="https://discord.gg/Hr4UWYEcTT"><img src="https://img.shields.io/badge/Discord-Join%20Server-7289DA?logo=discord&logoColor=white" alt="Discord"></a>
|
||||
<a href="https://x.com/simdotai"><img src="https://img.shields.io/twitter/follow/simstudioai?style=social" alt="Twitter"></a>
|
||||
<a href="https://github.com/simstudioai/sim/pulls"><img src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg" alt="PRs welcome"></a>
|
||||
<a href="https://docs.sim.ai"><img src="https://img.shields.io/badge/Docs-visit%20documentation-blue.svg" alt="Documentation"></a>
|
||||
</p>
|
||||
<p align="center">Build and deploy AI agent workflows in minutes.</p>
|
||||
|
||||
<p align="center">
|
||||
<strong>Sim</strong> is a lightweight, user-friendly platform for building AI agent workflows.
|
||||
<a href="https://sim.ai" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/badge/sim.ai-6F3DFA" alt="Sim.ai"></a>
|
||||
<a href="https://discord.gg/Hr4UWYEcTT" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/badge/Discord-Join%20Server-5865F2?logo=discord&logoColor=white" alt="Discord"></a>
|
||||
<a href="https://x.com/simdotai" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/twitter/follow/simstudioai?style=social" alt="Twitter"></a>
|
||||
<a href="https://docs.sim.ai" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/badge/Docs-6F3DFA.svg" alt="Documentation"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="apps/sim/public/static/demo.gif" alt="Sim Demo" width="800"/>
|
||||
</p>
|
||||
|
||||
## Getting Started
|
||||
## Quickstart
|
||||
|
||||
1. Use our [cloud-hosted version](https://sim.ai)
|
||||
2. Self-host using one of the methods below
|
||||
### Cloud-hosted: [sim.ai](https://sim.ai)
|
||||
|
||||
## Self-Hosting Options
|
||||
<a href="https://sim.ai" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/badge/sim.ai-6F3DFA?logo=data:image/svg%2bxml;base64,PHN2ZyB3aWR0aD0iNjE2IiBoZWlnaHQ9IjYxNiIgdmlld0JveD0iMCAwIDYxNiA2MTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMF8xMTU5XzMxMykiPgo8cGF0aCBkPSJNNjE2IDBIMFY2MTZINjE2VjBaIiBmaWxsPSIjNkYzREZBIi8+CjxwYXRoIGQ9Ik04MyAzNjUuNTY3SDExM0MxMTMgMzczLjgwNSAxMTYgMzgwLjM3MyAxMjIgMzg1LjI3MkMxMjggMzg5Ljk0OCAxMzYuMTExIDM5Mi4yODUgMTQ2LjMzMyAzOTIuMjg1QzE1Ny40NDQgMzkyLjI4NSAxNjYgMzkwLjE3MSAxNzIgMzg1LjkzOUMxNzcuOTk5IDM4MS40ODcgMTgxIDM3NS41ODYgMTgxIDM2OC4yMzlDMTgxIDM2Mi44OTUgMTc5LjMzMyAzNTguNDQyIDE3NiAzNTQuODhDMTcyLjg4OSAzNTEuMzE4IDE2Ny4xMTEgMzQ4LjQyMiAxNTguNjY3IDM0Ni4xOTZMMTMwIDMzOS41MTdDMTE1LjU1NSAzMzUuOTU1IDEwNC43NzggMzMwLjQ5OSA5Ny42NjY1IDMyMy4xNTFDOTAuNzc3NSAzMTUuODA0IDg3LjMzMzQgMzA2LjExOSA4Ny4zMzM0IDI5NC4wOTZDODcuMzMzNCAyODQuMDc2IDg5Ljg4OSAyNzUuMzkyIDk0Ljk5OTYgMjY4LjA0NUMxMDAuMzMzIDI2MC42OTcgMTA3LjU1NSAyNTUuMDIgMTE2LjY2NiAyNTEuMDEyQzEyNiAyNDcuMDA0IDEzNi42NjcgMjQ1IDE0OC42NjYgMjQ1QzE2MC42NjcgMjQ1IDE3MSAyNDcuMTE2IDE3OS42NjcgMjUxLjM0NkMxODguNTU1IDI1NS41NzYgMTk1LjQ0NCAyNjEuNDc3IDIwMC4zMzMgMjY5LjA0N0MyMDUuNDQ0IDI3Ni42MTcgMjA4LjExMSAyODUuNjM0IDIwOC4zMzMgMjk2LjA5OUgxNzguMzMzQzE3OC4xMTEgMjg3LjYzOCAxNzUuMzMzIDI4MS4wNyAxNjkuOTk5IDI3Ni4zOTRDMTY0LjY2NiAyNzEuNzE5IDE1Ny4yMjIgMjY5LjM4MSAxNDcuNjY3IDI2OS4zODFDMTM3Ljg4OSAyNjkuMzgxIDEzMC4zMzMgMjcxLjQ5NiAxMjUgMjc1LjcyNkMxMTkuNjY2IDI3OS45NTcgMTE3IDI4NS43NDYgMTE3IDI5My4wOTNDMTE3IDMwNC4wMDMgMTI1IDMxMS40NjIgMTQxIDMxNS40N0wxNjkuNjY3IDMyMi40ODNDMTgzLjQ0NSAzMjUuNiAxOTMuNzc4IDMzMC43MjIgMjAwLjY2NyAzMzcuODQ3QzIwNy41NTUgMzQ0Ljc0OSAyMTEgMzU0LjIxMiAyMTEgMzY2LjIzNUMyMTEgMzc2LjQ3NyAyMDguMjIyIDM4NS40OTQgMjAyLjY2NiAzOTMuMjg3QzE5Ny4xMTEgNDAwLjg1NyAxODkuNDQ0IDQwNi43NTggMTc5LjY2NyA0MTAuOTg5QzE3MC4xMTEgNDE0Ljk5NiAxNTguNzc4IDQxNyAxNDUuNjY3IDQxN0MxMjYuNTU1IDQxNyAxMTEuMzMzIDQxMi4zMjUgOTkuOTk5NyA0MDIuOTczQzg4LjY2NjggMzkzLjYyMSA4MyAzODEuMTUzIDgzIDM2NS41NjdaIiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBkPSJNMjMyLjI5MSA0MTNWMjUwLjA4MkMyNDQuNjg0IDI1NC42MTQgMjUwLjE0OCAyNTQuNjE0IDI2My4zNzEgMjUwLjA4MlY0MTNIMjMyLjI5MVpNMjQ3LjUgMjM5LjMxM0MyNDEuOTkgMjM5LjMxMyAyMzcuMTQgMjM3LjMxMyAyMzIuOTUyIDIzMy4zMTZDMjI4Ljk4NCAyMjkuMDk1IDIyNyAyMjQuMjA5IDIyNyAyMTguNjU2QzIyNyAyMTIuODgyIDIyOC45ODQgMjA3Ljk5NSAyMzIuOTUyIDIwMy45OTdDMjM3LjE0IDE5OS45OTkgMjQxLjk5IDE5OCAyNDcuNSAxOThDMjUzLjIzMSAxOTggMjU4LjA4IDE5OS45OTkgMjYyLjA0OSAyMDMuOTk3QzI2Ni4wMTYgMjA3Ljk5NSAyNjggMjEyLjg4MiAyNjggMjE4LjY1NkMyNjggMjI0LjIwOSAyNjYuMDE2IDIyOS4wOTUgMjYyLjA0OSAyMzMuMzE2QzI1OC4wOCAyMzcuMzEzIDI1My4yMzEgMjM5LjMxMyAyNDcuNSAyMzkuMzEzWiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZD0iTTMxOS4zMzMgNDEzSDI4OFYyNDkuNjc2SDMxNlYyNzcuMjMzQzMxOS4zMzMgMjY4LjEwNCAzMjUuNzc4IDI2MC4zNjQgMzM0LjY2NyAyNTQuMzUyQzM0My43NzggMjQ4LjExNyAzNTQuNzc4IDI0NSAzNjcuNjY3IDI0NUMzODIuMTExIDI0NSAzOTQuMTEyIDI0OC44OTcgNDAzLjY2NyAyNTYuNjlDNDEzLjIyMiAyNjQuNDg0IDQxOS40NDQgMjc0LjgzNyA0MjIuMzM0IDI4Ny43NTJINDE2LjY2N0M0MTguODg5IDI3NC44MzcgNDI1IDI2NC40ODQgNDM1IDI1Ni42OUM0NDUgMjQ4Ljg5NyA0NTcuMzM0IDI0NSA0NzIgMjQ1QzQ5MC42NjYgMjQ1IDUwNS4zMzQgMjUwLjQ1NSA1MTYgMjYxLjM2NkM1MjYuNjY3IDI3Mi4yNzYgNTMyIDI4Ny4xOTUgNTMyIDMwNi4xMjFWNDEzSDUwMS4zMzNWMzEzLjgwNEM1MDEuMzMzIDMwMC44ODkgNDk4IDI5MC45ODEgNDkxLjMzMyAyODQuMDc4QzQ4NC44ODkgMjc2Ljk1MiA0NzYuMTExIDI3My4zOSA0NjUgMjczLjM5QzQ1Ny4yMjIgMjczLjM5IDQ1MC4zMzMgMjc1LjE3MSA0NDQuMzM0IDI3OC43MzRDNDM4LjU1NiAyODIuMDc0IDQzNCAyODYuOTcyIDQzMC42NjcgMjkzLjQzQzQyNy4zMzMgMjk5Ljg4NyA0MjUuNjY3IDMwNy40NTcgNDI1LjY2NyAzMTYuMTQxVjQxM0gzOTQuNjY3VjMxMy40NjlDMzk0LjY2NyAzMDAuNTU1IDM5MS40NDUgMjkwLjc1OCAzODUgMjg0LjA3OEMzNzguNTU2IDI3Ny4xNzUgMzY5Ljc3OCAyNzMuNzI0IDM1OC42NjcgMjczLjcyNEMzNTAuODg5IDI3My43MjQgMzQ0IDI3NS41MDUgMzM4IDI3OS4wNjhDMzMyLjIyMiAyODIuNDA4IDMyNy42NjcgMjg3LjMwNyAzMjQuMzMzIDI5My43NjNDMzIxIDI5OS45OTggMzE5LjMzMyAzMDcuNDU3IDMxOS4zMzMgMzE2LjE0MVY0MTNaIiBmaWxsPSJ3aGl0ZSIvPgo8L2c+CjxkZWZzPgo8Y2xpcFBhdGggaWQ9ImNsaXAwXzExNTlfMzEzIj4KPHJlY3Qgd2lkdGg9IjYxNiIgaGVpZ2h0PSI2MTYiIGZpbGw9IndoaXRlIi8+CjwvY2xpcFBhdGg+CjwvZGVmcz4KPC9zdmc+Cg==&logoColor=white" alt="Sim.ai"></a>
|
||||
|
||||
### Option 1: NPM Package (Simplest)
|
||||
|
||||
The easiest way to run Sim locally is using our [NPM package](https://www.npmjs.com/package/simstudio?activeTab=readme):
|
||||
### Self-hosted: NPM Package
|
||||
|
||||
```bash
|
||||
npx simstudio
|
||||
```
|
||||
→ http://localhost:3000
|
||||
|
||||
After running these commands, open [http://localhost:3000/](http://localhost:3000/) in your browser.
|
||||
#### Note
|
||||
Docker must be installed and running on your machine.
|
||||
|
||||
#### Options
|
||||
|
||||
- `-p, --port <port>`: Specify the port to run Sim on (default: 3000)
|
||||
- `--no-pull`: Skip pulling the latest Docker images
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `-p, --port <port>` | Port to run Sim on (default `3000`) |
|
||||
| `--no-pull` | Skip pulling latest Docker images |
|
||||
|
||||
#### Requirements
|
||||
|
||||
- Docker must be installed and running on your machine
|
||||
|
||||
### Option 2: Docker Compose
|
||||
### Self-hosted: Docker Compose
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
@@ -76,14 +72,14 @@ Wait for the model to download, then visit [http://localhost:3000](http://localh
|
||||
docker compose -f docker-compose.ollama.yml exec ollama ollama pull llama3.1:8b
|
||||
```
|
||||
|
||||
### Option 3: Dev Containers
|
||||
### Self-hosted: Dev Containers
|
||||
|
||||
1. Open VS Code with the [Remote - Containers extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers)
|
||||
2. Open the project and click "Reopen in Container" when prompted
|
||||
3. Run `bun run dev:full` in the terminal or use the `sim-start` alias
|
||||
- This starts both the main application and the realtime socket server
|
||||
|
||||
### Option 4: Manual Setup
|
||||
### Self-hosted: Manual Setup
|
||||
|
||||
**Requirements:**
|
||||
- [Bun](https://bun.sh/) runtime
|
||||
@@ -158,6 +154,14 @@ cd apps/sim
|
||||
bun run dev:sockets
|
||||
```
|
||||
|
||||
## Copilot API Keys
|
||||
|
||||
Copilot is a Sim-managed service. To use Copilot on a self-hosted instance:
|
||||
|
||||
- Go to https://sim.ai → Settings → Copilot and generate a Copilot API key
|
||||
- Set `COPILOT_API_KEY` in your self-hosted environment to that value
|
||||
- Host Sim on a publicly available DNS and set NEXT_PUBLIC_APP_URL and BETTER_AUTH_URL to that value ([ngrok](https://ngrok.com/))
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Framework**: [Next.js](https://nextjs.org/) (App Router)
|
||||
@@ -180,4 +184,4 @@ We welcome contributions! Please see our [Contributing Guide](.github/CONTRIBUTI
|
||||
|
||||
This project is licensed under the Apache License 2.0 - see the [LICENSE](LICENSE) file for details.
|
||||
|
||||
<p align="center">Made with ❤️ by the Sim Team</p>
|
||||
<p align="center">Made with ❤️ by the Sim Team</p>
|
||||
|
||||
97
apps/docs/content/docs/copilot/index.mdx
Normal file
97
apps/docs/content/docs/copilot/index.mdx
Normal file
@@ -0,0 +1,97 @@
|
||||
---
|
||||
title: Copilot
|
||||
description: Build and edit workflows with Sim Copilot
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout'
|
||||
import { Card, Cards } from 'fumadocs-ui/components/card'
|
||||
import { MessageCircle, Package, Zap, Infinity as InfinityIcon, Brain, BrainCircuit } from 'lucide-react'
|
||||
|
||||
## What is Copilot
|
||||
|
||||
Copilot is your in-editor assistant that helps you build, understand, and improve workflows. It can:
|
||||
|
||||
- **Explain**: Answer questions about Sim and your current workflow
|
||||
- **Guide**: Suggest edits and best practices
|
||||
- **Edit**: Make changes to blocks, connections, and settings when you approve
|
||||
|
||||
<Callout type="info">
|
||||
Copilot is a Sim-managed service. For self-hosted deployments, generate a Copilot API key in the hosted app (sim.ai → Settings → Copilot)
|
||||
1. Go to [sim.ai](https://sim.ai) → Settings → Copilot and generate a Copilot API key
|
||||
2. Set `COPILOT_API_KEY` in your self-hosted environment to that value
|
||||
3. Host Sim on a publicly available DNS and set `NEXT_PUBLIC_APP_URL` and `BETTER_AUTH_URL` to that value (e.g., using ngrok)
|
||||
</Callout>
|
||||
|
||||
## Modes
|
||||
|
||||
<Cards>
|
||||
<Card title="Ask">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="mt-0.5 inline-flex h-8 w-8 items-center justify-center rounded-md border border-border/50 bg-muted/60">
|
||||
<MessageCircle className="h-4 w-4 text-muted-foreground" />
|
||||
</span>
|
||||
<div>
|
||||
<p className="m-0 text-sm">
|
||||
Q&A mode for explanations, guidance, and suggestions without making changes to your workflow.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card title="Agent">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="mt-0.5 inline-flex h-8 w-8 items-center justify-center rounded-md border border-border/50 bg-muted/60">
|
||||
<Package className="h-4 w-4 text-muted-foreground" />
|
||||
</span>
|
||||
<div>
|
||||
<p className="m-0 text-sm">
|
||||
Build-and-edit mode. Copilot proposes specific edits (add blocks, wire variables, tweak settings) and applies them when you approve.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Cards>
|
||||
|
||||
## Depth Levels
|
||||
|
||||
<Cards>
|
||||
<Card title="Fast">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="mt-0.5 inline-flex h-8 w-8 items-center justify-center rounded-md border border-border/50 bg-muted/60">
|
||||
<Zap className="h-4 w-4 text-muted-foreground" />
|
||||
</span>
|
||||
<div>
|
||||
<p className="m-0 text-sm">Quickest and cheapest. Best for small edits, simple workflows, and minor tweaks.</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card title="Auto">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="mt-0.5 inline-flex h-8 w-8 items-center justify-center rounded-md border border-border/50 bg-muted/60">
|
||||
<InfinityIcon className="h-4 w-4 text-muted-foreground" />
|
||||
</span>
|
||||
<div>
|
||||
<p className="m-0 text-sm">Balanced speed and reasoning. Recommended default for most tasks.</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card title="Pro">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="mt-0.5 inline-flex h-8 w-8 items-center justify-center rounded-md border border-border/50 bg-muted/60">
|
||||
<Brain className="h-4 w-4 text-muted-foreground" />
|
||||
</span>
|
||||
<div>
|
||||
<p className="m-0 text-sm">More reasoning for larger workflows and complex edits while staying performant.</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card title="Max">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="mt-0.5 inline-flex h-8 w-8 items-center justify-center rounded-md border border-border/50 bg-muted/60">
|
||||
<BrainCircuit className="h-4 w-4 text-muted-foreground" />
|
||||
</span>
|
||||
<div>
|
||||
<p className="m-0 text-sm">Maximum reasoning for deep planning, debugging, and complex architectural changes.</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Cards>
|
||||
4
apps/docs/content/docs/copilot/meta.json
Normal file
4
apps/docs/content/docs/copilot/meta.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"title": "Copilot",
|
||||
"pages": ["index"]
|
||||
}
|
||||
@@ -12,6 +12,8 @@
|
||||
"connections",
|
||||
"---Execution---",
|
||||
"execution",
|
||||
"---Copilot---",
|
||||
"copilot",
|
||||
"---Advanced---",
|
||||
"./variables/index",
|
||||
"yaml",
|
||||
|
||||
@@ -115,8 +115,7 @@ Read data from a Microsoft Excel spreadsheet
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `success` | boolean | Operation success status |
|
||||
| `output` | object | Excel spreadsheet data and metadata |
|
||||
| `data` | object | Range data from the spreadsheet |
|
||||
|
||||
### `microsoft_excel_write`
|
||||
|
||||
@@ -136,8 +135,11 @@ Write data to a Microsoft Excel spreadsheet
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `success` | boolean | Operation success status |
|
||||
| `output` | object | Write operation results and metadata |
|
||||
| `updatedRange` | string | The range that was updated |
|
||||
| `updatedRows` | number | Number of rows that were updated |
|
||||
| `updatedColumns` | number | Number of columns that were updated |
|
||||
| `updatedCells` | number | Number of cells that were updated |
|
||||
| `metadata` | object | Spreadsheet metadata |
|
||||
|
||||
### `microsoft_excel_table_add`
|
||||
|
||||
@@ -155,8 +157,9 @@ Add new rows to a Microsoft Excel table
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `success` | boolean | Operation success status |
|
||||
| `output` | object | Table add operation results and metadata |
|
||||
| `index` | number | Index of the first row that was added |
|
||||
| `values` | array | Array of rows that were added to the table |
|
||||
| `metadata` | object | Spreadsheet metadata |
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -142,7 +142,7 @@ Get a single row from a Supabase table based on filter criteria
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `message` | string | Operation status message |
|
||||
| `results` | object | The row data if found, null if not found |
|
||||
| `results` | array | Array containing the row data if found, empty array if not found |
|
||||
|
||||
### `supabase_update`
|
||||
|
||||
@@ -185,6 +185,26 @@ Delete rows from a Supabase table based on filter criteria
|
||||
| `message` | string | Operation status message |
|
||||
| `results` | array | Array of deleted records |
|
||||
|
||||
### `supabase_upsert`
|
||||
|
||||
Insert or update data in a Supabase table (upsert operation)
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `projectId` | string | Yes | Your Supabase project ID \(e.g., jdrkgepadsdopsntdlom\) |
|
||||
| `table` | string | Yes | The name of the Supabase table to upsert data into |
|
||||
| `data` | any | Yes | The data to upsert \(insert or update\) |
|
||||
| `apiKey` | string | Yes | Your Supabase service role secret key |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `message` | string | Operation status message |
|
||||
| `results` | array | Array of upserted records |
|
||||
|
||||
|
||||
|
||||
## Notes
|
||||
|
||||
@@ -84,14 +84,12 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Check if the access token is valid
|
||||
if (!credential.accessToken) {
|
||||
logger.warn(`[${requestId}] No access token available for credential`)
|
||||
return NextResponse.json({ error: 'No access token available' }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
// Refresh the token if needed
|
||||
const { accessToken } = await refreshTokenIfNeeded(requestId, credential, credentialId)
|
||||
return NextResponse.json({ accessToken }, { status: 200 })
|
||||
} catch (_error) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { and, desc, eq } from 'drizzle-orm'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { refreshOAuthToken } from '@/lib/oauth/oauth'
|
||||
@@ -70,7 +70,8 @@ export async function getOAuthToken(userId: string, providerId: string): Promise
|
||||
})
|
||||
.from(account)
|
||||
.where(and(eq(account.userId, userId), eq(account.providerId, providerId)))
|
||||
.orderBy(account.createdAt)
|
||||
// Always use the most recently updated credential for this provider
|
||||
.orderBy(desc(account.updatedAt))
|
||||
.limit(1)
|
||||
|
||||
if (connections.length === 0) {
|
||||
@@ -80,19 +81,13 @@ export async function getOAuthToken(userId: string, providerId: string): Promise
|
||||
|
||||
const credential = connections[0]
|
||||
|
||||
// Check if we have a valid access token
|
||||
if (!credential.accessToken) {
|
||||
logger.warn(`Access token is null for user ${userId}, provider ${providerId}`)
|
||||
return null
|
||||
}
|
||||
|
||||
// Check if the token is expired and needs refreshing
|
||||
// Determine whether we should refresh: missing token OR expired token
|
||||
const now = new Date()
|
||||
const tokenExpiry = credential.accessTokenExpiresAt
|
||||
// Only refresh if we have an expiration time AND it's expired AND we have a refresh token
|
||||
const needsRefresh = tokenExpiry && tokenExpiry < now && !!credential.refreshToken
|
||||
const shouldAttemptRefresh =
|
||||
!!credential.refreshToken && (!credential.accessToken || (tokenExpiry && tokenExpiry < now))
|
||||
|
||||
if (needsRefresh) {
|
||||
if (shouldAttemptRefresh) {
|
||||
logger.info(
|
||||
`Access token expired for user ${userId}, provider ${providerId}. Attempting to refresh.`
|
||||
)
|
||||
@@ -141,6 +136,13 @@ export async function getOAuthToken(userId: string, providerId: string): Promise
|
||||
}
|
||||
}
|
||||
|
||||
if (!credential.accessToken) {
|
||||
logger.warn(
|
||||
`Access token is null and no refresh attempted or available for user ${userId}, provider ${providerId}`
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
logger.info(`Found valid OAuth token for user ${userId}, provider ${providerId}`)
|
||||
return credential.accessToken
|
||||
}
|
||||
@@ -164,19 +166,21 @@ export async function refreshAccessTokenIfNeeded(
|
||||
return null
|
||||
}
|
||||
|
||||
// Check if we need to refresh the token
|
||||
// Decide if we should refresh: token missing OR expired
|
||||
const expiresAt = credential.accessTokenExpiresAt
|
||||
const now = new Date()
|
||||
// Only refresh if we have an expiration time AND it's expired
|
||||
// If no expiration time is set (newly created credentials), assume token is valid
|
||||
const needsRefresh = expiresAt && expiresAt <= now
|
||||
const shouldRefresh =
|
||||
!!credential.refreshToken && (!credential.accessToken || (expiresAt && expiresAt <= now))
|
||||
|
||||
const accessToken = credential.accessToken
|
||||
|
||||
if (needsRefresh && credential.refreshToken) {
|
||||
if (shouldRefresh) {
|
||||
logger.info(`[${requestId}] Token expired, attempting to refresh for credential`)
|
||||
try {
|
||||
const refreshedToken = await refreshOAuthToken(credential.providerId, credential.refreshToken)
|
||||
const refreshedToken = await refreshOAuthToken(
|
||||
credential.providerId,
|
||||
credential.refreshToken!
|
||||
)
|
||||
|
||||
if (!refreshedToken) {
|
||||
logger.error(`[${requestId}] Failed to refresh token for credential: ${credentialId}`, {
|
||||
@@ -217,6 +221,7 @@ export async function refreshAccessTokenIfNeeded(
|
||||
return null
|
||||
}
|
||||
} else if (!accessToken) {
|
||||
// We have no access token and either no refresh token or not eligible to refresh
|
||||
logger.error(`[${requestId}] Missing access token for credential`)
|
||||
return null
|
||||
}
|
||||
@@ -233,21 +238,20 @@ export async function refreshTokenIfNeeded(
|
||||
credential: any,
|
||||
credentialId: string
|
||||
): Promise<{ accessToken: string; refreshed: boolean }> {
|
||||
// Check if we need to refresh the token
|
||||
// Decide if we should refresh: token missing OR expired
|
||||
const expiresAt = credential.accessTokenExpiresAt
|
||||
const now = new Date()
|
||||
// Only refresh if we have an expiration time AND it's expired
|
||||
// If no expiration time is set (newly created credentials), assume token is valid
|
||||
const needsRefresh = expiresAt && expiresAt <= now
|
||||
const shouldRefresh =
|
||||
!!credential.refreshToken && (!credential.accessToken || (expiresAt && expiresAt <= now))
|
||||
|
||||
// If token is still valid, return it directly
|
||||
if (!needsRefresh || !credential.refreshToken) {
|
||||
// If token appears valid and present, return it directly
|
||||
if (!shouldRefresh) {
|
||||
logger.info(`[${requestId}] Access token is valid`)
|
||||
return { accessToken: credential.accessToken, refreshed: false }
|
||||
}
|
||||
|
||||
try {
|
||||
const refreshResult = await refreshOAuthToken(credential.providerId, credential.refreshToken)
|
||||
const refreshResult = await refreshOAuthToken(credential.providerId, credential.refreshToken!)
|
||||
|
||||
if (!refreshResult) {
|
||||
logger.error(`[${requestId}] Failed to refresh token for credential`)
|
||||
|
||||
@@ -3,8 +3,7 @@ import { eq, sql } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkInternalApiKey } from '@/lib/copilot/utils'
|
||||
import { env } from '@/lib/env'
|
||||
import { isBillingEnabled, isProd } from '@/lib/environment'
|
||||
import { isBillingEnabled } from '@/lib/environment'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { db } from '@/db'
|
||||
import { userStats } from '@/db/schema'
|
||||
@@ -17,6 +16,7 @@ const UpdateCostSchema = z.object({
|
||||
input: z.number().min(0, 'Input tokens must be a non-negative number'),
|
||||
output: z.number().min(0, 'Output tokens must be a non-negative number'),
|
||||
model: z.string().min(1, 'Model is required'),
|
||||
multiplier: z.number().min(0),
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -75,27 +75,27 @@ export async function POST(req: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
const { userId, input, output, model } = validation.data
|
||||
const { userId, input, output, model, multiplier } = validation.data
|
||||
|
||||
logger.info(`[${requestId}] Processing cost update`, {
|
||||
userId,
|
||||
input,
|
||||
output,
|
||||
model,
|
||||
multiplier,
|
||||
})
|
||||
|
||||
const finalPromptTokens = input
|
||||
const finalCompletionTokens = output
|
||||
const totalTokens = input + output
|
||||
|
||||
// Calculate cost using COPILOT_COST_MULTIPLIER (only in production, like normal executions)
|
||||
const copilotMultiplier = isProd ? env.COPILOT_COST_MULTIPLIER || 1 : 1
|
||||
// Calculate cost using provided multiplier (required)
|
||||
const costResult = calculateCost(
|
||||
model,
|
||||
finalPromptTokens,
|
||||
finalCompletionTokens,
|
||||
false,
|
||||
copilotMultiplier
|
||||
multiplier
|
||||
)
|
||||
|
||||
logger.info(`[${requestId}] Cost calculation result`, {
|
||||
@@ -104,7 +104,7 @@ export async function POST(req: NextRequest) {
|
||||
promptTokens: finalPromptTokens,
|
||||
completionTokens: finalCompletionTokens,
|
||||
totalTokens: totalTokens,
|
||||
copilotMultiplier,
|
||||
multiplier,
|
||||
costResult,
|
||||
})
|
||||
|
||||
@@ -127,6 +127,10 @@ export async function POST(req: NextRequest) {
|
||||
totalTokensUsed: totalTokens,
|
||||
totalCost: costToStore.toString(),
|
||||
currentPeriodCost: costToStore.toString(),
|
||||
// Copilot usage tracking
|
||||
totalCopilotCost: costToStore.toString(),
|
||||
totalCopilotTokens: totalTokens,
|
||||
totalCopilotCalls: 1,
|
||||
lastActive: new Date(),
|
||||
})
|
||||
|
||||
@@ -141,6 +145,10 @@ export async function POST(req: NextRequest) {
|
||||
totalTokensUsed: sql`total_tokens_used + ${totalTokens}`,
|
||||
totalCost: sql`total_cost + ${costToStore}`,
|
||||
currentPeriodCost: sql`current_period_cost + ${costToStore}`,
|
||||
// Copilot usage tracking increments
|
||||
totalCopilotCost: sql`total_copilot_cost + ${costToStore}`,
|
||||
totalCopilotTokens: sql`total_copilot_tokens + ${totalTokens}`,
|
||||
totalCopilotCalls: sql`total_copilot_calls + 1`,
|
||||
totalApiCalls: sql`total_api_calls`,
|
||||
lastActive: new Date(),
|
||||
}
|
||||
|
||||
70
apps/sim/app/api/copilot/api-keys/generate/route.ts
Normal file
70
apps/sim/app/api/copilot/api-keys/generate/route.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { createCipheriv, createHash, createHmac, randomBytes } from 'crypto'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { env } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { generateApiKey } from '@/lib/utils'
|
||||
import { db } from '@/db'
|
||||
import { copilotApiKeys } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('CopilotApiKeysGenerate')
|
||||
|
||||
function deriveKey(keyString: string): Buffer {
|
||||
return createHash('sha256').update(keyString, 'utf8').digest()
|
||||
}
|
||||
|
||||
function encryptRandomIv(plaintext: string, keyString: string): string {
|
||||
const key = deriveKey(keyString)
|
||||
const iv = randomBytes(16)
|
||||
const cipher = createCipheriv('aes-256-gcm', key, iv)
|
||||
let encrypted = cipher.update(plaintext, 'utf8', 'hex')
|
||||
encrypted += cipher.final('hex')
|
||||
const authTag = cipher.getAuthTag().toString('hex')
|
||||
return `${iv.toString('hex')}:${encrypted}:${authTag}`
|
||||
}
|
||||
|
||||
function computeLookup(plaintext: string, keyString: string): string {
|
||||
// Deterministic, constant-time comparable MAC: HMAC-SHA256(DB_KEY, plaintext)
|
||||
return createHmac('sha256', Buffer.from(keyString, 'utf8'))
|
||||
.update(plaintext, 'utf8')
|
||||
.digest('hex')
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (!env.AGENT_API_DB_ENCRYPTION_KEY) {
|
||||
logger.error('AGENT_API_DB_ENCRYPTION_KEY is not set')
|
||||
return NextResponse.json({ error: 'Server not configured' }, { status: 500 })
|
||||
}
|
||||
|
||||
const userId = session.user.id
|
||||
|
||||
// Generate and prefix the key (strip the generic sim_ prefix from the random part)
|
||||
const rawKey = generateApiKey().replace(/^sim_/, '')
|
||||
const plaintextKey = `sk-sim-copilot-${rawKey}`
|
||||
|
||||
// Encrypt with random IV for confidentiality
|
||||
const dbEncrypted = encryptRandomIv(plaintextKey, env.AGENT_API_DB_ENCRYPTION_KEY)
|
||||
|
||||
// Compute deterministic lookup value for O(1) search
|
||||
const lookup = computeLookup(plaintextKey, env.AGENT_API_DB_ENCRYPTION_KEY)
|
||||
|
||||
const [inserted] = await db
|
||||
.insert(copilotApiKeys)
|
||||
.values({ userId, apiKeyEncrypted: dbEncrypted, apiKeyLookup: lookup })
|
||||
.returning({ id: copilotApiKeys.id })
|
||||
|
||||
return NextResponse.json(
|
||||
{ success: true, key: { id: inserted.id, apiKey: plaintextKey } },
|
||||
{ status: 201 }
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error('Failed to generate copilot API key', { error })
|
||||
return NextResponse.json({ error: 'Failed to generate copilot API key' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
85
apps/sim/app/api/copilot/api-keys/route.ts
Normal file
85
apps/sim/app/api/copilot/api-keys/route.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { createDecipheriv, createHash } from 'crypto'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { env } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { db } from '@/db'
|
||||
import { copilotApiKeys } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('CopilotApiKeys')
|
||||
|
||||
function deriveKey(keyString: string): Buffer {
|
||||
return createHash('sha256').update(keyString, 'utf8').digest()
|
||||
}
|
||||
|
||||
function decryptWithKey(encryptedValue: string, keyString: string): string {
|
||||
const parts = encryptedValue.split(':')
|
||||
if (parts.length !== 3) {
|
||||
throw new Error('Invalid encrypted value format')
|
||||
}
|
||||
const [ivHex, encryptedHex, authTagHex] = parts
|
||||
const key = deriveKey(keyString)
|
||||
const iv = Buffer.from(ivHex, 'hex')
|
||||
const decipher = createDecipheriv('aes-256-gcm', key, iv)
|
||||
decipher.setAuthTag(Buffer.from(authTagHex, 'hex'))
|
||||
let decrypted = decipher.update(encryptedHex, 'hex', 'utf8')
|
||||
decrypted += decipher.final('utf8')
|
||||
return decrypted
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (!env.AGENT_API_DB_ENCRYPTION_KEY) {
|
||||
logger.error('AGENT_API_DB_ENCRYPTION_KEY is not set')
|
||||
return NextResponse.json({ error: 'Server not configured' }, { status: 500 })
|
||||
}
|
||||
|
||||
const userId = session.user.id
|
||||
|
||||
const rows = await db
|
||||
.select({ id: copilotApiKeys.id, apiKeyEncrypted: copilotApiKeys.apiKeyEncrypted })
|
||||
.from(copilotApiKeys)
|
||||
.where(eq(copilotApiKeys.userId, userId))
|
||||
|
||||
const keys = rows.map((row) => ({
|
||||
id: row.id,
|
||||
apiKey: decryptWithKey(row.apiKeyEncrypted, env.AGENT_API_DB_ENCRYPTION_KEY as string),
|
||||
}))
|
||||
|
||||
return NextResponse.json({ keys }, { status: 200 })
|
||||
} catch (error) {
|
||||
logger.error('Failed to get copilot API keys', { error })
|
||||
return NextResponse.json({ error: 'Failed to get keys' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const userId = session.user.id
|
||||
const url = new URL(request.url)
|
||||
const id = url.searchParams.get('id')
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: 'id is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
await db
|
||||
.delete(copilotApiKeys)
|
||||
.where(and(eq(copilotApiKeys.userId, userId), eq(copilotApiKeys.id, id)))
|
||||
|
||||
return NextResponse.json({ success: true }, { status: 200 })
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete copilot API key', { error })
|
||||
return NextResponse.json({ error: 'Failed to delete key' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
79
apps/sim/app/api/copilot/api-keys/validate/route.ts
Normal file
79
apps/sim/app/api/copilot/api-keys/validate/route.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { createHmac } from 'crypto'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { env } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { db } from '@/db'
|
||||
import { copilotApiKeys, userStats } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('CopilotApiKeysValidate')
|
||||
|
||||
function computeLookup(plaintext: string, keyString: string): string {
|
||||
// Deterministic MAC: HMAC-SHA256(DB_KEY, plaintext)
|
||||
return createHmac('sha256', Buffer.from(keyString, 'utf8'))
|
||||
.update(plaintext, 'utf8')
|
||||
.digest('hex')
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
if (!env.AGENT_API_DB_ENCRYPTION_KEY) {
|
||||
logger.error('AGENT_API_DB_ENCRYPTION_KEY is not set')
|
||||
return NextResponse.json({ error: 'Server not configured' }, { status: 500 })
|
||||
}
|
||||
|
||||
const body = await req.json().catch(() => null)
|
||||
const apiKey = typeof body?.apiKey === 'string' ? body.apiKey : undefined
|
||||
|
||||
if (!apiKey) {
|
||||
return new NextResponse(null, { status: 401 })
|
||||
}
|
||||
|
||||
const lookup = computeLookup(apiKey, env.AGENT_API_DB_ENCRYPTION_KEY)
|
||||
|
||||
// Find matching API key and its user
|
||||
const rows = await db
|
||||
.select({ id: copilotApiKeys.id, userId: copilotApiKeys.userId })
|
||||
.from(copilotApiKeys)
|
||||
.where(eq(copilotApiKeys.apiKeyLookup, lookup))
|
||||
.limit(1)
|
||||
|
||||
if (rows.length === 0) {
|
||||
return new NextResponse(null, { status: 401 })
|
||||
}
|
||||
|
||||
const { userId } = rows[0]
|
||||
|
||||
// Check usage for the associated user
|
||||
const usage = await db
|
||||
.select({
|
||||
currentPeriodCost: userStats.currentPeriodCost,
|
||||
totalCost: userStats.totalCost,
|
||||
currentUsageLimit: userStats.currentUsageLimit,
|
||||
})
|
||||
.from(userStats)
|
||||
.where(eq(userStats.userId, userId))
|
||||
.limit(1)
|
||||
|
||||
if (usage.length > 0) {
|
||||
const currentUsage = Number.parseFloat(
|
||||
(usage[0].currentPeriodCost?.toString() as string) ||
|
||||
(usage[0].totalCost as unknown as string) ||
|
||||
'0'
|
||||
)
|
||||
const limit = Number.parseFloat((usage[0].currentUsageLimit as unknown as string) || '0')
|
||||
|
||||
if (!Number.isNaN(limit) && limit > 0 && currentUsage >= limit) {
|
||||
// Usage exceeded
|
||||
logger.info('[API VALIDATION] Usage exceeded', { userId, currentUsage, limit })
|
||||
return new NextResponse(null, { status: 402 })
|
||||
}
|
||||
}
|
||||
|
||||
// Valid and within usage limits
|
||||
return new NextResponse(null, { status: 200 })
|
||||
} catch (error) {
|
||||
logger.error('Error validating copilot API key', { error })
|
||||
return NextResponse.json({ error: 'Failed to validate key' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -104,7 +104,8 @@ describe('Copilot Chat API Route', () => {
|
||||
vi.doMock('@/lib/env', () => ({
|
||||
env: {
|
||||
SIM_AGENT_API_URL: 'http://localhost:8000',
|
||||
SIM_AGENT_API_KEY: 'test-sim-agent-key',
|
||||
COPILOT_API_KEY: 'test-sim-agent-key',
|
||||
BETTER_AUTH_URL: 'http://localhost:3000',
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -225,6 +226,7 @@ describe('Copilot Chat API Route', () => {
|
||||
mode: 'agent',
|
||||
provider: 'openai',
|
||||
depth: 0,
|
||||
origin: 'http://localhost:3000',
|
||||
}),
|
||||
})
|
||||
)
|
||||
@@ -288,6 +290,7 @@ describe('Copilot Chat API Route', () => {
|
||||
mode: 'agent',
|
||||
provider: 'openai',
|
||||
depth: 0,
|
||||
origin: 'http://localhost:3000',
|
||||
}),
|
||||
})
|
||||
)
|
||||
@@ -343,6 +346,7 @@ describe('Copilot Chat API Route', () => {
|
||||
mode: 'agent',
|
||||
provider: 'openai',
|
||||
depth: 0,
|
||||
origin: 'http://localhost:3000',
|
||||
}),
|
||||
})
|
||||
)
|
||||
@@ -438,6 +442,7 @@ describe('Copilot Chat API Route', () => {
|
||||
mode: 'ask',
|
||||
provider: 'openai',
|
||||
depth: 0,
|
||||
origin: 'http://localhost:3000',
|
||||
}),
|
||||
})
|
||||
)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'crypto'
|
||||
import { and, desc, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
@@ -13,6 +14,7 @@ import { getCopilotModel } from '@/lib/copilot/config'
|
||||
import { TITLE_GENERATION_SYSTEM_PROMPT, TITLE_GENERATION_USER_PROMPT } from '@/lib/copilot/prompts'
|
||||
import { env } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { SIM_AGENT_API_URL_DEFAULT } from '@/lib/sim-agent'
|
||||
import { downloadFile } from '@/lib/uploads'
|
||||
import { downloadFromS3WithConfig } from '@/lib/uploads/s3/s3-client'
|
||||
import { S3_COPILOT_CONFIG, USE_S3_STORAGE } from '@/lib/uploads/setup'
|
||||
@@ -23,6 +25,46 @@ import { createAnthropicFileContent, isSupportedFileType } from './file-utils'
|
||||
|
||||
const logger = createLogger('CopilotChatAPI')
|
||||
|
||||
// Sim Agent API configuration
|
||||
const SIM_AGENT_API_URL = env.SIM_AGENT_API_URL || SIM_AGENT_API_URL_DEFAULT
|
||||
|
||||
function getRequestOrigin(_req: NextRequest): string {
|
||||
try {
|
||||
// Strictly use configured Better Auth URL
|
||||
return env.BETTER_AUTH_URL || ''
|
||||
} catch (_) {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function deriveKey(keyString: string): Buffer {
|
||||
return createHash('sha256').update(keyString, 'utf8').digest()
|
||||
}
|
||||
|
||||
function decryptWithKey(encryptedValue: string, keyString: string): string {
|
||||
const [ivHex, encryptedHex, authTagHex] = encryptedValue.split(':')
|
||||
if (!ivHex || !encryptedHex || !authTagHex) {
|
||||
throw new Error('Invalid encrypted format')
|
||||
}
|
||||
const key = deriveKey(keyString)
|
||||
const iv = Buffer.from(ivHex, 'hex')
|
||||
const decipher = createDecipheriv('aes-256-gcm', key, iv)
|
||||
decipher.setAuthTag(Buffer.from(authTagHex, 'hex'))
|
||||
let decrypted = decipher.update(encryptedHex, 'hex', 'utf8')
|
||||
decrypted += decipher.final('utf8')
|
||||
return decrypted
|
||||
}
|
||||
|
||||
function encryptWithKey(plaintext: string, keyString: string): string {
|
||||
const key = deriveKey(keyString)
|
||||
const iv = randomBytes(16)
|
||||
const cipher = createCipheriv('aes-256-gcm', key, iv)
|
||||
let encrypted = cipher.update(plaintext, 'utf8', 'hex')
|
||||
encrypted += cipher.final('hex')
|
||||
const authTag = cipher.getAuthTag().toString('hex')
|
||||
return `${iv.toString('hex')}:${encrypted}:${authTag}`
|
||||
}
|
||||
|
||||
// Schema for file attachments
|
||||
const FileAttachmentSchema = z.object({
|
||||
id: z.string(),
|
||||
@@ -39,7 +81,8 @@ const ChatMessageSchema = z.object({
|
||||
chatId: z.string().optional(),
|
||||
workflowId: z.string().min(1, 'Workflow ID is required'),
|
||||
mode: z.enum(['ask', 'agent']).optional().default('agent'),
|
||||
depth: z.number().int().min(0).max(3).optional().default(0),
|
||||
depth: z.number().int().min(-2).max(3).optional().default(0),
|
||||
prefetch: z.boolean().optional(),
|
||||
createNewChat: z.boolean().optional().default(false),
|
||||
stream: z.boolean().optional().default(true),
|
||||
implicitFeedback: z.string().optional(),
|
||||
@@ -48,10 +91,6 @@ const ChatMessageSchema = z.object({
|
||||
conversationId: z.string().optional(),
|
||||
})
|
||||
|
||||
// Sim Agent API configuration
|
||||
const SIM_AGENT_API_URL = env.SIM_AGENT_API_URL || 'http://localhost:8000'
|
||||
const SIM_AGENT_API_KEY = env.SIM_AGENT_API_KEY
|
||||
|
||||
/**
|
||||
* Generate a chat title using LLM
|
||||
*/
|
||||
@@ -160,6 +199,7 @@ export async function POST(req: NextRequest) {
|
||||
workflowId,
|
||||
mode,
|
||||
depth,
|
||||
prefetch,
|
||||
createNewChat,
|
||||
stream,
|
||||
implicitFeedback,
|
||||
@@ -168,6 +208,27 @@ export async function POST(req: NextRequest) {
|
||||
conversationId,
|
||||
} = ChatMessageSchema.parse(body)
|
||||
|
||||
// Derive request origin for downstream service
|
||||
const requestOrigin = getRequestOrigin(req)
|
||||
|
||||
if (!requestOrigin) {
|
||||
logger.error(`[${tracker.requestId}] Missing required configuration: BETTER_AUTH_URL`)
|
||||
return createInternalServerErrorResponse('Missing required configuration: BETTER_AUTH_URL')
|
||||
}
|
||||
|
||||
// Consolidation mapping: map negative depths to base depth with prefetch=true
|
||||
let effectiveDepth: number | undefined = typeof depth === 'number' ? depth : undefined
|
||||
let effectivePrefetch: boolean | undefined = prefetch
|
||||
if (typeof effectiveDepth === 'number') {
|
||||
if (effectiveDepth === -2) {
|
||||
effectiveDepth = 1
|
||||
effectivePrefetch = true
|
||||
} else if (effectiveDepth === -1) {
|
||||
effectiveDepth = 0
|
||||
effectivePrefetch = true
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`[${tracker.requestId}] Processing copilot chat request`, {
|
||||
userId: authenticatedUserId,
|
||||
workflowId,
|
||||
@@ -179,6 +240,9 @@ export async function POST(req: NextRequest) {
|
||||
hasImplicitFeedback: !!implicitFeedback,
|
||||
provider: provider || 'openai',
|
||||
hasConversationId: !!conversationId,
|
||||
depth,
|
||||
prefetch,
|
||||
origin: requestOrigin,
|
||||
})
|
||||
|
||||
// Handle chat context
|
||||
@@ -341,34 +405,68 @@ export async function POST(req: NextRequest) {
|
||||
(currentChat?.conversationId as string | undefined) || conversationId
|
||||
|
||||
// If we have a conversationId, only send the most recent user message; else send full history
|
||||
const messagesForAgent = effectiveConversationId ? [messages[messages.length - 1]] : messages
|
||||
const latestUserMessage =
|
||||
[...messages].reverse().find((m) => m?.role === 'user') || messages[messages.length - 1]
|
||||
const messagesForAgent = effectiveConversationId ? [latestUserMessage] : messages
|
||||
|
||||
const requestPayload = {
|
||||
messages: messagesForAgent,
|
||||
workflowId,
|
||||
userId: authenticatedUserId,
|
||||
stream: stream,
|
||||
streamToolCalls: true,
|
||||
mode: mode,
|
||||
provider: providerToUse,
|
||||
...(effectiveConversationId ? { conversationId: effectiveConversationId } : {}),
|
||||
...(typeof effectiveDepth === 'number' ? { depth: effectiveDepth } : {}),
|
||||
...(typeof effectivePrefetch === 'boolean' ? { prefetch: effectivePrefetch } : {}),
|
||||
...(session?.user?.name && { userName: session.user.name }),
|
||||
...(requestOrigin ? { origin: requestOrigin } : {}),
|
||||
}
|
||||
|
||||
// Log the payload being sent to the streaming endpoint
|
||||
try {
|
||||
logger.info(`[${tracker.requestId}] Sending payload to sim agent streaming endpoint`, {
|
||||
url: `${SIM_AGENT_API_URL}/api/chat-completion-streaming`,
|
||||
provider: providerToUse,
|
||||
mode,
|
||||
stream,
|
||||
workflowId,
|
||||
hasConversationId: !!effectiveConversationId,
|
||||
depth: typeof effectiveDepth === 'number' ? effectiveDepth : undefined,
|
||||
prefetch: typeof effectivePrefetch === 'boolean' ? effectivePrefetch : undefined,
|
||||
messagesCount: requestPayload.messages.length,
|
||||
...(requestOrigin ? { origin: requestOrigin } : {}),
|
||||
})
|
||||
// Full payload as JSON string
|
||||
logger.info(
|
||||
`[${tracker.requestId}] Full streaming payload: ${JSON.stringify(requestPayload)}`
|
||||
)
|
||||
} catch (e) {
|
||||
logger.warn(`[${tracker.requestId}] Failed to log payload preview for streaming endpoint`, e)
|
||||
}
|
||||
|
||||
const simAgentResponse = await fetch(`${SIM_AGENT_API_URL}/api/chat-completion-streaming`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(SIM_AGENT_API_KEY && { 'x-api-key': SIM_AGENT_API_KEY }),
|
||||
...(env.COPILOT_API_KEY ? { 'x-api-key': env.COPILOT_API_KEY } : {}),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
messages: messagesForAgent,
|
||||
workflowId,
|
||||
userId: authenticatedUserId,
|
||||
stream: stream,
|
||||
streamToolCalls: true,
|
||||
mode: mode,
|
||||
provider: providerToUse,
|
||||
...(effectiveConversationId ? { conversationId: effectiveConversationId } : {}),
|
||||
...(typeof depth === 'number' ? { depth } : {}),
|
||||
...(session?.user?.name && { userName: session.user.name }),
|
||||
}),
|
||||
body: JSON.stringify(requestPayload),
|
||||
})
|
||||
|
||||
if (!simAgentResponse.ok) {
|
||||
const errorText = await simAgentResponse.text()
|
||||
if (simAgentResponse.status === 401 || simAgentResponse.status === 402) {
|
||||
// Rethrow status only; client will render appropriate assistant message
|
||||
return new NextResponse(null, { status: simAgentResponse.status })
|
||||
}
|
||||
|
||||
const errorText = await simAgentResponse.text().catch(() => '')
|
||||
logger.error(`[${tracker.requestId}] Sim agent API error:`, {
|
||||
status: simAgentResponse.status,
|
||||
error: errorText,
|
||||
})
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: `Sim agent API error: ${simAgentResponse.statusText}` },
|
||||
{ status: simAgentResponse.status }
|
||||
@@ -398,6 +496,12 @@ export async function POST(req: NextRequest) {
|
||||
let isFirstDone = true
|
||||
let responseIdFromStart: string | undefined
|
||||
let responseIdFromDone: string | undefined
|
||||
// Track tool call progress to identify a safe done event
|
||||
const announcedToolCallIds = new Set<string>()
|
||||
const startedToolExecutionIds = new Set<string>()
|
||||
const completedToolExecutionIds = new Set<string>()
|
||||
let lastDoneResponseId: string | undefined
|
||||
let lastSafeDoneResponseId: string | undefined
|
||||
|
||||
// Send chatId as first event
|
||||
if (actualChatId) {
|
||||
@@ -515,6 +619,9 @@ export async function POST(req: NextRequest) {
|
||||
)
|
||||
if (!event.data?.partial) {
|
||||
toolCalls.push(event.data)
|
||||
if (event.data?.id) {
|
||||
announcedToolCallIds.add(event.data.id)
|
||||
}
|
||||
}
|
||||
break
|
||||
|
||||
@@ -524,6 +631,14 @@ export async function POST(req: NextRequest) {
|
||||
toolName: event.toolName,
|
||||
status: event.status,
|
||||
})
|
||||
if (event.toolCallId) {
|
||||
if (event.status === 'completed') {
|
||||
startedToolExecutionIds.add(event.toolCallId)
|
||||
completedToolExecutionIds.add(event.toolCallId)
|
||||
} else {
|
||||
startedToolExecutionIds.add(event.toolCallId)
|
||||
}
|
||||
}
|
||||
break
|
||||
|
||||
case 'tool_result':
|
||||
@@ -534,6 +649,9 @@ export async function POST(req: NextRequest) {
|
||||
result: `${JSON.stringify(event.result).substring(0, 200)}...`,
|
||||
resultSize: JSON.stringify(event.result).length,
|
||||
})
|
||||
if (event.toolCallId) {
|
||||
completedToolExecutionIds.add(event.toolCallId)
|
||||
}
|
||||
break
|
||||
|
||||
case 'tool_error':
|
||||
@@ -543,6 +661,9 @@ export async function POST(req: NextRequest) {
|
||||
error: event.error,
|
||||
success: event.success,
|
||||
})
|
||||
if (event.toolCallId) {
|
||||
completedToolExecutionIds.add(event.toolCallId)
|
||||
}
|
||||
break
|
||||
|
||||
case 'start':
|
||||
@@ -557,9 +678,25 @@ export async function POST(req: NextRequest) {
|
||||
case 'done':
|
||||
if (event.data?.responseId) {
|
||||
responseIdFromDone = event.data.responseId
|
||||
lastDoneResponseId = responseIdFromDone
|
||||
logger.info(
|
||||
`[${tracker.requestId}] Received done event with responseId: ${responseIdFromDone}`
|
||||
)
|
||||
// Mark this done as safe only if no tool call is currently in progress or pending
|
||||
const announced = announcedToolCallIds.size
|
||||
const completed = completedToolExecutionIds.size
|
||||
const started = startedToolExecutionIds.size
|
||||
const hasToolInProgress = announced > completed || started > completed
|
||||
if (!hasToolInProgress) {
|
||||
lastSafeDoneResponseId = responseIdFromDone
|
||||
logger.info(
|
||||
`[${tracker.requestId}] Marked done as SAFE (no tools in progress)`
|
||||
)
|
||||
} else {
|
||||
logger.info(
|
||||
`[${tracker.requestId}] Done received but tools are in progress (announced=${announced}, started=${started}, completed=${completed})`
|
||||
)
|
||||
}
|
||||
}
|
||||
if (isFirstDone) {
|
||||
logger.info(
|
||||
@@ -654,7 +791,9 @@ export async function POST(req: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
const responseId = responseIdFromDone || responseIdFromStart
|
||||
// Persist only a safe conversationId to avoid continuing from a state that expects tool outputs
|
||||
const previousConversationId = currentChat?.conversationId as string | undefined
|
||||
const responseId = lastSafeDoneResponseId || previousConversationId || undefined
|
||||
|
||||
// Update chat in database immediately (without title)
|
||||
await db
|
||||
|
||||
@@ -48,11 +48,6 @@ async function updateToolCallStatus(
|
||||
while (Date.now() - startTime < timeout) {
|
||||
const exists = await redis.exists(key)
|
||||
if (exists) {
|
||||
logger.info('Tool call found in Redis, updating status', {
|
||||
toolCallId,
|
||||
key,
|
||||
pollDuration: Date.now() - startTime,
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
@@ -79,27 +74,8 @@ async function updateToolCallStatus(
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
|
||||
// Log what we're about to update in Redis
|
||||
logger.info('About to update Redis with tool call data', {
|
||||
toolCallId,
|
||||
key,
|
||||
toolCallData,
|
||||
serializedData: JSON.stringify(toolCallData),
|
||||
providedStatus: status,
|
||||
providedMessage: message,
|
||||
messageIsUndefined: message === undefined,
|
||||
messageIsNull: message === null,
|
||||
})
|
||||
|
||||
await redis.set(key, JSON.stringify(toolCallData), 'EX', 86400) // Keep 24 hour expiry
|
||||
|
||||
logger.info('Tool call status updated in Redis', {
|
||||
toolCallId,
|
||||
key,
|
||||
status,
|
||||
message,
|
||||
pollDuration: Date.now() - startTime,
|
||||
})
|
||||
return true
|
||||
} catch (error) {
|
||||
logger.error('Failed to update tool call status in Redis', {
|
||||
@@ -131,13 +107,6 @@ export async function POST(req: NextRequest) {
|
||||
const body = await req.json()
|
||||
const { toolCallId, status, message } = ConfirmationSchema.parse(body)
|
||||
|
||||
logger.info(`[${tracker.requestId}] Tool call confirmation request`, {
|
||||
userId: authenticatedUserId,
|
||||
toolCallId,
|
||||
status,
|
||||
message,
|
||||
})
|
||||
|
||||
// Update the tool call status in Redis
|
||||
const updated = await updateToolCallStatus(toolCallId, status, message)
|
||||
|
||||
@@ -153,13 +122,6 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
|
||||
const duration = tracker.getDuration()
|
||||
logger.info(`[${tracker.requestId}] Tool call confirmation completed`, {
|
||||
userId: authenticatedUserId,
|
||||
toolCallId,
|
||||
status,
|
||||
internalStatus: status,
|
||||
duration,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
|
||||
@@ -60,6 +60,7 @@ describe('Copilot Methods API Route', () => {
|
||||
vi.doMock('@/lib/env', () => ({
|
||||
env: {
|
||||
INTERNAL_API_SECRET: 'test-secret-key',
|
||||
COPILOT_API_KEY: 'test-copilot-key',
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -123,10 +124,8 @@ describe('Copilot Methods API Route', () => {
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
const responseData = await response.json()
|
||||
expect(responseData).toEqual({
|
||||
success: false,
|
||||
error: 'Invalid API key',
|
||||
})
|
||||
expect(responseData.success).toBe(false)
|
||||
expect(typeof responseData.error).toBe('string')
|
||||
})
|
||||
|
||||
it('should return 401 when internal API key is not configured', async () => {
|
||||
@@ -134,6 +133,7 @@ describe('Copilot Methods API Route', () => {
|
||||
vi.doMock('@/lib/env', () => ({
|
||||
env: {
|
||||
INTERNAL_API_SECRET: undefined,
|
||||
COPILOT_API_KEY: 'test-copilot-key',
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -154,10 +154,9 @@ describe('Copilot Methods API Route', () => {
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
const responseData = await response.json()
|
||||
expect(responseData).toEqual({
|
||||
success: false,
|
||||
error: 'Internal API key not configured',
|
||||
})
|
||||
expect(responseData.status).toBeUndefined()
|
||||
expect(responseData.success).toBe(false)
|
||||
expect(typeof responseData.error).toBe('string')
|
||||
})
|
||||
|
||||
it('should return 400 for invalid request body - missing methodId', async () => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { copilotToolRegistry } from '@/lib/copilot/tools/server-tools/registry'
|
||||
import type { NotificationStatus } from '@/lib/copilot/types'
|
||||
import { checkInternalApiKey } from '@/lib/copilot/utils'
|
||||
import { checkCopilotApiKey, checkInternalApiKey } from '@/lib/copilot/utils'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getRedisClient } from '@/lib/redis'
|
||||
import { createErrorResponse } from '@/app/api/copilot/methods/utils'
|
||||
@@ -69,12 +69,6 @@ async function pollRedisForTool(
|
||||
const pollInterval = 1000 // 1 second
|
||||
const startTime = Date.now()
|
||||
|
||||
logger.info('Starting to poll Redis for tool call status', {
|
||||
toolCallId,
|
||||
timeout,
|
||||
pollInterval,
|
||||
})
|
||||
|
||||
while (Date.now() - startTime < timeout) {
|
||||
try {
|
||||
const redisValue = await redis.get(key)
|
||||
@@ -112,23 +106,6 @@ async function pollRedisForTool(
|
||||
rawRedisValue: redisValue,
|
||||
})
|
||||
|
||||
logger.info('Tool call status resolved', {
|
||||
toolCallId,
|
||||
status,
|
||||
message,
|
||||
duration: Date.now() - startTime,
|
||||
rawRedisValue: redisValue,
|
||||
parsedAsJSON: redisValue
|
||||
? (() => {
|
||||
try {
|
||||
return JSON.parse(redisValue)
|
||||
} catch {
|
||||
return 'failed-to-parse'
|
||||
}
|
||||
})()
|
||||
: null,
|
||||
})
|
||||
|
||||
// Special logging for set environment variables tool when Redis status is found
|
||||
if (toolCallId && (status === 'accepted' || status === 'rejected')) {
|
||||
logger.info('SET_ENV_VARS: Redis polling found status update', {
|
||||
@@ -255,10 +232,13 @@ export async function POST(req: NextRequest) {
|
||||
const startTime = Date.now()
|
||||
|
||||
try {
|
||||
// Check authentication (internal API key)
|
||||
const authResult = checkInternalApiKey(req)
|
||||
if (!authResult.success) {
|
||||
return NextResponse.json(createErrorResponse(authResult.error || 'Authentication failed'), {
|
||||
// Evaluate both auth schemes; pass if either is valid
|
||||
const internalAuth = checkInternalApiKey(req)
|
||||
const copilotAuth = checkCopilotApiKey(req)
|
||||
const isAuthenticated = !!(internalAuth?.success || copilotAuth?.success)
|
||||
if (!isAuthenticated) {
|
||||
const errorMessage = copilotAuth.error || internalAuth.error || 'Authentication failed'
|
||||
return NextResponse.json(createErrorResponse(errorMessage), {
|
||||
status: 401,
|
||||
})
|
||||
}
|
||||
@@ -266,7 +246,7 @@ export async function POST(req: NextRequest) {
|
||||
const body = await req.json()
|
||||
const { methodId, params, toolCallId } = MethodExecutionSchema.parse(body)
|
||||
|
||||
logger.info(`[${requestId}] Method execution request: ${methodId}`, {
|
||||
logger.info(`[${requestId}] Method execution request`, {
|
||||
methodId,
|
||||
toolCallId,
|
||||
hasParams: !!params && Object.keys(params).length > 0,
|
||||
|
||||
@@ -178,7 +178,7 @@ export function findLocalFile(filename: string): string | null {
|
||||
* Create a file response with appropriate headers
|
||||
*/
|
||||
export function createFileResponse(file: FileResponse): NextResponse {
|
||||
return new NextResponse(file.buffer, {
|
||||
return new NextResponse(file.buffer as BodyInit, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': file.contentType,
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { Resend } from 'resend'
|
||||
import { z } from 'zod'
|
||||
import { renderHelpConfirmationEmail } from '@/components/emails'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { sendEmail } from '@/lib/email/mailer'
|
||||
import { env } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getEmailDomain } from '@/lib/urls/utils'
|
||||
|
||||
const resend = env.RESEND_API_KEY ? new Resend(env.RESEND_API_KEY) : null
|
||||
const logger = createLogger('HelpAPI')
|
||||
|
||||
const helpFormSchema = z.object({
|
||||
email: z.string().email('Invalid email address'),
|
||||
subject: z.string().min(1, 'Subject is required'),
|
||||
message: z.string().min(1, 'Message is required'),
|
||||
type: z.enum(['bug', 'feedback', 'feature_request', 'other']),
|
||||
@@ -19,23 +19,19 @@ export async function POST(req: NextRequest) {
|
||||
const requestId = crypto.randomUUID().slice(0, 8)
|
||||
|
||||
try {
|
||||
// Check if Resend API key is configured
|
||||
if (!resend) {
|
||||
logger.error(`[${requestId}] RESEND_API_KEY not configured`)
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'Email service not configured. Please set RESEND_API_KEY in environment variables.',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
// Get user session
|
||||
const session = await getSession()
|
||||
if (!session?.user?.email) {
|
||||
logger.warn(`[${requestId}] Unauthorized help request attempt`)
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
|
||||
}
|
||||
|
||||
const email = session.user.email
|
||||
|
||||
// Handle multipart form data
|
||||
const formData = await req.formData()
|
||||
|
||||
// Extract form fields
|
||||
const email = formData.get('email') as string
|
||||
const subject = formData.get('subject') as string
|
||||
const message = formData.get('message') as string
|
||||
const type = formData.get('type') as string
|
||||
@@ -46,19 +42,18 @@ export async function POST(req: NextRequest) {
|
||||
})
|
||||
|
||||
// Validate the form data
|
||||
const result = helpFormSchema.safeParse({
|
||||
email,
|
||||
const validationResult = helpFormSchema.safeParse({
|
||||
subject,
|
||||
message,
|
||||
type,
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
if (!validationResult.success) {
|
||||
logger.warn(`[${requestId}] Invalid help request data`, {
|
||||
errors: result.error.format(),
|
||||
errors: validationResult.error.format(),
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid request data', details: result.error.format() },
|
||||
{ error: 'Invalid request data', details: validationResult.error.format() },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
@@ -96,63 +91,60 @@ ${message}
|
||||
emailText += `\n\n${images.length} image(s) attached.`
|
||||
}
|
||||
|
||||
// Send email using Resend
|
||||
const { data, error } = await resend.emails.send({
|
||||
from: `Sim <noreply@${getEmailDomain()}>`,
|
||||
to: [`help@${getEmailDomain()}`],
|
||||
const emailResult = await sendEmail({
|
||||
to: [`help@${env.EMAIL_DOMAIN || getEmailDomain()}`],
|
||||
subject: `[${type.toUpperCase()}] ${subject}`,
|
||||
replyTo: email,
|
||||
text: emailText,
|
||||
from: `${env.SENDER_NAME || 'Sim'} <noreply@${env.EMAIL_DOMAIN || getEmailDomain()}>`,
|
||||
replyTo: email,
|
||||
emailType: 'transactional',
|
||||
attachments: images.map((image) => ({
|
||||
filename: image.filename,
|
||||
content: image.content.toString('base64'),
|
||||
contentType: image.contentType,
|
||||
disposition: 'attachment', // Explicitly set as attachment
|
||||
disposition: 'attachment',
|
||||
})),
|
||||
})
|
||||
|
||||
if (error) {
|
||||
logger.error(`[${requestId}] Error sending help request email`, error)
|
||||
if (!emailResult.success) {
|
||||
logger.error(`[${requestId}] Error sending help request email`, emailResult.message)
|
||||
return NextResponse.json({ error: 'Failed to send email' }, { status: 500 })
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Help request email sent successfully`)
|
||||
|
||||
// Send confirmation email to the user
|
||||
await resend.emails
|
||||
.send({
|
||||
from: `Sim <noreply@${getEmailDomain()}>`,
|
||||
try {
|
||||
const confirmationHtml = await renderHelpConfirmationEmail(
|
||||
email,
|
||||
type as 'bug' | 'feedback' | 'feature_request' | 'other',
|
||||
images.length
|
||||
)
|
||||
|
||||
await sendEmail({
|
||||
to: [email],
|
||||
subject: `Your ${type} request has been received: ${subject}`,
|
||||
text: `
|
||||
Hello,
|
||||
|
||||
Thank you for your ${type} submission. We've received your request and will get back to you as soon as possible.
|
||||
|
||||
Your message:
|
||||
${message}
|
||||
|
||||
${images.length > 0 ? `You attached ${images.length} image(s).` : ''}
|
||||
|
||||
Best regards,
|
||||
The Sim Team
|
||||
`,
|
||||
replyTo: `help@${getEmailDomain()}`,
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.warn(`[${requestId}] Failed to send confirmation email`, err)
|
||||
html: confirmationHtml,
|
||||
from: `${env.SENDER_NAME || 'Sim'} <noreply@${env.EMAIL_DOMAIN || getEmailDomain()}>`,
|
||||
replyTo: `help@${env.EMAIL_DOMAIN || getEmailDomain()}`,
|
||||
emailType: 'transactional',
|
||||
})
|
||||
} catch (err) {
|
||||
logger.warn(`[${requestId}] Failed to send confirmation email`, err)
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ success: true, message: 'Help request submitted successfully' },
|
||||
{ status: 200 }
|
||||
)
|
||||
} catch (error) {
|
||||
// Check if error is related to missing API key
|
||||
if (error instanceof Error && error.message.includes('API key')) {
|
||||
logger.error(`[${requestId}] API key configuration error`, error)
|
||||
if (error instanceof Error && error.message.includes('not configured')) {
|
||||
logger.error(`[${requestId}] Email service configuration error`, error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Email service configuration error. Please check your RESEND_API_KEY.' },
|
||||
{
|
||||
error:
|
||||
'Email service configuration error. Please check your email service configuration.',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { runs } from '@trigger.dev/sdk/v3'
|
||||
import { runs } from '@trigger.dev/sdk'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
|
||||
@@ -4,15 +4,50 @@
|
||||
*
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('drizzle-orm')
|
||||
vi.mock('@/lib/logs/console/logger')
|
||||
vi.mock('@/lib/logs/console/logger', () => ({
|
||||
createLogger: vi.fn(() => ({
|
||||
info: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
})),
|
||||
}))
|
||||
vi.mock('@/db')
|
||||
vi.mock('@/lib/documents/utils', () => ({
|
||||
retryWithExponentialBackoff: (fn: any) => fn(),
|
||||
}))
|
||||
|
||||
import { handleTagAndVectorSearch, handleTagOnlySearch, handleVectorOnlySearch } from './utils'
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [{ embedding: [0.1, 0.2, 0.3] }],
|
||||
}),
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@/lib/env', () => ({
|
||||
env: {},
|
||||
isTruthy: (value: string | boolean | number | undefined) =>
|
||||
typeof value === 'string' ? value === 'true' || value === '1' : Boolean(value),
|
||||
}))
|
||||
|
||||
import {
|
||||
generateSearchEmbedding,
|
||||
handleTagAndVectorSearch,
|
||||
handleTagOnlySearch,
|
||||
handleVectorOnlySearch,
|
||||
} from './utils'
|
||||
|
||||
describe('Knowledge Search Utils', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('handleTagOnlySearch', () => {
|
||||
it('should throw error when no filters provided', async () => {
|
||||
const params = {
|
||||
@@ -140,4 +175,251 @@ describe('Knowledge Search Utils', () => {
|
||||
expect(params.distanceThreshold).toBe(0.8)
|
||||
})
|
||||
})
|
||||
|
||||
describe('generateSearchEmbedding', () => {
|
||||
it('should use Azure OpenAI when KB-specific config is provided', async () => {
|
||||
const { env } = await import('@/lib/env')
|
||||
Object.keys(env).forEach((key) => delete (env as any)[key])
|
||||
Object.assign(env, {
|
||||
AZURE_OPENAI_API_KEY: 'test-azure-key',
|
||||
AZURE_OPENAI_ENDPOINT: 'https://test.openai.azure.com',
|
||||
AZURE_OPENAI_API_VERSION: '2024-12-01-preview',
|
||||
KB_OPENAI_MODEL_NAME: 'text-embedding-ada-002',
|
||||
OPENAI_API_KEY: 'test-openai-key',
|
||||
})
|
||||
|
||||
const fetchSpy = vi.mocked(fetch)
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [{ embedding: [0.1, 0.2, 0.3] }],
|
||||
}),
|
||||
} as any)
|
||||
|
||||
const result = await generateSearchEmbedding('test query')
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
'https://test.openai.azure.com/openai/deployments/text-embedding-ada-002/embeddings?api-version=2024-12-01-preview',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
'api-key': 'test-azure-key',
|
||||
}),
|
||||
})
|
||||
)
|
||||
expect(result).toEqual([0.1, 0.2, 0.3])
|
||||
|
||||
// Clean up
|
||||
Object.keys(env).forEach((key) => delete (env as any)[key])
|
||||
})
|
||||
|
||||
it('should fallback to OpenAI when no KB Azure config provided', async () => {
|
||||
const { env } = await import('@/lib/env')
|
||||
Object.keys(env).forEach((key) => delete (env as any)[key])
|
||||
Object.assign(env, {
|
||||
OPENAI_API_KEY: 'test-openai-key',
|
||||
})
|
||||
|
||||
const fetchSpy = vi.mocked(fetch)
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [{ embedding: [0.1, 0.2, 0.3] }],
|
||||
}),
|
||||
} as any)
|
||||
|
||||
const result = await generateSearchEmbedding('test query')
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
'https://api.openai.com/v1/embeddings',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer test-openai-key',
|
||||
}),
|
||||
})
|
||||
)
|
||||
expect(result).toEqual([0.1, 0.2, 0.3])
|
||||
|
||||
// Clean up
|
||||
Object.keys(env).forEach((key) => delete (env as any)[key])
|
||||
})
|
||||
|
||||
it('should use default API version when not provided in Azure config', async () => {
|
||||
const { env } = await import('@/lib/env')
|
||||
Object.keys(env).forEach((key) => delete (env as any)[key])
|
||||
Object.assign(env, {
|
||||
AZURE_OPENAI_API_KEY: 'test-azure-key',
|
||||
AZURE_OPENAI_ENDPOINT: 'https://test.openai.azure.com',
|
||||
KB_OPENAI_MODEL_NAME: 'custom-embedding-model',
|
||||
OPENAI_API_KEY: 'test-openai-key',
|
||||
})
|
||||
|
||||
const fetchSpy = vi.mocked(fetch)
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [{ embedding: [0.1, 0.2, 0.3] }],
|
||||
}),
|
||||
} as any)
|
||||
|
||||
await generateSearchEmbedding('test query')
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('api-version='),
|
||||
expect.any(Object)
|
||||
)
|
||||
|
||||
// Clean up
|
||||
Object.keys(env).forEach((key) => delete (env as any)[key])
|
||||
})
|
||||
|
||||
it('should use custom model name when provided in Azure config', async () => {
|
||||
const { env } = await import('@/lib/env')
|
||||
Object.keys(env).forEach((key) => delete (env as any)[key])
|
||||
Object.assign(env, {
|
||||
AZURE_OPENAI_API_KEY: 'test-azure-key',
|
||||
AZURE_OPENAI_ENDPOINT: 'https://test.openai.azure.com',
|
||||
AZURE_OPENAI_API_VERSION: '2024-12-01-preview',
|
||||
KB_OPENAI_MODEL_NAME: 'custom-embedding-model',
|
||||
OPENAI_API_KEY: 'test-openai-key',
|
||||
})
|
||||
|
||||
const fetchSpy = vi.mocked(fetch)
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [{ embedding: [0.1, 0.2, 0.3] }],
|
||||
}),
|
||||
} as any)
|
||||
|
||||
await generateSearchEmbedding('test query', 'text-embedding-3-small')
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
'https://test.openai.azure.com/openai/deployments/custom-embedding-model/embeddings?api-version=2024-12-01-preview',
|
||||
expect.any(Object)
|
||||
)
|
||||
|
||||
// Clean up
|
||||
Object.keys(env).forEach((key) => delete (env as any)[key])
|
||||
})
|
||||
|
||||
it('should throw error when no API configuration provided', async () => {
|
||||
const { env } = await import('@/lib/env')
|
||||
Object.keys(env).forEach((key) => delete (env as any)[key])
|
||||
|
||||
await expect(generateSearchEmbedding('test query')).rejects.toThrow(
|
||||
'Either OPENAI_API_KEY or Azure OpenAI configuration (AZURE_OPENAI_API_KEY + AZURE_OPENAI_ENDPOINT) must be configured'
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle Azure OpenAI API errors properly', async () => {
|
||||
const { env } = await import('@/lib/env')
|
||||
Object.keys(env).forEach((key) => delete (env as any)[key])
|
||||
Object.assign(env, {
|
||||
AZURE_OPENAI_API_KEY: 'test-azure-key',
|
||||
AZURE_OPENAI_ENDPOINT: 'https://test.openai.azure.com',
|
||||
AZURE_OPENAI_API_VERSION: '2024-12-01-preview',
|
||||
KB_OPENAI_MODEL_NAME: 'text-embedding-ada-002',
|
||||
})
|
||||
|
||||
const fetchSpy = vi.mocked(fetch)
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 404,
|
||||
statusText: 'Not Found',
|
||||
text: async () => 'Deployment not found',
|
||||
} as any)
|
||||
|
||||
await expect(generateSearchEmbedding('test query')).rejects.toThrow('Embedding API failed')
|
||||
|
||||
// Clean up
|
||||
Object.keys(env).forEach((key) => delete (env as any)[key])
|
||||
})
|
||||
|
||||
it('should handle OpenAI API errors properly', async () => {
|
||||
const { env } = await import('@/lib/env')
|
||||
Object.keys(env).forEach((key) => delete (env as any)[key])
|
||||
Object.assign(env, {
|
||||
OPENAI_API_KEY: 'test-openai-key',
|
||||
})
|
||||
|
||||
const fetchSpy = vi.mocked(fetch)
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 429,
|
||||
statusText: 'Too Many Requests',
|
||||
text: async () => 'Rate limit exceeded',
|
||||
} as any)
|
||||
|
||||
await expect(generateSearchEmbedding('test query')).rejects.toThrow('Embedding API failed')
|
||||
|
||||
// Clean up
|
||||
Object.keys(env).forEach((key) => delete (env as any)[key])
|
||||
})
|
||||
|
||||
it('should include correct request body for Azure OpenAI', async () => {
|
||||
const { env } = await import('@/lib/env')
|
||||
Object.keys(env).forEach((key) => delete (env as any)[key])
|
||||
Object.assign(env, {
|
||||
AZURE_OPENAI_API_KEY: 'test-azure-key',
|
||||
AZURE_OPENAI_ENDPOINT: 'https://test.openai.azure.com',
|
||||
AZURE_OPENAI_API_VERSION: '2024-12-01-preview',
|
||||
KB_OPENAI_MODEL_NAME: 'text-embedding-ada-002',
|
||||
})
|
||||
|
||||
const fetchSpy = vi.mocked(fetch)
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [{ embedding: [0.1, 0.2, 0.3] }],
|
||||
}),
|
||||
} as any)
|
||||
|
||||
await generateSearchEmbedding('test query')
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
input: ['test query'],
|
||||
encoding_format: 'float',
|
||||
}),
|
||||
})
|
||||
)
|
||||
|
||||
// Clean up
|
||||
Object.keys(env).forEach((key) => delete (env as any)[key])
|
||||
})
|
||||
|
||||
it('should include correct request body for OpenAI', async () => {
|
||||
const { env } = await import('@/lib/env')
|
||||
Object.keys(env).forEach((key) => delete (env as any)[key])
|
||||
Object.assign(env, {
|
||||
OPENAI_API_KEY: 'test-openai-key',
|
||||
})
|
||||
|
||||
const fetchSpy = vi.mocked(fetch)
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [{ embedding: [0.1, 0.2, 0.3] }],
|
||||
}),
|
||||
} as any)
|
||||
|
||||
await generateSearchEmbedding('test query', 'text-embedding-3-small')
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
input: ['test query'],
|
||||
model: 'text-embedding-3-small',
|
||||
encoding_format: 'float',
|
||||
}),
|
||||
})
|
||||
)
|
||||
|
||||
// Clean up
|
||||
Object.keys(env).forEach((key) => delete (env as any)[key])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,22 +1,10 @@
|
||||
import { and, eq, inArray, sql } from 'drizzle-orm'
|
||||
import { retryWithExponentialBackoff } from '@/lib/documents/utils'
|
||||
import { env } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { db } from '@/db'
|
||||
import { embedding } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('KnowledgeSearchUtils')
|
||||
|
||||
export class APIError extends Error {
|
||||
public status: number
|
||||
|
||||
constructor(message: string, status: number) {
|
||||
super(message)
|
||||
this.name = 'APIError'
|
||||
this.status = status
|
||||
}
|
||||
}
|
||||
|
||||
export interface SearchResult {
|
||||
id: string
|
||||
content: string
|
||||
@@ -41,61 +29,8 @@ export interface SearchParams {
|
||||
distanceThreshold?: number
|
||||
}
|
||||
|
||||
export async function generateSearchEmbedding(query: string): Promise<number[]> {
|
||||
const openaiApiKey = env.OPENAI_API_KEY
|
||||
if (!openaiApiKey) {
|
||||
throw new Error('OPENAI_API_KEY not configured')
|
||||
}
|
||||
|
||||
try {
|
||||
const embedding = await retryWithExponentialBackoff(
|
||||
async () => {
|
||||
const response = await fetch('https://api.openai.com/v1/embeddings', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${openaiApiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
input: query,
|
||||
model: 'text-embedding-3-small',
|
||||
encoding_format: 'float',
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
const error = new APIError(
|
||||
`OpenAI API error: ${response.status} ${response.statusText} - ${errorText}`,
|
||||
response.status
|
||||
)
|
||||
throw error
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!data.data || !Array.isArray(data.data) || data.data.length === 0) {
|
||||
throw new Error('Invalid response format from OpenAI embeddings API')
|
||||
}
|
||||
|
||||
return data.data[0].embedding
|
||||
},
|
||||
{
|
||||
maxRetries: 5,
|
||||
initialDelayMs: 1000,
|
||||
maxDelayMs: 30000,
|
||||
backoffMultiplier: 2,
|
||||
}
|
||||
)
|
||||
|
||||
return embedding
|
||||
} catch (error) {
|
||||
logger.error('Failed to generate search embedding:', error)
|
||||
throw new Error(
|
||||
`Embedding generation failed: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
)
|
||||
}
|
||||
}
|
||||
// Use shared embedding utility
|
||||
export { generateSearchEmbedding } from '@/lib/embeddings/utils'
|
||||
|
||||
function getTagFilters(filters: Record<string, string>, embedding: any) {
|
||||
return Object.entries(filters).map(([key, value]) => {
|
||||
|
||||
@@ -252,5 +252,76 @@ describe('Knowledge Utils', () => {
|
||||
|
||||
expect(result.length).toBe(2)
|
||||
})
|
||||
|
||||
it('should use Azure OpenAI when Azure config is provided', async () => {
|
||||
const { env } = await import('@/lib/env')
|
||||
Object.keys(env).forEach((key) => delete (env as any)[key])
|
||||
Object.assign(env, {
|
||||
AZURE_OPENAI_API_KEY: 'test-azure-key',
|
||||
AZURE_OPENAI_ENDPOINT: 'https://test.openai.azure.com',
|
||||
AZURE_OPENAI_API_VERSION: '2024-12-01-preview',
|
||||
KB_OPENAI_MODEL_NAME: 'text-embedding-ada-002',
|
||||
OPENAI_API_KEY: 'test-openai-key',
|
||||
})
|
||||
|
||||
const fetchSpy = vi.mocked(fetch)
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [{ embedding: [0.1, 0.2], index: 0 }],
|
||||
}),
|
||||
} as any)
|
||||
|
||||
await generateEmbeddings(['test text'])
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
'https://test.openai.azure.com/openai/deployments/text-embedding-ada-002/embeddings?api-version=2024-12-01-preview',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
'api-key': 'test-azure-key',
|
||||
}),
|
||||
})
|
||||
)
|
||||
|
||||
Object.keys(env).forEach((key) => delete (env as any)[key])
|
||||
})
|
||||
|
||||
it('should fallback to OpenAI when no Azure config provided', async () => {
|
||||
const { env } = await import('@/lib/env')
|
||||
Object.keys(env).forEach((key) => delete (env as any)[key])
|
||||
Object.assign(env, {
|
||||
OPENAI_API_KEY: 'test-openai-key',
|
||||
})
|
||||
|
||||
const fetchSpy = vi.mocked(fetch)
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [{ embedding: [0.1, 0.2], index: 0 }],
|
||||
}),
|
||||
} as any)
|
||||
|
||||
await generateEmbeddings(['test text'])
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
'https://api.openai.com/v1/embeddings',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer test-openai-key',
|
||||
}),
|
||||
})
|
||||
)
|
||||
|
||||
Object.keys(env).forEach((key) => delete (env as any)[key])
|
||||
})
|
||||
|
||||
it('should throw error when no API configuration provided', async () => {
|
||||
const { env } = await import('@/lib/env')
|
||||
Object.keys(env).forEach((key) => delete (env as any)[key])
|
||||
|
||||
await expect(generateEmbeddings(['test text'])).rejects.toThrow(
|
||||
'Either OPENAI_API_KEY or Azure OpenAI configuration (AZURE_OPENAI_API_KEY + AZURE_OPENAI_ENDPOINT) must be configured'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import crypto from 'crypto'
|
||||
import { and, eq, isNull } from 'drizzle-orm'
|
||||
import { processDocument } from '@/lib/documents/document-processor'
|
||||
import { retryWithExponentialBackoff } from '@/lib/documents/utils'
|
||||
import { env } from '@/lib/env'
|
||||
import { generateEmbeddings } from '@/lib/embeddings/utils'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getUserEntityPermissions } from '@/lib/permissions/utils'
|
||||
import { db } from '@/db'
|
||||
@@ -10,22 +9,11 @@ import { document, embedding, knowledgeBase } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('KnowledgeUtils')
|
||||
|
||||
// Timeout constants (in milliseconds)
|
||||
const TIMEOUTS = {
|
||||
OVERALL_PROCESSING: 150000, // 150 seconds (2.5 minutes)
|
||||
EMBEDDINGS_API: 60000, // 60 seconds per batch
|
||||
} as const
|
||||
|
||||
class APIError extends Error {
|
||||
public status: number
|
||||
|
||||
constructor(message: string, status: number) {
|
||||
super(message)
|
||||
this.name = 'APIError'
|
||||
this.status = status
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a timeout wrapper for async operations
|
||||
*/
|
||||
@@ -110,18 +98,6 @@ export interface EmbeddingData {
|
||||
updatedAt: Date
|
||||
}
|
||||
|
||||
interface OpenAIEmbeddingResponse {
|
||||
data: Array<{
|
||||
embedding: number[]
|
||||
index: number
|
||||
}>
|
||||
model: string
|
||||
usage: {
|
||||
prompt_tokens: number
|
||||
total_tokens: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface KnowledgeBaseAccessResult {
|
||||
hasAccess: true
|
||||
knowledgeBase: Pick<KnowledgeBaseData, 'id' | 'userId'>
|
||||
@@ -405,87 +381,8 @@ export async function checkChunkAccess(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate embeddings using OpenAI API with retry logic for rate limiting
|
||||
*/
|
||||
export async function generateEmbeddings(
|
||||
texts: string[],
|
||||
embeddingModel = 'text-embedding-3-small'
|
||||
): Promise<number[][]> {
|
||||
const openaiApiKey = env.OPENAI_API_KEY
|
||||
if (!openaiApiKey) {
|
||||
throw new Error('OPENAI_API_KEY not configured')
|
||||
}
|
||||
|
||||
try {
|
||||
const batchSize = 100
|
||||
const allEmbeddings: number[][] = []
|
||||
|
||||
for (let i = 0; i < texts.length; i += batchSize) {
|
||||
const batch = texts.slice(i, i + batchSize)
|
||||
|
||||
logger.info(
|
||||
`Generating embeddings for batch ${Math.floor(i / batchSize) + 1} (${batch.length} texts)`
|
||||
)
|
||||
|
||||
const batchEmbeddings = await retryWithExponentialBackoff(
|
||||
async () => {
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), TIMEOUTS.EMBEDDINGS_API)
|
||||
|
||||
try {
|
||||
const response = await fetch('https://api.openai.com/v1/embeddings', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${openaiApiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
input: batch,
|
||||
model: embeddingModel,
|
||||
encoding_format: 'float',
|
||||
}),
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
clearTimeout(timeoutId)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
const error = new APIError(
|
||||
`OpenAI API error: ${response.status} ${response.statusText} - ${errorText}`,
|
||||
response.status
|
||||
)
|
||||
throw error
|
||||
}
|
||||
|
||||
const data: OpenAIEmbeddingResponse = await response.json()
|
||||
return data.data.map((item) => item.embedding)
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId)
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
throw new Error('OpenAI API request timed out')
|
||||
}
|
||||
throw error
|
||||
}
|
||||
},
|
||||
{
|
||||
maxRetries: 5,
|
||||
initialDelayMs: 1000,
|
||||
maxDelayMs: 60000, // Max 1 minute delay for embeddings
|
||||
backoffMultiplier: 2,
|
||||
}
|
||||
)
|
||||
|
||||
allEmbeddings.push(...batchEmbeddings)
|
||||
}
|
||||
|
||||
return allEmbeddings
|
||||
} catch (error) {
|
||||
logger.error('Failed to generate embeddings:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
// Export for external use
|
||||
export { generateEmbeddings }
|
||||
|
||||
/**
|
||||
* Process a document asynchronously with full error handling
|
||||
|
||||
@@ -46,20 +46,7 @@ export async function GET(
|
||||
startedAt: workflowLog.startedAt.toISOString(),
|
||||
endedAt: workflowLog.endedAt?.toISOString(),
|
||||
totalDurationMs: workflowLog.totalDurationMs,
|
||||
blockStats: {
|
||||
total: workflowLog.blockCount,
|
||||
success: workflowLog.successCount,
|
||||
error: workflowLog.errorCount,
|
||||
skipped: workflowLog.skippedCount,
|
||||
},
|
||||
cost: {
|
||||
total: workflowLog.totalCost ? Number.parseFloat(workflowLog.totalCost) : null,
|
||||
input: workflowLog.totalInputCost ? Number.parseFloat(workflowLog.totalInputCost) : null,
|
||||
output: workflowLog.totalOutputCost
|
||||
? Number.parseFloat(workflowLog.totalOutputCost)
|
||||
: null,
|
||||
},
|
||||
totalTokens: workflowLog.totalTokens,
|
||||
cost: workflowLog.cost || null,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
102
apps/sim/app/api/logs/by-id/[id]/route.ts
Normal file
102
apps/sim/app/api/logs/by-id/[id]/route.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
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 { permissions, workflow, workflowExecutionLogs } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('LogDetailsByIdAPI')
|
||||
|
||||
export const revalidate = 0
|
||||
|
||||
export async function GET(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const requestId = crypto.randomUUID().slice(0, 8)
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
logger.warn(`[${requestId}] Unauthorized log details access attempt`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const userId = session.user.id
|
||||
const { id } = await params
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
id: workflowExecutionLogs.id,
|
||||
workflowId: workflowExecutionLogs.workflowId,
|
||||
executionId: workflowExecutionLogs.executionId,
|
||||
stateSnapshotId: workflowExecutionLogs.stateSnapshotId,
|
||||
level: workflowExecutionLogs.level,
|
||||
trigger: workflowExecutionLogs.trigger,
|
||||
startedAt: workflowExecutionLogs.startedAt,
|
||||
endedAt: workflowExecutionLogs.endedAt,
|
||||
totalDurationMs: workflowExecutionLogs.totalDurationMs,
|
||||
executionData: workflowExecutionLogs.executionData,
|
||||
cost: workflowExecutionLogs.cost,
|
||||
files: workflowExecutionLogs.files,
|
||||
createdAt: workflowExecutionLogs.createdAt,
|
||||
workflowName: workflow.name,
|
||||
workflowDescription: workflow.description,
|
||||
workflowColor: workflow.color,
|
||||
workflowFolderId: workflow.folderId,
|
||||
workflowUserId: workflow.userId,
|
||||
workflowWorkspaceId: workflow.workspaceId,
|
||||
workflowCreatedAt: workflow.createdAt,
|
||||
workflowUpdatedAt: workflow.updatedAt,
|
||||
})
|
||||
.from(workflowExecutionLogs)
|
||||
.innerJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id))
|
||||
.innerJoin(
|
||||
permissions,
|
||||
and(
|
||||
eq(permissions.entityType, 'workspace'),
|
||||
eq(permissions.entityId, workflow.workspaceId),
|
||||
eq(permissions.userId, userId)
|
||||
)
|
||||
)
|
||||
.where(eq(workflowExecutionLogs.id, id))
|
||||
.limit(1)
|
||||
|
||||
const log = rows[0]
|
||||
if (!log) {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const workflowSummary = {
|
||||
id: log.workflowId,
|
||||
name: log.workflowName,
|
||||
description: log.workflowDescription,
|
||||
color: log.workflowColor,
|
||||
folderId: log.workflowFolderId,
|
||||
userId: log.workflowUserId,
|
||||
workspaceId: log.workflowWorkspaceId,
|
||||
createdAt: log.workflowCreatedAt,
|
||||
updatedAt: log.workflowUpdatedAt,
|
||||
}
|
||||
|
||||
const response = {
|
||||
id: log.id,
|
||||
workflowId: log.workflowId,
|
||||
executionId: log.executionId,
|
||||
level: log.level,
|
||||
duration: log.totalDurationMs ? `${log.totalDurationMs}ms` : null,
|
||||
trigger: log.trigger,
|
||||
createdAt: log.startedAt.toISOString(),
|
||||
files: log.files || undefined,
|
||||
workflow: workflowSummary,
|
||||
executionData: {
|
||||
totalDuration: log.totalDurationMs,
|
||||
...(log.executionData as any),
|
||||
enhanced: true,
|
||||
},
|
||||
cost: log.cost as any,
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: response })
|
||||
} catch (error: any) {
|
||||
logger.error(`[${requestId}] log details fetch error`, error)
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -99,21 +99,13 @@ export async function GET(request: NextRequest) {
|
||||
executionId: workflowExecutionLogs.executionId,
|
||||
stateSnapshotId: workflowExecutionLogs.stateSnapshotId,
|
||||
level: workflowExecutionLogs.level,
|
||||
message: workflowExecutionLogs.message,
|
||||
trigger: workflowExecutionLogs.trigger,
|
||||
startedAt: workflowExecutionLogs.startedAt,
|
||||
endedAt: workflowExecutionLogs.endedAt,
|
||||
totalDurationMs: workflowExecutionLogs.totalDurationMs,
|
||||
blockCount: workflowExecutionLogs.blockCount,
|
||||
successCount: workflowExecutionLogs.successCount,
|
||||
errorCount: workflowExecutionLogs.errorCount,
|
||||
skippedCount: workflowExecutionLogs.skippedCount,
|
||||
totalCost: workflowExecutionLogs.totalCost,
|
||||
totalInputCost: workflowExecutionLogs.totalInputCost,
|
||||
totalOutputCost: workflowExecutionLogs.totalOutputCost,
|
||||
totalTokens: workflowExecutionLogs.totalTokens,
|
||||
executionData: workflowExecutionLogs.executionData,
|
||||
cost: workflowExecutionLogs.cost,
|
||||
files: workflowExecutionLogs.files,
|
||||
metadata: workflowExecutionLogs.metadata,
|
||||
createdAt: workflowExecutionLogs.createdAt,
|
||||
})
|
||||
.from(workflowExecutionLogs)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { and, desc, eq, gte, inArray, lte, or, type SQL, sql } from 'drizzle-orm'
|
||||
import { and, desc, eq, gte, inArray, lte, type SQL, sql } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
@@ -44,8 +44,7 @@ function extractBlockExecutionsFromTraceSpans(traceSpans: any[]): any[] {
|
||||
export const revalidate = 0
|
||||
|
||||
const QueryParamsSchema = z.object({
|
||||
includeWorkflow: z.coerce.boolean().optional().default(false),
|
||||
includeBlocks: z.coerce.boolean().optional().default(false),
|
||||
details: z.enum(['basic', 'full']).optional().default('basic'),
|
||||
limit: z.coerce.number().optional().default(100),
|
||||
offset: z.coerce.number().optional().default(0),
|
||||
level: z.string().optional(),
|
||||
@@ -81,20 +80,12 @@ export async function GET(request: NextRequest) {
|
||||
executionId: workflowExecutionLogs.executionId,
|
||||
stateSnapshotId: workflowExecutionLogs.stateSnapshotId,
|
||||
level: workflowExecutionLogs.level,
|
||||
message: workflowExecutionLogs.message,
|
||||
trigger: workflowExecutionLogs.trigger,
|
||||
startedAt: workflowExecutionLogs.startedAt,
|
||||
endedAt: workflowExecutionLogs.endedAt,
|
||||
totalDurationMs: workflowExecutionLogs.totalDurationMs,
|
||||
blockCount: workflowExecutionLogs.blockCount,
|
||||
successCount: workflowExecutionLogs.successCount,
|
||||
errorCount: workflowExecutionLogs.errorCount,
|
||||
skippedCount: workflowExecutionLogs.skippedCount,
|
||||
totalCost: workflowExecutionLogs.totalCost,
|
||||
totalInputCost: workflowExecutionLogs.totalInputCost,
|
||||
totalOutputCost: workflowExecutionLogs.totalOutputCost,
|
||||
totalTokens: workflowExecutionLogs.totalTokens,
|
||||
metadata: workflowExecutionLogs.metadata,
|
||||
executionData: workflowExecutionLogs.executionData,
|
||||
cost: workflowExecutionLogs.cost,
|
||||
files: workflowExecutionLogs.files,
|
||||
createdAt: workflowExecutionLogs.createdAt,
|
||||
workflowName: workflow.name,
|
||||
@@ -163,13 +154,8 @@ export async function GET(request: NextRequest) {
|
||||
// Filter by search query
|
||||
if (params.search) {
|
||||
const searchTerm = `%${params.search}%`
|
||||
conditions = and(
|
||||
conditions,
|
||||
or(
|
||||
sql`${workflowExecutionLogs.message} ILIKE ${searchTerm}`,
|
||||
sql`${workflowExecutionLogs.executionId} ILIKE ${searchTerm}`
|
||||
)
|
||||
)
|
||||
// With message removed, restrict search to executionId only
|
||||
conditions = and(conditions, sql`${workflowExecutionLogs.executionId} ILIKE ${searchTerm}`)
|
||||
}
|
||||
|
||||
// Execute the query using the optimized join
|
||||
@@ -290,31 +276,20 @@ export async function GET(request: NextRequest) {
|
||||
const enhancedLogs = logs.map((log) => {
|
||||
const blockExecutions = blockExecutionsByExecution[log.executionId] || []
|
||||
|
||||
// Use stored trace spans from metadata if available, otherwise create from block executions
|
||||
const storedTraceSpans = (log.metadata as any)?.traceSpans
|
||||
// Use stored trace spans if available, otherwise create from block executions
|
||||
const storedTraceSpans = (log.executionData as any)?.traceSpans
|
||||
const traceSpans =
|
||||
storedTraceSpans && Array.isArray(storedTraceSpans) && storedTraceSpans.length > 0
|
||||
? storedTraceSpans
|
||||
: createTraceSpans(blockExecutions)
|
||||
|
||||
// Use extracted cost summary if available, otherwise use stored values
|
||||
// Prefer stored cost JSON; otherwise synthesize from blocks
|
||||
const costSummary =
|
||||
blockExecutions.length > 0
|
||||
? extractCostSummary(blockExecutions)
|
||||
: {
|
||||
input: Number(log.totalInputCost) || 0,
|
||||
output: Number(log.totalOutputCost) || 0,
|
||||
total: Number(log.totalCost) || 0,
|
||||
tokens: {
|
||||
total: log.totalTokens || 0,
|
||||
prompt: (log.metadata as any)?.tokenBreakdown?.prompt || 0,
|
||||
completion: (log.metadata as any)?.tokenBreakdown?.completion || 0,
|
||||
},
|
||||
models: (log.metadata as any)?.models || {},
|
||||
}
|
||||
log.cost && Object.keys(log.cost as any).length > 0
|
||||
? (log.cost as any)
|
||||
: extractCostSummary(blockExecutions)
|
||||
|
||||
// Build workflow object from joined data
|
||||
const workflow = {
|
||||
const workflowSummary = {
|
||||
id: log.workflowId,
|
||||
name: log.workflowName,
|
||||
description: log.workflowDescription,
|
||||
@@ -329,67 +304,28 @@ export async function GET(request: NextRequest) {
|
||||
return {
|
||||
id: log.id,
|
||||
workflowId: log.workflowId,
|
||||
executionId: log.executionId,
|
||||
executionId: params.details === 'full' ? log.executionId : undefined,
|
||||
level: log.level,
|
||||
message: log.message,
|
||||
duration: log.totalDurationMs ? `${log.totalDurationMs}ms` : null,
|
||||
trigger: log.trigger,
|
||||
createdAt: log.startedAt.toISOString(),
|
||||
files: log.files || undefined,
|
||||
workflow: params.includeWorkflow ? workflow : undefined,
|
||||
metadata: {
|
||||
totalDuration: log.totalDurationMs,
|
||||
cost: costSummary,
|
||||
blockStats: {
|
||||
total: log.blockCount,
|
||||
success: log.successCount,
|
||||
error: log.errorCount,
|
||||
skipped: log.skippedCount,
|
||||
},
|
||||
traceSpans,
|
||||
blockExecutions,
|
||||
enhanced: true,
|
||||
},
|
||||
files: params.details === 'full' ? log.files || undefined : undefined,
|
||||
workflow: workflowSummary,
|
||||
executionData:
|
||||
params.details === 'full'
|
||||
? {
|
||||
totalDuration: log.totalDurationMs,
|
||||
traceSpans,
|
||||
blockExecutions,
|
||||
enhanced: true,
|
||||
}
|
||||
: undefined,
|
||||
cost:
|
||||
params.details === 'full'
|
||||
? (costSummary as any)
|
||||
: { total: (costSummary as any)?.total || 0 },
|
||||
}
|
||||
})
|
||||
|
||||
// Include block execution data if requested
|
||||
if (params.includeBlocks) {
|
||||
// Block executions are now extracted from stored trace spans in metadata
|
||||
const blockLogsByExecution: Record<string, any[]> = {}
|
||||
|
||||
logs.forEach((log) => {
|
||||
const storedTraceSpans = (log.metadata as any)?.traceSpans
|
||||
if (storedTraceSpans && Array.isArray(storedTraceSpans)) {
|
||||
blockLogsByExecution[log.executionId] =
|
||||
extractBlockExecutionsFromTraceSpans(storedTraceSpans)
|
||||
} else {
|
||||
blockLogsByExecution[log.executionId] = []
|
||||
}
|
||||
})
|
||||
|
||||
// Add block logs to metadata
|
||||
const logsWithBlocks = enhancedLogs.map((log) => ({
|
||||
...log,
|
||||
metadata: {
|
||||
...log.metadata,
|
||||
blockExecutions: blockLogsByExecution[log.executionId] || [],
|
||||
},
|
||||
}))
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
data: logsWithBlocks,
|
||||
total: Number(count),
|
||||
page: Math.floor(params.offset / params.limit) + 1,
|
||||
pageSize: params.limit,
|
||||
totalPages: Math.ceil(Number(count) / params.limit),
|
||||
},
|
||||
{ status: 200 }
|
||||
)
|
||||
}
|
||||
|
||||
// Return basic logs
|
||||
return NextResponse.json(
|
||||
{
|
||||
data: enhancedLogs,
|
||||
|
||||
@@ -39,6 +39,8 @@ export async function POST(request: NextRequest) {
|
||||
stream,
|
||||
messages,
|
||||
environmentVariables,
|
||||
reasoningEffort,
|
||||
verbosity,
|
||||
} = body
|
||||
|
||||
logger.info(`[${requestId}] Provider request details`, {
|
||||
@@ -58,6 +60,8 @@ export async function POST(request: NextRequest) {
|
||||
messageCount: messages?.length || 0,
|
||||
hasEnvironmentVariables:
|
||||
!!environmentVariables && Object.keys(environmentVariables).length > 0,
|
||||
reasoningEffort,
|
||||
verbosity,
|
||||
})
|
||||
|
||||
let finalApiKey: string
|
||||
@@ -99,6 +103,8 @@ export async function POST(request: NextRequest) {
|
||||
stream,
|
||||
messages,
|
||||
environmentVariables,
|
||||
reasoningEffort,
|
||||
verbosity,
|
||||
})
|
||||
|
||||
const executionTime = Date.now() - startTime
|
||||
|
||||
@@ -80,7 +80,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
workspaceId: workspaceId,
|
||||
name: `${templateData.name} (copy)`,
|
||||
description: templateData.description,
|
||||
state: templateData.state,
|
||||
color: templateData.color,
|
||||
userId: session.user.id,
|
||||
createdAt: now,
|
||||
@@ -158,9 +157,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
}))
|
||||
}
|
||||
|
||||
// Update the workflow with the corrected state
|
||||
await tx.update(workflow).set({ state: updatedState }).where(eq(workflow.id, newWorkflowId))
|
||||
|
||||
// Insert blocks and edges
|
||||
if (blockEntries.length > 0) {
|
||||
await tx.insert(workflowBlocks).values(blockEntries)
|
||||
|
||||
@@ -45,7 +45,7 @@ export async function GET(request: NextRequest) {
|
||||
// Fetch the file from Google Drive API
|
||||
logger.info(`[${requestId}] Fetching file ${fileId} from Google Drive API`)
|
||||
const response = await fetch(
|
||||
`https://www.googleapis.com/drive/v3/files/${fileId}?fields=id,name,mimeType,iconLink,webViewLink,thumbnailLink,createdTime,modifiedTime,size,owners,exportLinks`,
|
||||
`https://www.googleapis.com/drive/v3/files/${fileId}?fields=id,name,mimeType,iconLink,webViewLink,thumbnailLink,createdTime,modifiedTime,size,owners,exportLinks,shortcutDetails&supportsAllDrives=true`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
@@ -77,6 +77,34 @@ export async function GET(request: NextRequest) {
|
||||
'application/vnd.google-apps.presentation': 'application/pdf', // Google Slides to PDF
|
||||
}
|
||||
|
||||
// Resolve shortcuts transparently for UI stability
|
||||
if (
|
||||
file.mimeType === 'application/vnd.google-apps.shortcut' &&
|
||||
file.shortcutDetails?.targetId
|
||||
) {
|
||||
const targetId = file.shortcutDetails.targetId
|
||||
const shortcutResp = await fetch(
|
||||
`https://www.googleapis.com/drive/v3/files/${targetId}?fields=id,name,mimeType,iconLink,webViewLink,thumbnailLink,createdTime,modifiedTime,size,owners,exportLinks&supportsAllDrives=true`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
}
|
||||
)
|
||||
if (shortcutResp.ok) {
|
||||
const targetFile = await shortcutResp.json()
|
||||
file.id = targetFile.id
|
||||
file.name = targetFile.name
|
||||
file.mimeType = targetFile.mimeType
|
||||
file.iconLink = targetFile.iconLink
|
||||
file.webViewLink = targetFile.webViewLink
|
||||
file.thumbnailLink = targetFile.thumbnailLink
|
||||
file.createdTime = targetFile.createdTime
|
||||
file.modifiedTime = targetFile.modifiedTime
|
||||
file.size = targetFile.size
|
||||
file.owners = targetFile.owners
|
||||
file.exportLinks = targetFile.exportLinks
|
||||
}
|
||||
}
|
||||
|
||||
// If the file is a Google Docs, Sheets, or Slides file, we need to provide the export link
|
||||
if (file.mimeType.startsWith('application/vnd.google-apps.')) {
|
||||
const format = exportFormats[file.mimeType] || 'application/pdf'
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
import { db } from '@/db'
|
||||
import { account } from '@/db/schema'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -32,64 +30,48 @@ export async function GET(request: NextRequest) {
|
||||
const credentialId = searchParams.get('credentialId')
|
||||
const mimeType = searchParams.get('mimeType')
|
||||
const query = searchParams.get('query') || ''
|
||||
const folderId = searchParams.get('folderId') || searchParams.get('parentId') || ''
|
||||
const workflowId = searchParams.get('workflowId') || undefined
|
||||
|
||||
if (!credentialId) {
|
||||
logger.warn(`[${requestId}] Missing credential ID`)
|
||||
return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Get the credential from the database
|
||||
const credentials = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
|
||||
|
||||
if (!credentials.length) {
|
||||
logger.warn(`[${requestId}] Credential not found`, { credentialId })
|
||||
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const credential = credentials[0]
|
||||
|
||||
// Check if the credential belongs to the user
|
||||
if (credential.userId !== session.user.id) {
|
||||
logger.warn(`[${requestId}] Unauthorized credential access attempt`, {
|
||||
credentialUserId: credential.userId,
|
||||
requestUserId: session.user.id,
|
||||
})
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
|
||||
// Authorize use of the credential (supports collaborator credentials via workflow)
|
||||
const authz = await authorizeCredentialUse(request, { credentialId: credentialId!, workflowId })
|
||||
if (!authz.ok || !authz.credentialOwnerUserId) {
|
||||
logger.warn(`[${requestId}] Unauthorized credential access attempt`, authz)
|
||||
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Refresh access token if needed using the utility function
|
||||
const accessToken = await refreshAccessTokenIfNeeded(credentialId, session.user.id, requestId)
|
||||
const accessToken = await refreshAccessTokenIfNeeded(
|
||||
credentialId!,
|
||||
authz.credentialOwnerUserId,
|
||||
requestId
|
||||
)
|
||||
|
||||
if (!accessToken) {
|
||||
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Build the query parameters for Google Drive API
|
||||
let queryParams = 'trashed=false'
|
||||
|
||||
// Add mimeType filter if provided
|
||||
// Build Drive 'q' expression safely
|
||||
const qParts: string[] = ['trashed = false']
|
||||
if (folderId) {
|
||||
qParts.push(`'${folderId.replace(/'/g, "\\'")}' in parents`)
|
||||
}
|
||||
if (mimeType) {
|
||||
// For Google Drive API, we need to use 'q' parameter for mimeType filtering
|
||||
// Instead of using the mimeType parameter directly, we'll add it to the query
|
||||
if (queryParams.includes('q=')) {
|
||||
queryParams += ` and mimeType='${mimeType}'`
|
||||
} else {
|
||||
queryParams += `&q=mimeType='${mimeType}'`
|
||||
}
|
||||
qParts.push(`mimeType = '${mimeType.replace(/'/g, "\\'")}'`)
|
||||
}
|
||||
|
||||
// Add search query if provided
|
||||
if (query) {
|
||||
if (queryParams.includes('q=')) {
|
||||
queryParams += ` and name contains '${query}'`
|
||||
} else {
|
||||
queryParams += `&q=name contains '${query}'`
|
||||
}
|
||||
qParts.push(`name contains '${query.replace(/'/g, "\\'")}'`)
|
||||
}
|
||||
const q = encodeURIComponent(qParts.join(' and '))
|
||||
|
||||
// Fetch files from Google Drive API
|
||||
// Fetch files from Google Drive API with shared drives support
|
||||
const response = await fetch(
|
||||
`https://www.googleapis.com/drive/v3/files?${queryParams}&fields=files(id,name,mimeType,iconLink,webViewLink,thumbnailLink,createdTime,modifiedTime,size,owners)`,
|
||||
`https://www.googleapis.com/drive/v3/files?q=${q}&supportsAllDrives=true&includeItemsFromAllDrives=true&spaces=drive&fields=files(id,name,mimeType,iconLink,webViewLink,thumbnailLink,createdTime,modifiedTime,size,owners,parents)`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { Logger } from '@/lib/logs/console/logger'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getJiraCloudId } from '@/tools/jira/utils'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = new Logger('JiraIssueAPI')
|
||||
const logger = createLogger('JiraIssueAPI')
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { Logger } from '@/lib/logs/console/logger'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getJiraCloudId } from '@/tools/jira/utils'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = new Logger('JiraIssuesAPI')
|
||||
const logger = createLogger('JiraIssuesAPI')
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { Logger } from '@/lib/logs/console/logger'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getJiraCloudId } from '@/tools/jira/utils'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = new Logger('JiraProjectsAPI')
|
||||
const logger = createLogger('JiraProjectsAPI')
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { Logger } from '@/lib/logs/console/logger'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getJiraCloudId } from '@/tools/jira/utils'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = new Logger('JiraUpdateAPI')
|
||||
const logger = createLogger('JiraUpdateAPI')
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
try {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { Logger } from '@/lib/logs/console/logger'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getJiraCloudId } from '@/tools/jira/utils'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = new Logger('JiraWriteAPI')
|
||||
const logger = createLogger('JiraWriteAPI')
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
|
||||
120
apps/sim/app/api/users/me/profile/route.ts
Normal file
120
apps/sim/app/api/users/me/profile/route.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { db } from '@/db'
|
||||
import { user } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('UpdateUserProfileAPI')
|
||||
|
||||
// Schema for updating user profile
|
||||
const UpdateProfileSchema = z
|
||||
.object({
|
||||
name: z.string().min(1, 'Name is required').optional(),
|
||||
})
|
||||
.refine((data) => data.name !== undefined, {
|
||||
message: 'Name field must be provided',
|
||||
})
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export async function PATCH(request: NextRequest) {
|
||||
const requestId = crypto.randomUUID().slice(0, 8)
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
logger.warn(`[${requestId}] Unauthorized profile update attempt`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const userId = session.user.id
|
||||
const body = await request.json()
|
||||
|
||||
const validatedData = UpdateProfileSchema.parse(body)
|
||||
|
||||
// Build update object
|
||||
const updateData: any = { updatedAt: new Date() }
|
||||
if (validatedData.name !== undefined) updateData.name = validatedData.name
|
||||
|
||||
// Update user profile
|
||||
const [updatedUser] = await db
|
||||
.update(user)
|
||||
.set(updateData)
|
||||
.where(eq(user.id, userId))
|
||||
.returning()
|
||||
|
||||
if (!updatedUser) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] User profile updated`, {
|
||||
userId,
|
||||
updatedFields: Object.keys(validatedData),
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
user: {
|
||||
id: updatedUser.id,
|
||||
name: updatedUser.name,
|
||||
email: updatedUser.email,
|
||||
image: updatedUser.image,
|
||||
},
|
||||
})
|
||||
} catch (error: any) {
|
||||
if (error instanceof z.ZodError) {
|
||||
logger.warn(`[${requestId}] Invalid profile data`, {
|
||||
errors: error.errors,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid profile data', details: error.errors },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Profile update error`, error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// GET endpoint to fetch current user profile
|
||||
export async function GET() {
|
||||
const requestId = crypto.randomUUID().slice(0, 8)
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
logger.warn(`[${requestId}] Unauthorized profile fetch attempt`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const userId = session.user.id
|
||||
|
||||
const [userRecord] = await db
|
||||
.select({
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
image: user.image,
|
||||
emailVerified: user.emailVerified,
|
||||
})
|
||||
.from(user)
|
||||
.where(eq(user.id, userId))
|
||||
.limit(1)
|
||||
|
||||
if (!userRecord) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
user: userRecord,
|
||||
})
|
||||
} catch (error: any) {
|
||||
logger.error(`[${requestId}] Profile fetch error`, error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { unstable_noStore as noStore } from 'next/cache'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import OpenAI from 'openai'
|
||||
import OpenAI, { AzureOpenAI } from 'openai'
|
||||
import { env } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
@@ -10,14 +10,32 @@ export const maxDuration = 60
|
||||
|
||||
const logger = createLogger('WandGenerateAPI')
|
||||
|
||||
const openai = env.OPENAI_API_KEY
|
||||
? new OpenAI({
|
||||
apiKey: env.OPENAI_API_KEY,
|
||||
})
|
||||
: null
|
||||
const azureApiKey = env.AZURE_OPENAI_API_KEY
|
||||
const azureEndpoint = env.AZURE_OPENAI_ENDPOINT
|
||||
const azureApiVersion = env.AZURE_OPENAI_API_VERSION
|
||||
const wandModelName = env.WAND_OPENAI_MODEL_NAME || 'gpt-4o'
|
||||
const openaiApiKey = env.OPENAI_API_KEY
|
||||
|
||||
if (!env.OPENAI_API_KEY) {
|
||||
logger.warn('OPENAI_API_KEY not found. Wand generation API will not function.')
|
||||
const useWandAzure = azureApiKey && azureEndpoint && azureApiVersion
|
||||
|
||||
const client = useWandAzure
|
||||
? new AzureOpenAI({
|
||||
apiKey: azureApiKey,
|
||||
apiVersion: azureApiVersion,
|
||||
endpoint: azureEndpoint,
|
||||
})
|
||||
: openaiApiKey
|
||||
? new OpenAI({
|
||||
apiKey: openaiApiKey,
|
||||
})
|
||||
: null
|
||||
|
||||
if (!useWandAzure && !openaiApiKey) {
|
||||
logger.warn(
|
||||
'Neither Azure OpenAI nor OpenAI API key found. Wand generation API will not function.'
|
||||
)
|
||||
} else {
|
||||
logger.info(`Using ${useWandAzure ? 'Azure OpenAI' : 'OpenAI'} for wand generation`)
|
||||
}
|
||||
|
||||
interface ChatMessage {
|
||||
@@ -32,14 +50,12 @@ interface RequestBody {
|
||||
history?: ChatMessage[]
|
||||
}
|
||||
|
||||
// The endpoint is now generic - system prompts come from wand configs
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const requestId = crypto.randomUUID().slice(0, 8)
|
||||
logger.info(`[${requestId}] Received wand generation request`)
|
||||
|
||||
if (!openai) {
|
||||
logger.error(`[${requestId}] OpenAI client not initialized. Missing API key.`)
|
||||
if (!client) {
|
||||
logger.error(`[${requestId}] AI client not initialized. Missing API key.`)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Wand generation service is not configured.' },
|
||||
{ status: 503 }
|
||||
@@ -74,16 +90,19 @@ export async function POST(req: NextRequest) {
|
||||
// Add the current user prompt
|
||||
messages.push({ role: 'user', content: prompt })
|
||||
|
||||
logger.debug(`[${requestId}] Calling OpenAI API for wand generation`, {
|
||||
stream,
|
||||
historyLength: history.length,
|
||||
})
|
||||
logger.debug(
|
||||
`[${requestId}] Calling ${useWandAzure ? 'Azure OpenAI' : 'OpenAI'} API for wand generation`,
|
||||
{
|
||||
stream,
|
||||
historyLength: history.length,
|
||||
}
|
||||
)
|
||||
|
||||
// For streaming responses
|
||||
if (stream) {
|
||||
try {
|
||||
const streamCompletion = await openai?.chat.completions.create({
|
||||
model: 'gpt-4o',
|
||||
const streamCompletion = await client.chat.completions.create({
|
||||
model: useWandAzure ? wandModelName : 'gpt-4o',
|
||||
messages: messages,
|
||||
temperature: 0.3,
|
||||
max_tokens: 10000,
|
||||
@@ -141,8 +160,8 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
|
||||
// For non-streaming responses
|
||||
const completion = await openai?.chat.completions.create({
|
||||
model: 'gpt-4o',
|
||||
const completion = await client.chat.completions.create({
|
||||
model: useWandAzure ? wandModelName : 'gpt-4o',
|
||||
messages: messages,
|
||||
temperature: 0.3,
|
||||
max_tokens: 10000,
|
||||
@@ -151,9 +170,11 @@ export async function POST(req: NextRequest) {
|
||||
const generatedContent = completion.choices[0]?.message?.content?.trim()
|
||||
|
||||
if (!generatedContent) {
|
||||
logger.error(`[${requestId}] OpenAI response was empty or invalid.`)
|
||||
logger.error(
|
||||
`[${requestId}] ${useWandAzure ? 'Azure OpenAI' : 'OpenAI'} response was empty or invalid.`
|
||||
)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to generate content. OpenAI response was empty.' },
|
||||
{ success: false, error: 'Failed to generate content. AI response was empty.' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
@@ -171,7 +192,9 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
if (error instanceof OpenAI.APIError) {
|
||||
status = error.status || 500
|
||||
logger.error(`[${requestId}] OpenAI API Error: ${status} - ${error.message}`)
|
||||
logger.error(
|
||||
`[${requestId}] ${useWandAzure ? 'Azure OpenAI' : 'OpenAI'} API Error: ${status} - ${error.message}`
|
||||
)
|
||||
|
||||
if (status === 401) {
|
||||
clientErrorMessage = 'Authentication failed. Please check your API key configuration.'
|
||||
@@ -181,6 +204,10 @@ export async function POST(req: NextRequest) {
|
||||
clientErrorMessage =
|
||||
'The wand generation service is currently unavailable. Please try again later.'
|
||||
}
|
||||
} else if (useWandAzure && error.message?.includes('DeploymentNotFound')) {
|
||||
clientErrorMessage =
|
||||
'Azure OpenAI deployment not found. Please check your model deployment configuration.'
|
||||
status = 404
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { nanoid } from 'nanoid'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { verifyCronAuth } from '@/lib/auth/internal'
|
||||
import { Logger } from '@/lib/logs/console/logger'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { acquireLock, releaseLock } from '@/lib/redis'
|
||||
import { pollGmailWebhooks } from '@/lib/webhooks/gmail-polling-service'
|
||||
|
||||
const logger = new Logger('GmailPollingAPI')
|
||||
const logger = createLogger('GmailPollingAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const maxDuration = 180 // Allow up to 3 minutes for polling to complete
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { nanoid } from 'nanoid'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { verifyCronAuth } from '@/lib/auth/internal'
|
||||
import { Logger } from '@/lib/logs/console/logger'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { acquireLock, releaseLock } from '@/lib/redis'
|
||||
import { pollOutlookWebhooks } from '@/lib/webhooks/outlook-polling-service'
|
||||
|
||||
const logger = new Logger('OutlookPollingAPI')
|
||||
const logger = createLogger('OutlookPollingAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const maxDuration = 180 // Allow up to 3 minutes for polling to complete
|
||||
|
||||
@@ -329,7 +329,7 @@ export async function POST(request: NextRequest) {
|
||||
logger.info(`[${requestId}] Gmail provider detected. Setting up Gmail webhook configuration.`)
|
||||
try {
|
||||
const { configureGmailPolling } = await import('@/lib/webhooks/utils')
|
||||
// Use workflow owner for OAuth lookups to support collaborator-saved credentials
|
||||
// Pass workflow owner for backward-compat fallback (utils prefers credentialId if present)
|
||||
const success = await configureGmailPolling(workflowRecord.userId, savedWebhook, requestId)
|
||||
|
||||
if (!success) {
|
||||
@@ -364,7 +364,7 @@ export async function POST(request: NextRequest) {
|
||||
)
|
||||
try {
|
||||
const { configureOutlookPolling } = await import('@/lib/webhooks/utils')
|
||||
// Use workflow owner for OAuth lookups to support collaborator-saved credentials
|
||||
// Pass workflow owner for backward-compat fallback (utils prefers credentialId if present)
|
||||
const success = await configureOutlookPolling(
|
||||
workflowRecord.userId,
|
||||
savedWebhook,
|
||||
|
||||
@@ -7,7 +7,6 @@ import { NextRequest } from 'next/server'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createMockRequest, mockExecutionDependencies } from '@/app/api/__test-utils__/utils'
|
||||
|
||||
// Define mock functions at the top level to be used in mocks
|
||||
const hasProcessedMessageMock = vi.fn().mockResolvedValue(false)
|
||||
const markMessageAsProcessedMock = vi.fn().mockResolvedValue(true)
|
||||
const closeRedisConnectionMock = vi.fn().mockResolvedValue(undefined)
|
||||
@@ -33,7 +32,6 @@ const executeMock = vi.fn().mockResolvedValue({
|
||||
},
|
||||
})
|
||||
|
||||
// Mock the DB schema objects
|
||||
const webhookMock = {
|
||||
id: 'webhook-id-column',
|
||||
path: 'path-column',
|
||||
@@ -43,10 +41,6 @@ const webhookMock = {
|
||||
}
|
||||
const workflowMock = { id: 'workflow-id-column' }
|
||||
|
||||
// Mock global timers
|
||||
vi.useFakeTimers()
|
||||
|
||||
// Mock modules at file scope before any tests
|
||||
vi.mock('@/lib/redis', () => ({
|
||||
hasProcessedMessage: hasProcessedMessageMock,
|
||||
markMessageAsProcessed: markMessageAsProcessedMock,
|
||||
@@ -77,19 +71,6 @@ vi.mock('@/executor', () => ({
|
||||
})),
|
||||
}))
|
||||
|
||||
// Mock setTimeout and other timer functions
|
||||
vi.mock('timers', () => {
|
||||
return {
|
||||
setTimeout: (callback: any) => {
|
||||
// Immediately invoke the callback
|
||||
callback()
|
||||
// Return a fake timer id
|
||||
return 123
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
// Mock the database and schema
|
||||
vi.mock('@/db', () => {
|
||||
const dbMock = {
|
||||
select: vi.fn().mockImplementation((columns) => ({
|
||||
@@ -128,11 +109,9 @@ describe('Webhook Trigger API Route', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
vi.resetAllMocks()
|
||||
vi.clearAllTimers()
|
||||
|
||||
mockExecutionDependencies()
|
||||
|
||||
// Mock services/queue for rate limiting
|
||||
vi.doMock('@/services/queue', () => ({
|
||||
RateLimiter: vi.fn().mockImplementation(() => ({
|
||||
checkRateLimit: vi.fn().mockResolvedValue({
|
||||
@@ -284,10 +263,340 @@ describe('Webhook Trigger API Route', () => {
|
||||
expect(text).toMatch(/not found/i) // Response should contain "not found" message
|
||||
})
|
||||
|
||||
/**
|
||||
* Test Slack-specific webhook handling
|
||||
* Verifies that Slack signature verification is performed
|
||||
*/
|
||||
// TODO: Fix failing test - returns 500 instead of 200
|
||||
// it('should handle Slack webhooks with signature verification', async () => { ... })
|
||||
describe('Generic Webhook Authentication', () => {
|
||||
const setupGenericWebhook = async (config: Record<string, any>) => {
|
||||
const { db } = await import('@/db')
|
||||
const limitMock = vi.fn().mockReturnValue([
|
||||
{
|
||||
webhook: {
|
||||
id: 'generic-webhook-id',
|
||||
provider: 'generic',
|
||||
path: 'test-path',
|
||||
isActive: true,
|
||||
providerConfig: config,
|
||||
workflowId: 'test-workflow-id',
|
||||
},
|
||||
workflow: {
|
||||
id: 'test-workflow-id',
|
||||
userId: 'test-user-id',
|
||||
name: 'Test Workflow',
|
||||
},
|
||||
},
|
||||
])
|
||||
const whereMock = vi.fn().mockReturnValue({ limit: limitMock })
|
||||
const innerJoinMock = vi.fn().mockReturnValue({ where: whereMock })
|
||||
const fromMock = vi.fn().mockReturnValue({ innerJoin: innerJoinMock })
|
||||
|
||||
const subscriptionLimitMock = vi.fn().mockReturnValue([{ plan: 'pro' }])
|
||||
const subscriptionWhereMock = vi.fn().mockReturnValue({ limit: subscriptionLimitMock })
|
||||
const subscriptionFromMock = vi.fn().mockReturnValue({ where: subscriptionWhereMock })
|
||||
|
||||
// @ts-ignore - mocking the query chain
|
||||
db.select.mockImplementation((columns: any) => {
|
||||
if (columns.plan) {
|
||||
return { from: subscriptionFromMock }
|
||||
}
|
||||
return { from: fromMock }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Test generic webhook without authentication (default behavior)
|
||||
*/
|
||||
it('should process generic webhook without authentication', async () => {
|
||||
await setupGenericWebhook({ requireAuth: false })
|
||||
|
||||
const req = createMockRequest('POST', { event: 'test', id: 'test-123' })
|
||||
const params = Promise.resolve({ path: 'test-path' })
|
||||
|
||||
vi.doMock('@trigger.dev/sdk', () => ({
|
||||
tasks: {
|
||||
trigger: vi.fn().mockResolvedValue({ id: 'mock-task-id' }),
|
||||
},
|
||||
}))
|
||||
|
||||
const { POST } = await import('@/app/api/webhooks/trigger/[path]/route')
|
||||
const response = await POST(req, { params })
|
||||
|
||||
// Authentication passed if we don't get 401
|
||||
expect(response.status).not.toBe(401)
|
||||
})
|
||||
|
||||
/**
|
||||
* Test generic webhook with Bearer token authentication (no custom header)
|
||||
*/
|
||||
it('should authenticate with Bearer token when no custom header is configured', async () => {
|
||||
await setupGenericWebhook({
|
||||
requireAuth: true,
|
||||
token: 'test-token-123',
|
||||
// No secretHeaderName - should default to Bearer
|
||||
})
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: 'Bearer test-token-123',
|
||||
}
|
||||
const req = createMockRequest('POST', { event: 'bearer.test' }, headers)
|
||||
const params = Promise.resolve({ path: 'test-path' })
|
||||
|
||||
vi.doMock('@trigger.dev/sdk', () => ({
|
||||
tasks: {
|
||||
trigger: vi.fn().mockResolvedValue({ id: 'mock-task-id' }),
|
||||
},
|
||||
}))
|
||||
|
||||
const { POST } = await import('@/app/api/webhooks/trigger/[path]/route')
|
||||
const response = await POST(req, { params })
|
||||
|
||||
// Authentication passed if we don't get 401
|
||||
expect(response.status).not.toBe(401)
|
||||
})
|
||||
|
||||
/**
|
||||
* Test generic webhook with custom header authentication
|
||||
*/
|
||||
it('should authenticate with custom header when configured', async () => {
|
||||
await setupGenericWebhook({
|
||||
requireAuth: true,
|
||||
token: 'secret-token-456',
|
||||
secretHeaderName: 'X-Custom-Auth',
|
||||
})
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Custom-Auth': 'secret-token-456',
|
||||
}
|
||||
const req = createMockRequest('POST', { event: 'custom.header.test' }, headers)
|
||||
const params = Promise.resolve({ path: 'test-path' })
|
||||
|
||||
vi.doMock('@trigger.dev/sdk', () => ({
|
||||
tasks: {
|
||||
trigger: vi.fn().mockResolvedValue({ id: 'mock-task-id' }),
|
||||
},
|
||||
}))
|
||||
|
||||
const { POST } = await import('@/app/api/webhooks/trigger/[path]/route')
|
||||
const response = await POST(req, { params })
|
||||
|
||||
// Authentication passed if we don't get 401
|
||||
expect(response.status).not.toBe(401)
|
||||
})
|
||||
|
||||
/**
|
||||
* Test case insensitive Bearer token authentication
|
||||
*/
|
||||
it('should handle case insensitive Bearer token authentication', async () => {
|
||||
await setupGenericWebhook({
|
||||
requireAuth: true,
|
||||
token: 'case-test-token',
|
||||
})
|
||||
|
||||
vi.doMock('@trigger.dev/sdk', () => ({
|
||||
tasks: {
|
||||
trigger: vi.fn().mockResolvedValue({ id: 'mock-task-id' }),
|
||||
},
|
||||
}))
|
||||
|
||||
const testCases = [
|
||||
'Bearer case-test-token',
|
||||
'bearer case-test-token',
|
||||
'BEARER case-test-token',
|
||||
'BeArEr case-test-token',
|
||||
]
|
||||
|
||||
for (const authHeader of testCases) {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: authHeader,
|
||||
}
|
||||
const req = createMockRequest('POST', { event: 'case.test' }, headers)
|
||||
const params = Promise.resolve({ path: 'test-path' })
|
||||
|
||||
const { POST } = await import('@/app/api/webhooks/trigger/[path]/route')
|
||||
const response = await POST(req, { params })
|
||||
|
||||
// Authentication passed if we don't get 401
|
||||
expect(response.status).not.toBe(401)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Test case insensitive custom header authentication
|
||||
*/
|
||||
it('should handle case insensitive custom header authentication', async () => {
|
||||
await setupGenericWebhook({
|
||||
requireAuth: true,
|
||||
token: 'custom-token-789',
|
||||
secretHeaderName: 'X-Secret-Key',
|
||||
})
|
||||
|
||||
vi.doMock('@trigger.dev/sdk', () => ({
|
||||
tasks: {
|
||||
trigger: vi.fn().mockResolvedValue({ id: 'mock-task-id' }),
|
||||
},
|
||||
}))
|
||||
|
||||
const testCases = ['X-Secret-Key', 'x-secret-key', 'X-SECRET-KEY', 'x-Secret-Key']
|
||||
|
||||
for (const headerName of testCases) {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
[headerName]: 'custom-token-789',
|
||||
}
|
||||
const req = createMockRequest('POST', { event: 'custom.case.test' }, headers)
|
||||
const params = Promise.resolve({ path: 'test-path' })
|
||||
|
||||
const { POST } = await import('@/app/api/webhooks/trigger/[path]/route')
|
||||
const response = await POST(req, { params })
|
||||
|
||||
// Authentication passed if we don't get 401
|
||||
expect(response.status).not.toBe(401)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Test rejection of wrong Bearer token
|
||||
*/
|
||||
it('should reject wrong Bearer token', async () => {
|
||||
await setupGenericWebhook({
|
||||
requireAuth: true,
|
||||
token: 'correct-token',
|
||||
})
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: 'Bearer wrong-token',
|
||||
}
|
||||
const req = createMockRequest('POST', { event: 'wrong.token.test' }, headers)
|
||||
const params = Promise.resolve({ path: 'test-path' })
|
||||
|
||||
const { POST } = await import('@/app/api/webhooks/trigger/[path]/route')
|
||||
const response = await POST(req, { params })
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(await response.text()).toContain('Unauthorized - Invalid authentication token')
|
||||
expect(processWebhookMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
/**
|
||||
* Test rejection of wrong custom header token
|
||||
*/
|
||||
it('should reject wrong custom header token', async () => {
|
||||
await setupGenericWebhook({
|
||||
requireAuth: true,
|
||||
token: 'correct-custom-token',
|
||||
secretHeaderName: 'X-Auth-Key',
|
||||
})
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Auth-Key': 'wrong-custom-token',
|
||||
}
|
||||
const req = createMockRequest('POST', { event: 'wrong.custom.test' }, headers)
|
||||
const params = Promise.resolve({ path: 'test-path' })
|
||||
|
||||
const { POST } = await import('@/app/api/webhooks/trigger/[path]/route')
|
||||
const response = await POST(req, { params })
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(await response.text()).toContain('Unauthorized - Invalid authentication token')
|
||||
expect(processWebhookMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
/**
|
||||
* Test rejection of missing authentication
|
||||
*/
|
||||
it('should reject missing authentication when required', async () => {
|
||||
await setupGenericWebhook({
|
||||
requireAuth: true,
|
||||
token: 'required-token',
|
||||
})
|
||||
|
||||
const req = createMockRequest('POST', { event: 'no.auth.test' })
|
||||
const params = Promise.resolve({ path: 'test-path' })
|
||||
|
||||
const { POST } = await import('@/app/api/webhooks/trigger/[path]/route')
|
||||
const response = await POST(req, { params })
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(await response.text()).toContain('Unauthorized - Invalid authentication token')
|
||||
expect(processWebhookMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
/**
|
||||
* Test exclusivity - Bearer token should be rejected when custom header is configured
|
||||
*/
|
||||
it('should reject Bearer token when custom header is configured', async () => {
|
||||
await setupGenericWebhook({
|
||||
requireAuth: true,
|
||||
token: 'exclusive-token',
|
||||
secretHeaderName: 'X-Only-Header',
|
||||
})
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: 'Bearer exclusive-token', // Correct token but wrong header type
|
||||
}
|
||||
const req = createMockRequest('POST', { event: 'exclusivity.test' }, headers)
|
||||
const params = Promise.resolve({ path: 'test-path' })
|
||||
|
||||
const { POST } = await import('@/app/api/webhooks/trigger/[path]/route')
|
||||
const response = await POST(req, { params })
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(await response.text()).toContain('Unauthorized - Invalid authentication token')
|
||||
expect(processWebhookMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
/**
|
||||
* Test wrong custom header name is rejected
|
||||
*/
|
||||
it('should reject wrong custom header name', async () => {
|
||||
await setupGenericWebhook({
|
||||
requireAuth: true,
|
||||
token: 'correct-token',
|
||||
secretHeaderName: 'X-Expected-Header',
|
||||
})
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Wrong-Header': 'correct-token', // Correct token but wrong header name
|
||||
}
|
||||
const req = createMockRequest('POST', { event: 'wrong.header.name.test' }, headers)
|
||||
const params = Promise.resolve({ path: 'test-path' })
|
||||
|
||||
const { POST } = await import('@/app/api/webhooks/trigger/[path]/route')
|
||||
const response = await POST(req, { params })
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(await response.text()).toContain('Unauthorized - Invalid authentication token')
|
||||
expect(processWebhookMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
/**
|
||||
* Test authentication required but no token configured
|
||||
*/
|
||||
it('should reject when auth is required but no token is configured', async () => {
|
||||
await setupGenericWebhook({
|
||||
requireAuth: true,
|
||||
// No token configured
|
||||
})
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: 'Bearer any-token',
|
||||
}
|
||||
const req = createMockRequest('POST', { event: 'no.token.config.test' }, headers)
|
||||
const params = Promise.resolve({ path: 'test-path' })
|
||||
|
||||
const { POST } = await import('@/app/api/webhooks/trigger/[path]/route')
|
||||
const response = await POST(req, { params })
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(await response.text()).toContain(
|
||||
'Unauthorized - Authentication required but not configured'
|
||||
)
|
||||
expect(processWebhookMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { tasks } from '@trigger.dev/sdk/v3'
|
||||
import { tasks } from '@trigger.dev/sdk'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { checkServerSideUsageLimits } from '@/lib/billing'
|
||||
@@ -196,6 +196,53 @@ export async function POST(
|
||||
}
|
||||
}
|
||||
|
||||
// Handle generic webhook authentication if enabled
|
||||
if (foundWebhook.provider === 'generic') {
|
||||
const providerConfig = (foundWebhook.providerConfig as Record<string, any>) || {}
|
||||
|
||||
if (providerConfig.requireAuth) {
|
||||
const configToken = providerConfig.token
|
||||
const secretHeaderName = providerConfig.secretHeaderName
|
||||
|
||||
// --- Token Validation ---
|
||||
if (configToken) {
|
||||
let isTokenValid = false
|
||||
|
||||
if (secretHeaderName) {
|
||||
// Check custom header (headers are case-insensitive)
|
||||
const headerValue = request.headers.get(secretHeaderName.toLowerCase())
|
||||
if (headerValue === configToken) {
|
||||
isTokenValid = true
|
||||
}
|
||||
} else {
|
||||
// Check standard Authorization header (case-insensitive Bearer keyword)
|
||||
const authHeader = request.headers.get('authorization')
|
||||
|
||||
// Case-insensitive comparison for "Bearer" keyword
|
||||
if (authHeader?.toLowerCase().startsWith('bearer ')) {
|
||||
const token = authHeader.substring(7) // Remove "Bearer " (7 characters)
|
||||
if (token === configToken) {
|
||||
isTokenValid = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!isTokenValid) {
|
||||
const expectedHeader = secretHeaderName || 'Authorization: Bearer TOKEN'
|
||||
logger.warn(
|
||||
`[${requestId}] Generic webhook authentication failed. Expected header: ${expectedHeader}`
|
||||
)
|
||||
return new NextResponse('Unauthorized - Invalid authentication token', { status: 401 })
|
||||
}
|
||||
} else {
|
||||
logger.warn(`[${requestId}] Generic webhook requires auth but no token configured`)
|
||||
return new NextResponse('Unauthorized - Authentication required but not configured', {
|
||||
status: 401,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- PHASE 3: Rate limiting for webhook execution ---
|
||||
try {
|
||||
// Get user subscription for rate limiting
|
||||
|
||||
@@ -17,12 +17,6 @@ export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('AutoLayoutAPI')
|
||||
|
||||
// Check API key configuration at module level
|
||||
const SIM_AGENT_API_KEY = process.env.SIM_AGENT_API_KEY
|
||||
if (!SIM_AGENT_API_KEY) {
|
||||
logger.warn('SIM_AGENT_API_KEY not configured - autolayout requests will fail')
|
||||
}
|
||||
|
||||
const AutoLayoutRequestSchema = z.object({
|
||||
strategy: z
|
||||
.enum(['smart', 'hierarchical', 'layered', 'force-directed'])
|
||||
@@ -125,15 +119,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
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`,
|
||||
{
|
||||
hasApiKey: !!SIM_AGENT_API_KEY,
|
||||
simAgentUrl: process.env.SIM_AGENT_API_URL || 'http://localhost:8000',
|
||||
}
|
||||
)
|
||||
|
||||
// Create workflow state for autolayout
|
||||
const workflowState = {
|
||||
blocks: currentWorkflowData.blocks,
|
||||
@@ -184,7 +169,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
resolveOutputType: resolveOutputType.toString(),
|
||||
},
|
||||
},
|
||||
apiKey: SIM_AGENT_API_KEY,
|
||||
})
|
||||
|
||||
// Log the full response for debugging
|
||||
|
||||
@@ -7,7 +7,7 @@ import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getUserEntityPermissions } from '@/lib/permissions/utils'
|
||||
import { db } from '@/db'
|
||||
import { workflow, workflowBlocks, workflowEdges, workflowSubflows } from '@/db/schema'
|
||||
import type { LoopConfig, ParallelConfig, WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
import type { LoopConfig, ParallelConfig } from '@/stores/workflows/workflow/types'
|
||||
|
||||
const logger = createLogger('WorkflowDuplicateAPI')
|
||||
|
||||
@@ -90,7 +90,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
folderId: folderId || source.folderId,
|
||||
name,
|
||||
description: description || source.description,
|
||||
state: source.state, // We'll update this later with new block IDs
|
||||
color: color || source.color,
|
||||
lastSynced: now,
|
||||
createdAt: now,
|
||||
@@ -112,9 +111,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
// Create a mapping from old block IDs to new block IDs
|
||||
const blockIdMapping = new Map<string, string>()
|
||||
|
||||
// Initialize state for updating with new block IDs
|
||||
let updatedState: WorkflowState = source.state as WorkflowState
|
||||
|
||||
if (sourceBlocks.length > 0) {
|
||||
// First pass: Create all block ID mappings
|
||||
sourceBlocks.forEach((block) => {
|
||||
@@ -265,86 +261,10 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
)
|
||||
}
|
||||
|
||||
// Update the JSON state to use new block IDs
|
||||
if (updatedState && typeof updatedState === 'object') {
|
||||
updatedState = JSON.parse(JSON.stringify(updatedState)) as WorkflowState
|
||||
|
||||
// Update blocks object keys
|
||||
if (updatedState.blocks && typeof updatedState.blocks === 'object') {
|
||||
const newBlocks = {} as Record<string, (typeof updatedState.blocks)[string]>
|
||||
for (const [oldId, blockData] of Object.entries(updatedState.blocks)) {
|
||||
const newId = blockIdMapping.get(oldId) || oldId
|
||||
newBlocks[newId] = {
|
||||
...blockData,
|
||||
id: newId,
|
||||
// Update data.parentId and extent in the JSON state as well
|
||||
data: (() => {
|
||||
const block = blockData as any
|
||||
if (block.data && typeof block.data === 'object' && block.data.parentId) {
|
||||
return {
|
||||
...block.data,
|
||||
parentId: blockIdMapping.get(block.data.parentId) || block.data.parentId,
|
||||
extent: 'parent', // Ensure extent is set for child blocks
|
||||
}
|
||||
}
|
||||
return block.data
|
||||
})(),
|
||||
}
|
||||
}
|
||||
updatedState.blocks = newBlocks
|
||||
}
|
||||
|
||||
// Update edges array
|
||||
if (updatedState.edges && Array.isArray(updatedState.edges)) {
|
||||
updatedState.edges = updatedState.edges.map((edge) => ({
|
||||
...edge,
|
||||
id: crypto.randomUUID(),
|
||||
source: blockIdMapping.get(edge.source) || edge.source,
|
||||
target: blockIdMapping.get(edge.target) || edge.target,
|
||||
}))
|
||||
}
|
||||
|
||||
// Update loops and parallels if they exist
|
||||
if (updatedState.loops && typeof updatedState.loops === 'object') {
|
||||
const newLoops = {} as Record<string, (typeof updatedState.loops)[string]>
|
||||
for (const [oldId, loopData] of Object.entries(updatedState.loops)) {
|
||||
const newId = blockIdMapping.get(oldId) || oldId
|
||||
const loopConfig = loopData as any
|
||||
newLoops[newId] = {
|
||||
...loopConfig,
|
||||
id: newId,
|
||||
// Update node references in loop config
|
||||
nodes: loopConfig.nodes
|
||||
? loopConfig.nodes.map((nodeId: string) => blockIdMapping.get(nodeId) || nodeId)
|
||||
: [],
|
||||
}
|
||||
}
|
||||
updatedState.loops = newLoops
|
||||
}
|
||||
|
||||
if (updatedState.parallels && typeof updatedState.parallels === 'object') {
|
||||
const newParallels = {} as Record<string, (typeof updatedState.parallels)[string]>
|
||||
for (const [oldId, parallelData] of Object.entries(updatedState.parallels)) {
|
||||
const newId = blockIdMapping.get(oldId) || oldId
|
||||
const parallelConfig = parallelData as any
|
||||
newParallels[newId] = {
|
||||
...parallelConfig,
|
||||
id: newId,
|
||||
// Update node references in parallel config
|
||||
nodes: parallelConfig.nodes
|
||||
? parallelConfig.nodes.map((nodeId: string) => blockIdMapping.get(nodeId) || nodeId)
|
||||
: [],
|
||||
}
|
||||
}
|
||||
updatedState.parallels = newParallels
|
||||
}
|
||||
}
|
||||
|
||||
// Update the workflow state with the new block IDs
|
||||
// Update the workflow timestamp
|
||||
await tx
|
||||
.update(workflow)
|
||||
.set({
|
||||
state: updatedState,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(workflow.id, newWorkflowId))
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { tasks } from '@trigger.dev/sdk/v3'
|
||||
import { tasks } from '@trigger.dev/sdk'
|
||||
import { eq, sql } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
|
||||
@@ -89,7 +89,14 @@ describe('Workflow By ID API Route', () => {
|
||||
userId: 'user-123',
|
||||
name: 'Test Workflow',
|
||||
workspaceId: null,
|
||||
state: { blocks: {}, edges: [] },
|
||||
}
|
||||
|
||||
const mockNormalizedData = {
|
||||
blocks: {},
|
||||
edges: [],
|
||||
loops: {},
|
||||
parallels: {},
|
||||
isFromNormalizedTables: true,
|
||||
}
|
||||
|
||||
vi.doMock('@/lib/auth', () => ({
|
||||
@@ -110,6 +117,10 @@ describe('Workflow By ID API Route', () => {
|
||||
},
|
||||
}))
|
||||
|
||||
vi.doMock('@/lib/workflows/db-helpers', () => ({
|
||||
loadWorkflowFromNormalizedTables: vi.fn().mockResolvedValue(mockNormalizedData),
|
||||
}))
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123')
|
||||
const params = Promise.resolve({ id: 'workflow-123' })
|
||||
|
||||
@@ -127,7 +138,14 @@ describe('Workflow By ID API Route', () => {
|
||||
userId: 'other-user',
|
||||
name: 'Test Workflow',
|
||||
workspaceId: 'workspace-456',
|
||||
state: { blocks: {}, edges: [] },
|
||||
}
|
||||
|
||||
const mockNormalizedData = {
|
||||
blocks: {},
|
||||
edges: [],
|
||||
loops: {},
|
||||
parallels: {},
|
||||
isFromNormalizedTables: true,
|
||||
}
|
||||
|
||||
vi.doMock('@/lib/auth', () => ({
|
||||
@@ -148,6 +166,10 @@ describe('Workflow By ID API Route', () => {
|
||||
},
|
||||
}))
|
||||
|
||||
vi.doMock('@/lib/workflows/db-helpers', () => ({
|
||||
loadWorkflowFromNormalizedTables: vi.fn().mockResolvedValue(mockNormalizedData),
|
||||
}))
|
||||
|
||||
vi.doMock('@/lib/permissions/utils', () => ({
|
||||
getUserEntityPermissions: vi.fn().mockResolvedValue('read'),
|
||||
hasAdminPermission: vi.fn().mockResolvedValue(false),
|
||||
@@ -170,7 +192,6 @@ describe('Workflow By ID API Route', () => {
|
||||
userId: 'other-user',
|
||||
name: 'Test Workflow',
|
||||
workspaceId: 'workspace-456',
|
||||
state: { blocks: {}, edges: [] },
|
||||
}
|
||||
|
||||
vi.doMock('@/lib/auth', () => ({
|
||||
@@ -213,7 +234,6 @@ describe('Workflow By ID API Route', () => {
|
||||
userId: 'user-123',
|
||||
name: 'Test Workflow',
|
||||
workspaceId: null,
|
||||
state: { blocks: {}, edges: [] },
|
||||
}
|
||||
|
||||
const mockNormalizedData = {
|
||||
|
||||
@@ -120,8 +120,6 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
logger.debug(`[${requestId}] Attempting to load workflow ${workflowId} from normalized tables`)
|
||||
const normalizedData = await loadWorkflowFromNormalizedTables(workflowId)
|
||||
|
||||
const finalWorkflowData = { ...workflowData }
|
||||
|
||||
if (normalizedData) {
|
||||
logger.debug(`[${requestId}] Found normalized data for workflow ${workflowId}:`, {
|
||||
blocksCount: Object.keys(normalizedData.blocks).length,
|
||||
@@ -131,38 +129,31 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
loops: normalizedData.loops,
|
||||
})
|
||||
|
||||
// Use normalized table data - reconstruct complete state object
|
||||
// First get any existing state properties, then override with normalized data
|
||||
const existingState =
|
||||
workflowData.state && typeof workflowData.state === 'object' ? workflowData.state : {}
|
||||
|
||||
finalWorkflowData.state = {
|
||||
// Default values for expected properties
|
||||
deploymentStatuses: {},
|
||||
hasActiveWebhook: false,
|
||||
// Preserve any existing state properties
|
||||
...existingState,
|
||||
// Override with normalized data (this takes precedence)
|
||||
blocks: normalizedData.blocks,
|
||||
edges: normalizedData.edges,
|
||||
loops: normalizedData.loops,
|
||||
parallels: normalizedData.parallels,
|
||||
lastSaved: Date.now(),
|
||||
isDeployed: workflowData.isDeployed || false,
|
||||
deployedAt: workflowData.deployedAt,
|
||||
// Construct response object with workflow data and state from normalized tables
|
||||
const finalWorkflowData = {
|
||||
...workflowData,
|
||||
state: {
|
||||
// Default values for expected properties
|
||||
deploymentStatuses: {},
|
||||
hasActiveWebhook: false,
|
||||
// Data from normalized tables
|
||||
blocks: normalizedData.blocks,
|
||||
edges: normalizedData.edges,
|
||||
loops: normalizedData.loops,
|
||||
parallels: normalizedData.parallels,
|
||||
lastSaved: Date.now(),
|
||||
isDeployed: workflowData.isDeployed || false,
|
||||
deployedAt: workflowData.deployedAt,
|
||||
},
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Loaded workflow ${workflowId} from normalized tables`)
|
||||
} else {
|
||||
// Fallback to JSON blob
|
||||
logger.info(
|
||||
`[${requestId}] Using JSON blob for workflow ${workflowId} - no normalized data found`
|
||||
)
|
||||
const elapsed = Date.now() - startTime
|
||||
logger.info(`[${requestId}] Successfully fetched workflow ${workflowId} in ${elapsed}ms`)
|
||||
|
||||
return NextResponse.json({ data: finalWorkflowData }, { status: 200 })
|
||||
}
|
||||
|
||||
const elapsed = Date.now() - startTime
|
||||
logger.info(`[${requestId}] Successfully fetched workflow ${workflowId} in ${elapsed}ms`)
|
||||
|
||||
return NextResponse.json({ data: finalWorkflowData }, { status: 200 })
|
||||
return NextResponse.json({ error: 'Workflow has no normalized data' }, { status: 400 })
|
||||
} catch (error: any) {
|
||||
const elapsed = Date.now() - startTime
|
||||
logger.error(`[${requestId}] Error fetching workflow ${workflowId} after ${elapsed}ms`, error)
|
||||
|
||||
@@ -220,7 +220,6 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
|
||||
.set({
|
||||
lastSynced: new Date(),
|
||||
updatedAt: new Date(),
|
||||
state: saveResult.jsonBlob, // Also update JSON blob for backward compatibility
|
||||
})
|
||||
.where(eq(workflow.id, workflowId))
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { env } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getUserEntityPermissions } from '@/lib/permissions/utils'
|
||||
import { simAgentClient } from '@/lib/sim-agent'
|
||||
import { SIM_AGENT_API_URL_DEFAULT, simAgentClient } from '@/lib/sim-agent'
|
||||
import {
|
||||
loadWorkflowFromNormalizedTables,
|
||||
saveWorkflowToNormalizedTables,
|
||||
@@ -17,15 +18,12 @@ import { db } from '@/db'
|
||||
import { workflowCheckpoints, workflow as workflowTable } from '@/db/schema'
|
||||
import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils'
|
||||
|
||||
// Sim Agent API configuration
|
||||
const SIM_AGENT_API_URL = process.env.SIM_AGENT_API_URL || 'http://localhost:8000'
|
||||
const SIM_AGENT_API_KEY = process.env.SIM_AGENT_API_KEY
|
||||
const SIM_AGENT_API_URL = env.SIM_AGENT_API_URL || SIM_AGENT_API_URL_DEFAULT
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
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(),
|
||||
@@ -74,7 +72,6 @@ async function createWorkflowCheckpoint(
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(SIM_AGENT_API_KEY && { 'x-api-key': SIM_AGENT_API_KEY }),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
workflowState: currentWorkflowData,
|
||||
@@ -288,7 +285,6 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(SIM_AGENT_API_KEY && { 'x-api-key': SIM_AGENT_API_KEY }),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
yamlContent,
|
||||
@@ -649,14 +645,13 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
|
||||
.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'
|
||||
const socketUrl = env.SOCKET_SERVER_URL || 'http://localhost:3002'
|
||||
await fetch(`${socketUrl}/api/copilot-workflow-edit`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
|
||||
@@ -151,7 +151,6 @@ export async function POST(req: NextRequest) {
|
||||
folderId: folderId || null,
|
||||
name,
|
||||
description,
|
||||
state: initialState,
|
||||
color,
|
||||
lastSynced: now,
|
||||
createdAt: now,
|
||||
|
||||
@@ -8,9 +8,6 @@ import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/w
|
||||
|
||||
const logger = createLogger('WorkflowYamlAPI')
|
||||
|
||||
// Get API key at module level like working routes
|
||||
const SIM_AGENT_API_KEY = process.env.SIM_AGENT_API_KEY
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const requestId = crypto.randomUUID().slice(0, 8)
|
||||
|
||||
@@ -55,7 +52,6 @@ export async function POST(request: NextRequest) {
|
||||
resolveOutputType: resolveOutputType.toString(),
|
||||
},
|
||||
},
|
||||
apiKey: SIM_AGENT_API_KEY,
|
||||
})
|
||||
|
||||
if (!result.success || !result.data?.yaml) {
|
||||
|
||||
@@ -14,9 +14,6 @@ import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/w
|
||||
|
||||
const logger = createLogger('WorkflowYamlExportAPI')
|
||||
|
||||
// Get API key at module level like working routes
|
||||
const SIM_AGENT_API_KEY = process.env.SIM_AGENT_API_KEY
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const requestId = crypto.randomUUID().slice(0, 8)
|
||||
const url = new URL(request.url)
|
||||
@@ -88,14 +85,10 @@ export async function GET(request: NextRequest) {
|
||||
edgesCount: normalizedData.edges.length,
|
||||
})
|
||||
|
||||
// Use normalized table data - reconstruct complete state object
|
||||
const existingState =
|
||||
workflowData.state && typeof workflowData.state === 'object' ? workflowData.state : {}
|
||||
|
||||
// Use normalized table data - construct state from normalized tables
|
||||
workflowState = {
|
||||
deploymentStatuses: {},
|
||||
hasActiveWebhook: false,
|
||||
...existingState,
|
||||
blocks: normalizedData.blocks,
|
||||
edges: normalizedData.edges,
|
||||
loops: normalizedData.loops,
|
||||
@@ -119,33 +112,10 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
logger.info(`[${requestId}] Loaded workflow ${workflowId} from normalized tables`)
|
||||
} else {
|
||||
// Fallback to JSON blob
|
||||
logger.info(
|
||||
`[${requestId}] Using JSON blob for workflow ${workflowId} - no normalized data found`
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Workflow has no normalized data' },
|
||||
{ status: 400 }
|
||||
)
|
||||
|
||||
if (!workflowData.state || typeof workflowData.state !== 'object') {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Workflow has no valid state data' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
workflowState = workflowData.state as any
|
||||
|
||||
// Extract subblock values from JSON blob state
|
||||
if (workflowState.blocks) {
|
||||
Object.entries(workflowState.blocks).forEach(([blockId, block]: [string, any]) => {
|
||||
subBlockValues[blockId] = {}
|
||||
if (block.subBlocks) {
|
||||
Object.entries(block.subBlocks).forEach(([subBlockId, subBlock]: [string, any]) => {
|
||||
if (subBlock && typeof subBlock === 'object' && 'value' in subBlock) {
|
||||
subBlockValues[blockId][subBlockId] = subBlock.value
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Gather block registry and utilities for sim-agent
|
||||
@@ -176,7 +146,6 @@ export async function GET(request: NextRequest) {
|
||||
resolveOutputType: resolveOutputType.toString(),
|
||||
},
|
||||
},
|
||||
apiKey: SIM_AGENT_API_KEY,
|
||||
})
|
||||
|
||||
if (!result.success || !result.data?.yaml) {
|
||||
|
||||
@@ -2,9 +2,9 @@ import { randomUUID } from 'crypto'
|
||||
import { render } from '@react-email/render'
|
||||
import { and, eq, inArray } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { Resend } from 'resend'
|
||||
import { WorkspaceInvitationEmail } from '@/components/emails/workspace-invitation'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { sendEmail } from '@/lib/email/mailer'
|
||||
import { env } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getEmailDomain } from '@/lib/urls/utils'
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('WorkspaceInvitationsAPI')
|
||||
const resend = env.RESEND_API_KEY ? new Resend(env.RESEND_API_KEY) : null
|
||||
|
||||
type PermissionType = (typeof permissionTypeEnum.enumValues)[number]
|
||||
|
||||
@@ -241,30 +240,25 @@ async function sendInvitationEmail({
|
||||
})
|
||||
)
|
||||
|
||||
if (!resend) {
|
||||
logger.error('RESEND_API_KEY not configured')
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'Email service not configured. Please set RESEND_API_KEY in environment variables.',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
const emailDomain = env.EMAIL_DOMAIN || getEmailDomain()
|
||||
const fromAddress = `noreply@${emailDomain}`
|
||||
const fromAddress = `${env.SENDER_NAME || 'Sim'} <noreply@${emailDomain}>`
|
||||
|
||||
logger.info(`Attempting to send email from ${fromAddress} to ${to}`)
|
||||
|
||||
const result = await resend.emails.send({
|
||||
from: fromAddress,
|
||||
const result = await sendEmail({
|
||||
to,
|
||||
subject: `You've been invited to join "${workspaceName}" on Sim`,
|
||||
html: emailHtml,
|
||||
from: fromAddress,
|
||||
emailType: 'transactional',
|
||||
useCustomFromFormat: true,
|
||||
})
|
||||
|
||||
logger.info(`Invitation email sent successfully to ${to}`, { result })
|
||||
if (result.success) {
|
||||
logger.info(`Invitation email sent successfully to ${to}`, { result })
|
||||
} else {
|
||||
logger.error(`Failed to send invitation email to ${to}`, { error: result.message })
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error sending invitation email:', error)
|
||||
// Continue even if email fails - the invitation is still created
|
||||
|
||||
@@ -113,64 +113,6 @@ async function createWorkspace(userId: string, name: string) {
|
||||
|
||||
// Create initial workflow for the workspace with start block
|
||||
const starterId = crypto.randomUUID()
|
||||
const initialState = {
|
||||
blocks: {
|
||||
[starterId]: {
|
||||
id: starterId,
|
||||
type: 'starter',
|
||||
name: 'Start',
|
||||
position: { x: 100, y: 100 },
|
||||
subBlocks: {
|
||||
startWorkflow: {
|
||||
id: 'startWorkflow',
|
||||
type: 'dropdown',
|
||||
value: 'manual',
|
||||
},
|
||||
webhookPath: {
|
||||
id: 'webhookPath',
|
||||
type: 'short-input',
|
||||
value: '',
|
||||
},
|
||||
webhookSecret: {
|
||||
id: 'webhookSecret',
|
||||
type: 'short-input',
|
||||
value: '',
|
||||
},
|
||||
scheduleType: {
|
||||
id: 'scheduleType',
|
||||
type: 'dropdown',
|
||||
value: 'daily',
|
||||
},
|
||||
minutesInterval: {
|
||||
id: 'minutesInterval',
|
||||
type: 'short-input',
|
||||
value: '',
|
||||
},
|
||||
minutesStartingAt: {
|
||||
id: 'minutesStartingAt',
|
||||
type: 'short-input',
|
||||
value: '',
|
||||
},
|
||||
},
|
||||
outputs: {
|
||||
response: { type: { input: 'any' } },
|
||||
},
|
||||
enabled: true,
|
||||
horizontalHandles: true,
|
||||
isWide: false,
|
||||
advancedMode: false,
|
||||
height: 95,
|
||||
},
|
||||
},
|
||||
edges: [],
|
||||
subflows: {},
|
||||
variables: {},
|
||||
metadata: {
|
||||
version: '1.0.0',
|
||||
createdAt: now.toISOString(),
|
||||
updatedAt: now.toISOString(),
|
||||
},
|
||||
}
|
||||
|
||||
// Create the workflow
|
||||
await tx.insert(workflow).values({
|
||||
@@ -180,7 +122,6 @@ async function createWorkspace(userId: string, name: string) {
|
||||
folderId: null,
|
||||
name: 'default-agent',
|
||||
description: 'Your first workflow - start building here!',
|
||||
state: initialState,
|
||||
color: '#3972F6',
|
||||
lastSynced: now,
|
||||
createdAt: now,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { env } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { SIM_AGENT_API_URL_DEFAULT } from '@/lib/sim-agent'
|
||||
import { getAllBlocks } from '@/blocks/registry'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import { resolveOutputType } from '@/blocks/utils'
|
||||
@@ -16,8 +18,7 @@ import {
|
||||
const logger = createLogger('YamlAutoLayoutAPI')
|
||||
|
||||
// Sim Agent API configuration
|
||||
const SIM_AGENT_API_URL = process.env.SIM_AGENT_API_URL || 'http://localhost:8000'
|
||||
const SIM_AGENT_API_KEY = process.env.SIM_AGENT_API_KEY
|
||||
const SIM_AGENT_API_URL = env.SIM_AGENT_API_URL || SIM_AGENT_API_URL_DEFAULT
|
||||
|
||||
const AutoLayoutRequestSchema = z.object({
|
||||
workflowState: z.object({
|
||||
@@ -58,7 +59,6 @@ export async function POST(request: NextRequest) {
|
||||
logger.info(`[${requestId}] Applying auto layout`, {
|
||||
blockCount: Object.keys(workflowState.blocks).length,
|
||||
edgeCount: workflowState.edges.length,
|
||||
hasApiKey: !!SIM_AGENT_API_KEY,
|
||||
strategy: options?.strategy || 'smart',
|
||||
simAgentUrl: SIM_AGENT_API_URL,
|
||||
})
|
||||
@@ -102,7 +102,6 @@ export async function POST(request: NextRequest) {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(SIM_AGENT_API_KEY && { 'x-api-key': SIM_AGENT_API_KEY }),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
workflowState: {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { env } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { SIM_AGENT_API_URL_DEFAULT } from '@/lib/sim-agent'
|
||||
import { getAllBlocks } from '@/blocks/registry'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import { resolveOutputType } from '@/blocks/utils'
|
||||
@@ -16,8 +18,7 @@ import {
|
||||
const logger = createLogger('YamlDiffCreateAPI')
|
||||
|
||||
// Sim Agent API configuration
|
||||
const SIM_AGENT_API_URL = process.env.SIM_AGENT_API_URL || 'http://localhost:8000'
|
||||
const SIM_AGENT_API_KEY = process.env.SIM_AGENT_API_KEY
|
||||
const SIM_AGENT_API_URL = env.SIM_AGENT_API_URL || SIM_AGENT_API_URL_DEFAULT
|
||||
|
||||
const CreateDiffRequestSchema = z.object({
|
||||
yamlContent: z.string().min(1),
|
||||
@@ -89,7 +90,6 @@ export async function POST(request: NextRequest) {
|
||||
hasDiffAnalysis: !!diffAnalysis,
|
||||
hasOptions: !!options,
|
||||
options: options,
|
||||
hasApiKey: !!SIM_AGENT_API_KEY,
|
||||
hasCurrentWorkflowState: !!currentWorkflowState,
|
||||
currentBlockCount: currentWorkflowState
|
||||
? Object.keys(currentWorkflowState.blocks || {}).length
|
||||
@@ -117,7 +117,6 @@ export async function POST(request: NextRequest) {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(SIM_AGENT_API_KEY && { 'x-api-key': SIM_AGENT_API_KEY }),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
yamlContent,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { env } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { SIM_AGENT_API_URL_DEFAULT } from '@/lib/sim-agent'
|
||||
import { getAllBlocks } from '@/blocks/registry'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import { resolveOutputType } from '@/blocks/utils'
|
||||
@@ -16,8 +18,7 @@ import {
|
||||
const logger = createLogger('YamlDiffMergeAPI')
|
||||
|
||||
// Sim Agent API configuration
|
||||
const SIM_AGENT_API_URL = process.env.SIM_AGENT_API_URL || 'http://localhost:8000'
|
||||
const SIM_AGENT_API_KEY = process.env.SIM_AGENT_API_KEY
|
||||
const SIM_AGENT_API_URL = env.SIM_AGENT_API_URL || SIM_AGENT_API_URL_DEFAULT
|
||||
|
||||
const MergeDiffRequestSchema = z.object({
|
||||
existingDiff: z.object({
|
||||
@@ -64,7 +65,6 @@ export async function POST(request: NextRequest) {
|
||||
hasDiffAnalysis: !!diffAnalysis,
|
||||
hasOptions: !!options,
|
||||
options: options,
|
||||
hasApiKey: !!SIM_AGENT_API_KEY,
|
||||
})
|
||||
|
||||
// Gather block registry
|
||||
@@ -88,7 +88,6 @@ export async function POST(request: NextRequest) {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(SIM_AGENT_API_KEY && { 'x-api-key': SIM_AGENT_API_KEY }),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
existingDiff,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { env } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { SIM_AGENT_API_URL_DEFAULT } from '@/lib/sim-agent'
|
||||
import { getAllBlocks } from '@/blocks/registry'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import { resolveOutputType } from '@/blocks/utils'
|
||||
@@ -9,8 +11,7 @@ import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/w
|
||||
const logger = createLogger('YamlGenerateAPI')
|
||||
|
||||
// Sim Agent API configuration
|
||||
const SIM_AGENT_API_URL = process.env.SIM_AGENT_API_URL || 'http://localhost:8000'
|
||||
const SIM_AGENT_API_KEY = process.env.SIM_AGENT_API_KEY
|
||||
const SIM_AGENT_API_URL = env.SIM_AGENT_API_URL || SIM_AGENT_API_URL_DEFAULT
|
||||
|
||||
const GenerateRequestSchema = z.object({
|
||||
workflowState: z.any(), // Let the yaml service handle validation
|
||||
@@ -27,7 +28,6 @@ export async function POST(request: NextRequest) {
|
||||
logger.info(`[${requestId}] Generating YAML from workflow`, {
|
||||
blocksCount: workflowState.blocks ? Object.keys(workflowState.blocks).length : 0,
|
||||
edgesCount: workflowState.edges ? workflowState.edges.length : 0,
|
||||
hasApiKey: !!SIM_AGENT_API_KEY,
|
||||
})
|
||||
|
||||
// Gather block registry and utilities
|
||||
@@ -51,7 +51,6 @@ export async function POST(request: NextRequest) {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(SIM_AGENT_API_KEY && { 'x-api-key': SIM_AGENT_API_KEY }),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
workflowState,
|
||||
|
||||
@@ -1,26 +1,24 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { env } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { SIM_AGENT_API_URL_DEFAULT } from '@/lib/sim-agent'
|
||||
|
||||
const logger = createLogger('YamlHealthAPI')
|
||||
|
||||
// Sim Agent API configuration
|
||||
const SIM_AGENT_API_URL = process.env.SIM_AGENT_API_URL || 'http://localhost:8000'
|
||||
const SIM_AGENT_API_KEY = process.env.SIM_AGENT_API_KEY
|
||||
const SIM_AGENT_API_URL = env.SIM_AGENT_API_URL || SIM_AGENT_API_URL_DEFAULT
|
||||
|
||||
export async function GET() {
|
||||
const requestId = crypto.randomUUID().slice(0, 8)
|
||||
|
||||
try {
|
||||
logger.info(`[${requestId}] Checking YAML service health`, {
|
||||
hasApiKey: !!SIM_AGENT_API_KEY,
|
||||
})
|
||||
logger.info(`[${requestId}] Checking YAML service health`)
|
||||
|
||||
// Check sim-agent health
|
||||
const response = await fetch(`${SIM_AGENT_API_URL}/health`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(SIM_AGENT_API_KEY && { 'x-api-key': SIM_AGENT_API_KEY }),
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { env } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { SIM_AGENT_API_URL_DEFAULT } from '@/lib/sim-agent'
|
||||
import { getAllBlocks } from '@/blocks/registry'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import { resolveOutputType } from '@/blocks/utils'
|
||||
@@ -9,11 +11,10 @@ import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/w
|
||||
const logger = createLogger('YamlParseAPI')
|
||||
|
||||
// Sim Agent API configuration
|
||||
const SIM_AGENT_API_URL = process.env.SIM_AGENT_API_URL || 'http://localhost:8000'
|
||||
const SIM_AGENT_API_KEY = process.env.SIM_AGENT_API_KEY
|
||||
const SIM_AGENT_API_URL = env.SIM_AGENT_API_URL || SIM_AGENT_API_URL_DEFAULT
|
||||
|
||||
const ParseRequestSchema = z.object({
|
||||
yamlContent: z.string().min(1),
|
||||
yamlContent: z.string(),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
@@ -25,7 +26,6 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
logger.info(`[${requestId}] Parsing YAML`, {
|
||||
contentLength: yamlContent.length,
|
||||
hasApiKey: !!SIM_AGENT_API_KEY,
|
||||
})
|
||||
|
||||
// Gather block registry and utilities
|
||||
@@ -49,7 +49,6 @@ export async function POST(request: NextRequest) {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(SIM_AGENT_API_KEY && { 'x-api-key': SIM_AGENT_API_KEY }),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
yamlContent,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { env } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { SIM_AGENT_API_URL_DEFAULT } from '@/lib/sim-agent'
|
||||
import { getAllBlocks } from '@/blocks/registry'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import { resolveOutputType } from '@/blocks/utils'
|
||||
@@ -9,8 +11,7 @@ import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/w
|
||||
const logger = createLogger('YamlToWorkflowAPI')
|
||||
|
||||
// Sim Agent API configuration
|
||||
const SIM_AGENT_API_URL = process.env.SIM_AGENT_API_URL || 'http://localhost:8000'
|
||||
const SIM_AGENT_API_KEY = process.env.SIM_AGENT_API_KEY
|
||||
const SIM_AGENT_API_URL = env.SIM_AGENT_API_URL || SIM_AGENT_API_URL_DEFAULT
|
||||
|
||||
const ConvertRequestSchema = z.object({
|
||||
yamlContent: z.string().min(1),
|
||||
@@ -33,7 +34,6 @@ export async function POST(request: NextRequest) {
|
||||
logger.info(`[${requestId}] Converting YAML to workflow`, {
|
||||
contentLength: yamlContent.length,
|
||||
hasOptions: !!options,
|
||||
hasApiKey: !!SIM_AGENT_API_KEY,
|
||||
})
|
||||
|
||||
// Gather block registry and utilities
|
||||
@@ -57,7 +57,6 @@ export async function POST(request: NextRequest) {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(SIM_AGENT_API_KEY && { 'x-api-key': SIM_AGENT_API_KEY }),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
yamlContent,
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
}
|
||||
|
||||
.workflow-container .react-flow__node-loopNode,
|
||||
.workflow-container .react-flow__node-parallelNode {
|
||||
.workflow-container .react-flow__node-parallelNode,
|
||||
.workflow-container .react-flow__node-subflowNode {
|
||||
z-index: -1 !important;
|
||||
}
|
||||
|
||||
@@ -205,23 +206,22 @@
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background-color: hsl(var(--scrollbar-track));
|
||||
border-radius: var(--radius);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: hsl(var(--scrollbar-thumb));
|
||||
background-color: hsl(var(--muted-foreground) / 0.3);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background-color: hsl(var(--scrollbar-thumb-hover));
|
||||
background-color: hsl(var(--muted-foreground) / 0.3);
|
||||
}
|
||||
|
||||
/* For Firefox */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: hsl(var(--scrollbar-thumb)) hsl(var(--scrollbar-track));
|
||||
scrollbar-color: hsl(var(--muted-foreground) / 0.3) transparent;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getAssetUrl } from '@/lib/utils'
|
||||
import '@/app/globals.css'
|
||||
|
||||
import { ThemeProvider } from '@/app/theme-provider'
|
||||
import { ZoomPrevention } from '@/app/zoom-prevention'
|
||||
|
||||
const logger = createLogger('RootLayout')
|
||||
@@ -45,11 +46,14 @@ if (typeof window !== 'undefined') {
|
||||
}
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: '#ffffff',
|
||||
width: 'device-width',
|
||||
initialScale: 1,
|
||||
maximumScale: 1,
|
||||
userScalable: false,
|
||||
themeColor: [
|
||||
{ media: '(prefers-color-scheme: light)', color: '#ffffff' },
|
||||
{ media: '(prefers-color-scheme: dark)', color: '#0c0c0c' },
|
||||
],
|
||||
}
|
||||
|
||||
// Generate dynamic metadata based on brand configuration
|
||||
@@ -70,8 +74,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
/>
|
||||
|
||||
{/* Meta tags for better SEO */}
|
||||
<meta name='theme-color' content='#ffffff' />
|
||||
<meta name='color-scheme' content='light' />
|
||||
<meta name='color-scheme' content='light dark' />
|
||||
<meta name='format-detection' content='telephone=no' />
|
||||
<meta httpEquiv='x-ua-compatible' content='ie=edge' />
|
||||
|
||||
@@ -107,16 +110,18 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
)}
|
||||
</head>
|
||||
<body suppressHydrationWarning>
|
||||
<BrandedLayout>
|
||||
<ZoomPrevention />
|
||||
{children}
|
||||
{isHosted && (
|
||||
<>
|
||||
<SpeedInsights />
|
||||
<Analytics />
|
||||
</>
|
||||
)}
|
||||
</BrandedLayout>
|
||||
<ThemeProvider>
|
||||
<BrandedLayout>
|
||||
<ZoomPrevention />
|
||||
{children}
|
||||
{isHosted && (
|
||||
<>
|
||||
<SpeedInsights />
|
||||
<Analytics />
|
||||
</>
|
||||
)}
|
||||
</BrandedLayout>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
|
||||
19
apps/sim/app/theme-provider.tsx
Normal file
19
apps/sim/app/theme-provider.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
'use client'
|
||||
|
||||
import type { ThemeProviderProps } from 'next-themes'
|
||||
import { ThemeProvider as NextThemesProvider } from 'next-themes'
|
||||
|
||||
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||
return (
|
||||
<NextThemesProvider
|
||||
attribute='class'
|
||||
defaultTheme='system'
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
storageKey='sim-theme'
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</NextThemesProvider>
|
||||
)
|
||||
}
|
||||
@@ -86,7 +86,7 @@ const getStatusDisplay = (doc: DocumentData) => {
|
||||
</>
|
||||
),
|
||||
className:
|
||||
'inline-flex items-center rounded-md bg-[var(--brand-primary-hex)]/10 px-2 py-1 text-xs font-medium text-[var(--brand-primary-hex)] dark:bg-[var(--brand-primary-hex)]/20 dark:text-[var(--brand-primary-hex)]',
|
||||
'inline-flex items-center rounded-md bg-purple-100 px-2 py-1 text-xs font-medium text-[var(--brand-primary-hex)] dark:bg-purple-900/30 dark:text-[var(--brand-primary-hex)]',
|
||||
}
|
||||
case 'failed':
|
||||
return {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/u
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getDocumentIcon } from '@/app/workspace/[workspaceId]/knowledge/components'
|
||||
import { useKnowledgeUpload } from '@/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload'
|
||||
|
||||
const logger = createLogger('UploadModal')
|
||||
@@ -152,6 +153,19 @@ export function UploadModal({
|
||||
}
|
||||
}
|
||||
|
||||
const getFileIcon = (mimeType: string, filename: string) => {
|
||||
const IconComponent = getDocumentIcon(mimeType, filename)
|
||||
return <IconComponent className='h-10 w-8' />
|
||||
}
|
||||
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return `${Number.parseFloat((bytes / k ** i).toFixed(1))} ${sizes[i]}`
|
||||
}
|
||||
|
||||
// Calculate progress percentage
|
||||
const progressPercentage =
|
||||
uploadProgress.totalFiles > 0
|
||||
@@ -221,11 +235,11 @@ export function UploadModal({
|
||||
multiple
|
||||
/>
|
||||
<p className='text-sm'>
|
||||
{isDragging ? 'Drop more files here!' : 'Add more files'}
|
||||
{isDragging ? 'Drop more files here!' : 'Drop more files or click to browse'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='max-h-60 space-y-1.5 overflow-auto'>
|
||||
<div className='max-h-60 space-y-2 overflow-auto'>
|
||||
{files.map((file, index) => {
|
||||
const fileStatus = uploadProgress.fileStatuses?.[index]
|
||||
const isCurrentlyUploading = fileStatus?.status === 'uploading'
|
||||
@@ -233,26 +247,31 @@ export function UploadModal({
|
||||
const isFailed = fileStatus?.status === 'failed'
|
||||
|
||||
return (
|
||||
<div key={index} className='space-y-1.5 rounded-md border p-2'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div key={index} className='rounded-md border p-3'>
|
||||
<div className='flex items-center gap-3'>
|
||||
{getFileIcon(file.type, file.name)}
|
||||
<div className='min-w-0 flex-1'>
|
||||
<div className='flex items-center gap-2'>
|
||||
{isCurrentlyUploading && (
|
||||
<Loader2 className='h-4 w-4 animate-spin text-blue-500' />
|
||||
<Loader2 className='h-4 w-4 animate-spin text-[var(--brand-primary-hex)]' />
|
||||
)}
|
||||
{isCompleted && <Check className='h-4 w-4 text-green-500' />}
|
||||
{isFailed && <X className='h-4 w-4 text-red-500' />}
|
||||
{!isCurrentlyUploading && !isCompleted && !isFailed && (
|
||||
<div className='h-4 w-4' />
|
||||
)}
|
||||
<p className='truncate text-sm'>
|
||||
<span className='font-medium'>{file.name}</span>
|
||||
<span className='text-muted-foreground'>
|
||||
{' '}
|
||||
• {(file.size / 1024 / 1024).toFixed(2)} MB
|
||||
</span>
|
||||
</p>
|
||||
<p className='truncate font-medium text-sm'>{file.name}</p>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{formatFileSize(file.size)}
|
||||
</p>
|
||||
{isCurrentlyUploading && (
|
||||
<div className='min-w-0 max-w-32 flex-1'>
|
||||
<Progress value={fileStatus?.progress || 0} className='h-1' />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isFailed && fileStatus?.error && (
|
||||
<p className='mt-1 text-red-500 text-xs'>{fileStatus.error}</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type='button'
|
||||
@@ -260,17 +279,11 @@ export function UploadModal({
|
||||
size='sm'
|
||||
onClick={() => removeFile(index)}
|
||||
disabled={isUploading}
|
||||
className='h-8 w-8 p-0'
|
||||
className='h-8 w-8 p-0 text-muted-foreground hover:text-destructive'
|
||||
>
|
||||
<X className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
{isCurrentlyUploading && (
|
||||
<Progress value={fileStatus?.progress || 0} className='h-1' />
|
||||
)}
|
||||
{isFailed && fileStatus?.error && (
|
||||
<p className='text-red-500 text-xs'>{fileStatus.error}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
@@ -287,7 +300,11 @@ export function UploadModal({
|
||||
<Button variant='outline' onClick={handleClose} disabled={isUploading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleUpload} disabled={files.length === 0 || isUploading}>
|
||||
<Button
|
||||
onClick={handleUpload}
|
||||
disabled={files.length === 0 || isUploading}
|
||||
className='bg-[var(--brand-primary-hex)] font-[480] text-primary-foreground shadow-[0_0_0_0_var(--brand-primary-hex)] transition-all duration-200 hover:bg-[var(--brand-primary-hover-hex)] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)]'
|
||||
>
|
||||
{isUploading
|
||||
? uploadProgress.stage === 'uploading'
|
||||
? `Uploading ${uploadProgress.filesCompleted + 1}/${uploadProgress.totalFiles}...`
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { AlertCircle, CheckCircle2, X } from 'lucide-react'
|
||||
import { AlertCircle, X } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { z } from 'zod'
|
||||
@@ -109,6 +109,7 @@ export function CreateModal({ open, onOpenChange, onKnowledgeBaseCreated }: Crea
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
watch,
|
||||
formState: { errors },
|
||||
} = useForm<FormValues>({
|
||||
resolver: zodResolver(FormSchema),
|
||||
@@ -119,9 +120,32 @@ export function CreateModal({ open, onOpenChange, onKnowledgeBaseCreated }: Crea
|
||||
maxChunkSize: 1024,
|
||||
overlapSize: 200,
|
||||
},
|
||||
mode: 'onChange',
|
||||
mode: 'onSubmit',
|
||||
})
|
||||
|
||||
// Watch the name field to enable/disable the submit button
|
||||
const nameValue = watch('name')
|
||||
|
||||
// Reset state when modal opens/closes
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
// Reset states when modal opens
|
||||
setSubmitStatus(null)
|
||||
setFileError(null)
|
||||
setFiles([])
|
||||
setIsDragging(false)
|
||||
setDragCounter(0)
|
||||
// Reset form to default values
|
||||
reset({
|
||||
name: '',
|
||||
description: '',
|
||||
minChunkSize: 1,
|
||||
maxChunkSize: 1024,
|
||||
overlapSize: 200,
|
||||
})
|
||||
}
|
||||
}, [open, reset])
|
||||
|
||||
const processFiles = async (fileList: FileList | File[]) => {
|
||||
setFileError(null)
|
||||
|
||||
@@ -292,18 +316,6 @@ export function CreateModal({ open, onOpenChange, onKnowledgeBaseCreated }: Crea
|
||||
logger.info(`Started processing ${uploadedFiles.length} documents in the background`)
|
||||
}
|
||||
|
||||
setSubmitStatus({
|
||||
type: 'success',
|
||||
message: 'Your knowledge base has been created successfully!',
|
||||
})
|
||||
reset({
|
||||
name: '',
|
||||
description: '',
|
||||
minChunkSize: 1,
|
||||
maxChunkSize: 1024,
|
||||
overlapSize: 200,
|
||||
})
|
||||
|
||||
// Clean up file previews
|
||||
files.forEach((file) => URL.revokeObjectURL(file.preview))
|
||||
setFiles([])
|
||||
@@ -313,10 +325,8 @@ export function CreateModal({ open, onOpenChange, onKnowledgeBaseCreated }: Crea
|
||||
onKnowledgeBaseCreated(newKnowledgeBase)
|
||||
}
|
||||
|
||||
// Close modal after a short delay to show success message
|
||||
setTimeout(() => {
|
||||
onOpenChange(false)
|
||||
}, 1500)
|
||||
// Close modal immediately - no need for success message
|
||||
onOpenChange(false)
|
||||
} catch (error) {
|
||||
logger.error('Error creating knowledge base:', error)
|
||||
setSubmitStatus({
|
||||
@@ -357,31 +367,13 @@ export function CreateModal({ open, onOpenChange, onKnowledgeBaseCreated }: Crea
|
||||
className='scrollbar-thin scrollbar-thumb-muted-foreground/20 hover:scrollbar-thumb-muted-foreground/25 scrollbar-track-transparent min-h-0 flex-1 overflow-y-auto px-6'
|
||||
>
|
||||
<div className='flex min-h-full flex-col py-4'>
|
||||
{submitStatus && submitStatus.type === 'success' ? (
|
||||
<Alert className='mb-6 border-border border-green-200 bg-green-50 dark:border-green-900 dark:bg-green-950/30'>
|
||||
<div className='flex items-start gap-4 py-1'>
|
||||
<div className='mt-[-1.5px] flex-shrink-0'>
|
||||
<CheckCircle2 className='h-4 w-4 text-green-600 dark:text-green-400' />
|
||||
</div>
|
||||
<div className='mr-4 flex-1 space-y-2'>
|
||||
<AlertTitle className='-mt-0.5 flex items-center justify-between'>
|
||||
<span className='font-medium text-green-600 dark:text-green-400'>
|
||||
Success
|
||||
</span>
|
||||
</AlertTitle>
|
||||
<AlertDescription className='text-green-600 dark:text-green-400'>
|
||||
{submitStatus.message}
|
||||
</AlertDescription>
|
||||
</div>
|
||||
</div>
|
||||
</Alert>
|
||||
) : submitStatus && submitStatus.type === 'error' ? (
|
||||
{submitStatus && submitStatus.type === 'error' && (
|
||||
<Alert variant='destructive' className='mb-6'>
|
||||
<AlertCircle className='h-4 w-4' />
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{submitStatus.message}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
)}
|
||||
|
||||
{/* Form Fields Section - Fixed at top */}
|
||||
<div className='flex-shrink-0 space-y-4'>
|
||||
@@ -611,8 +603,8 @@ export function CreateModal({ open, onOpenChange, onKnowledgeBaseCreated }: Crea
|
||||
</Button>
|
||||
<Button
|
||||
type='submit'
|
||||
disabled={isSubmitting}
|
||||
className='bg-[var(--brand-primary-hex)] font-[480] text-primary-foreground shadow-[0_0_0_0_var(--brand-primary-hex)] transition-all duration-200 hover:bg-[var(--brand-primary-hover-hex)] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)]'
|
||||
disabled={isSubmitting || !nameValue?.trim()}
|
||||
className='bg-[var(--brand-primary-hex)] font-[480] text-primary-foreground shadow-[0_0_0_0_var(--brand-primary-hex)] transition-all duration-200 hover:bg-[var(--brand-primary-hover-hex)] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)] disabled:opacity-50 disabled:hover:shadow-none'
|
||||
>
|
||||
{isSubmitting ? 'Creating...' : 'Create Knowledge Base'}
|
||||
</Button>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { ChevronDown, ChevronUp, Eye, X } from 'lucide-react'
|
||||
import { ChevronDown, ChevronUp, Eye, Loader2, X } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { CopyButton } from '@/components/ui/copy-button'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
@@ -209,29 +209,30 @@ export function Sidebar({
|
||||
}
|
||||
}, [log?.id])
|
||||
|
||||
const isLoadingDetails = useMemo(() => {
|
||||
if (!log) return false
|
||||
// Only show while we expect details to arrive (has executionId)
|
||||
if (!log.executionId) return false
|
||||
const hasEnhanced = !!log.executionData?.enhanced
|
||||
const hasAnyDetails = hasEnhanced || !!log.cost || Array.isArray(log.executionData?.traceSpans)
|
||||
return !hasAnyDetails
|
||||
}, [log])
|
||||
|
||||
const formattedContent = useMemo(() => {
|
||||
if (!log) return null
|
||||
|
||||
let blockInput: Record<string, any> | undefined
|
||||
|
||||
if (log.metadata?.blockInput) {
|
||||
blockInput = log.metadata.blockInput
|
||||
} else if (log.metadata?.traceSpans) {
|
||||
const blockIdMatch = log.message.match(/Block .+?(\d+)/i)
|
||||
const blockId = blockIdMatch ? blockIdMatch[1] : null
|
||||
|
||||
if (blockId) {
|
||||
const matchingSpan = log.metadata.traceSpans.find(
|
||||
(span) => span.blockId === blockId || span.name.includes(`Block ${blockId}`)
|
||||
)
|
||||
|
||||
if (matchingSpan?.input) {
|
||||
blockInput = matchingSpan.input
|
||||
}
|
||||
if (log.executionData?.blockInput) {
|
||||
blockInput = log.executionData.blockInput
|
||||
} else if (log.executionData?.traceSpans) {
|
||||
const firstSpanWithInput = log.executionData.traceSpans.find((s) => s.input)
|
||||
if (firstSpanWithInput?.input) {
|
||||
blockInput = firstSpanWithInput.input as any
|
||||
}
|
||||
}
|
||||
|
||||
return formatJsonContent(log.message, blockInput)
|
||||
return null
|
||||
}, [log])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -243,22 +244,16 @@ export function Sidebar({
|
||||
// Determine if this is a workflow execution log
|
||||
const isWorkflowExecutionLog = useMemo(() => {
|
||||
if (!log) return false
|
||||
// Check if message contains workflow execution phrases (success or failure)
|
||||
return (
|
||||
log.message.toLowerCase().includes('workflow executed') ||
|
||||
log.message.toLowerCase().includes('execution completed') ||
|
||||
log.message.toLowerCase().includes('workflow execution failed') ||
|
||||
log.message.toLowerCase().includes('execution failed') ||
|
||||
(log.trigger === 'manual' && log.duration) ||
|
||||
// Also check if we have enhanced logging metadata with trace spans
|
||||
(log.metadata?.enhanced && log.metadata?.traceSpans)
|
||||
(log.trigger === 'manual' && !!log.duration) ||
|
||||
(log.executionData?.enhanced && log.executionData?.traceSpans)
|
||||
)
|
||||
}, [log])
|
||||
|
||||
// Helper to determine if we have cost information to display
|
||||
// All workflow executions now have cost info (base charge + any model costs)
|
||||
const hasCostInfo = useMemo(() => {
|
||||
return isWorkflowExecutionLog && log?.metadata?.cost
|
||||
return isWorkflowExecutionLog && log?.cost
|
||||
}, [log, isWorkflowExecutionLog])
|
||||
|
||||
const isWorkflowWithCost = useMemo(() => {
|
||||
@@ -490,6 +485,14 @@ export function Sidebar({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Suspense while details load (positioned after summary fields) */}
|
||||
{isLoadingDetails && (
|
||||
<div className='flex w-full items-center justify-start gap-2 py-2 text-muted-foreground'>
|
||||
<Loader2 className='h-4 w-4 animate-spin' />
|
||||
<span className='text-sm'>Loading details…</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Files */}
|
||||
{log.files && log.files.length > 0 && (
|
||||
<div>
|
||||
@@ -541,19 +544,15 @@ export function Sidebar({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Message Content */}
|
||||
<div className='w-full pb-2'>
|
||||
<h3 className='mb-1 font-medium text-muted-foreground text-xs'>Message</h3>
|
||||
<div className='w-full'>{formattedContent}</div>
|
||||
</div>
|
||||
{/* end suspense */}
|
||||
|
||||
{/* Trace Spans (if available and this is a workflow execution log) */}
|
||||
{isWorkflowExecutionLog && log.metadata?.traceSpans && (
|
||||
{isWorkflowExecutionLog && log.executionData?.traceSpans && (
|
||||
<div className='w-full'>
|
||||
<div className='w-full overflow-x-hidden'>
|
||||
<TraceSpansDisplay
|
||||
traceSpans={log.metadata.traceSpans}
|
||||
totalDuration={log.metadata.totalDuration}
|
||||
traceSpans={log.executionData.traceSpans}
|
||||
totalDuration={log.executionData.totalDuration}
|
||||
onExpansionChange={handleTraceSpanToggle}
|
||||
/>
|
||||
</div>
|
||||
@@ -561,11 +560,11 @@ export function Sidebar({
|
||||
)}
|
||||
|
||||
{/* Tool Calls (if available) */}
|
||||
{log.metadata?.toolCalls && log.metadata.toolCalls.length > 0 && (
|
||||
{log.executionData?.toolCalls && log.executionData.toolCalls.length > 0 && (
|
||||
<div className='w-full'>
|
||||
<h3 className='mb-1 font-medium text-muted-foreground text-xs'>Tool Calls</h3>
|
||||
<div className='w-full overflow-x-hidden rounded-md bg-secondary/30 p-3'>
|
||||
<ToolCallsDisplay metadata={log.metadata} />
|
||||
<ToolCallsDisplay metadata={log.executionData} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -584,86 +583,80 @@ export function Sidebar({
|
||||
</div>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='text-muted-foreground text-sm'>Model Input:</span>
|
||||
<span className='text-sm'>
|
||||
{formatCost(log.metadata?.cost?.input || 0)}
|
||||
</span>
|
||||
<span className='text-sm'>{formatCost(log.cost?.input || 0)}</span>
|
||||
</div>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='text-muted-foreground text-sm'>Model Output:</span>
|
||||
<span className='text-sm'>
|
||||
{formatCost(log.metadata?.cost?.output || 0)}
|
||||
</span>
|
||||
<span className='text-sm'>{formatCost(log.cost?.output || 0)}</span>
|
||||
</div>
|
||||
<div className='mt-1 flex items-center justify-between border-t pt-2'>
|
||||
<span className='text-muted-foreground text-sm'>Total:</span>
|
||||
<span className='text-foreground text-sm'>
|
||||
{formatCost(log.metadata?.cost?.total || 0)}
|
||||
{formatCost(log.cost?.total || 0)}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='text-muted-foreground text-xs'>Tokens:</span>
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{log.metadata?.cost?.tokens?.prompt || 0} in /{' '}
|
||||
{log.metadata?.cost?.tokens?.completion || 0} out
|
||||
{log.cost?.tokens?.prompt || 0} in / {log.cost?.tokens?.completion || 0}{' '}
|
||||
out
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Models Breakdown */}
|
||||
{log.metadata?.cost?.models &&
|
||||
Object.keys(log.metadata?.cost?.models).length > 0 && (
|
||||
<div className='border-t'>
|
||||
<button
|
||||
onClick={() => setIsModelsExpanded(!isModelsExpanded)}
|
||||
className='flex w-full items-center justify-between p-3 text-left transition-colors hover:bg-muted/50'
|
||||
>
|
||||
<span className='font-medium text-muted-foreground text-xs'>
|
||||
Model Breakdown (
|
||||
{Object.keys(log.metadata?.cost?.models || {}).length})
|
||||
</span>
|
||||
{isModelsExpanded ? (
|
||||
<ChevronUp className='h-3 w-3 text-muted-foreground' />
|
||||
) : (
|
||||
<ChevronDown className='h-3 w-3 text-muted-foreground' />
|
||||
)}
|
||||
</button>
|
||||
{log.cost?.models && Object.keys(log.cost?.models).length > 0 && (
|
||||
<div className='border-t'>
|
||||
<button
|
||||
onClick={() => setIsModelsExpanded(!isModelsExpanded)}
|
||||
className='flex w-full items-center justify-between p-3 text-left transition-colors hover:bg-muted/50'
|
||||
>
|
||||
<span className='font-medium text-muted-foreground text-xs'>
|
||||
Model Breakdown ({Object.keys(log.cost?.models || {}).length})
|
||||
</span>
|
||||
{isModelsExpanded ? (
|
||||
<ChevronUp className='h-3 w-3 text-muted-foreground' />
|
||||
) : (
|
||||
<ChevronDown className='h-3 w-3 text-muted-foreground' />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isModelsExpanded && (
|
||||
<div className='space-y-3 border-t bg-muted/30 p-3'>
|
||||
{Object.entries(log.metadata?.cost?.models || {}).map(
|
||||
([model, cost]: [string, any]) => (
|
||||
<div key={model} className='space-y-1'>
|
||||
<div className='font-medium font-mono text-xs'>{model}</div>
|
||||
<div className='space-y-1 text-xs'>
|
||||
<div className='flex justify-between'>
|
||||
<span className='text-muted-foreground'>Input:</span>
|
||||
<span>{formatCost(cost.input || 0)}</span>
|
||||
</div>
|
||||
<div className='flex justify-between'>
|
||||
<span className='text-muted-foreground'>Output:</span>
|
||||
<span>{formatCost(cost.output || 0)}</span>
|
||||
</div>
|
||||
<div className='flex justify-between border-t pt-1'>
|
||||
<span className='text-muted-foreground'>Total:</span>
|
||||
<span className='font-medium'>
|
||||
{formatCost(cost.total || 0)}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex justify-between'>
|
||||
<span className='text-muted-foreground'>Tokens:</span>
|
||||
<span>
|
||||
{cost.tokens?.prompt || 0} in /{' '}
|
||||
{cost.tokens?.completion || 0} out
|
||||
</span>
|
||||
</div>
|
||||
{isModelsExpanded && (
|
||||
<div className='space-y-3 border-t bg-muted/30 p-3'>
|
||||
{Object.entries(log.cost?.models || {}).map(
|
||||
([model, cost]: [string, any]) => (
|
||||
<div key={model} className='space-y-1'>
|
||||
<div className='font-medium font-mono text-xs'>{model}</div>
|
||||
<div className='space-y-1 text-xs'>
|
||||
<div className='flex justify-between'>
|
||||
<span className='text-muted-foreground'>Input:</span>
|
||||
<span>{formatCost(cost.input || 0)}</span>
|
||||
</div>
|
||||
<div className='flex justify-between'>
|
||||
<span className='text-muted-foreground'>Output:</span>
|
||||
<span>{formatCost(cost.output || 0)}</span>
|
||||
</div>
|
||||
<div className='flex justify-between border-t pt-1'>
|
||||
<span className='text-muted-foreground'>Total:</span>
|
||||
<span className='font-medium'>
|
||||
{formatCost(cost.total || 0)}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex justify-between'>
|
||||
<span className='text-muted-foreground'>Tokens:</span>
|
||||
<span>
|
||||
{cost.tokens?.prompt || 0} in /{' '}
|
||||
{cost.tokens?.completion || 0} out
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isWorkflowWithCost && (
|
||||
<div className='border-t bg-muted p-3 text-muted-foreground text-xs'>
|
||||
@@ -688,7 +681,7 @@ export function Sidebar({
|
||||
executionId={log.executionId}
|
||||
workflowName={log.workflow?.name}
|
||||
trigger={log.trigger || undefined}
|
||||
traceSpans={log.metadata?.traceSpans}
|
||||
traceSpans={log.executionData?.traceSpans}
|
||||
isOpen={isFrozenCanvasOpen}
|
||||
onClose={() => setIsFrozenCanvasOpen(false)}
|
||||
/>
|
||||
|
||||
@@ -82,14 +82,21 @@ function transformBlockData(data: any, blockType: string, isInput: boolean) {
|
||||
interface CollapsibleInputOutputProps {
|
||||
span: TraceSpan
|
||||
spanId: string
|
||||
depth: number
|
||||
}
|
||||
|
||||
function CollapsibleInputOutput({ span, spanId }: CollapsibleInputOutputProps) {
|
||||
function CollapsibleInputOutput({ span, spanId, depth }: CollapsibleInputOutputProps) {
|
||||
const [inputExpanded, setInputExpanded] = useState(false)
|
||||
const [outputExpanded, setOutputExpanded] = useState(false)
|
||||
|
||||
// Calculate the left margin based on depth to match the parent span's indentation
|
||||
const leftMargin = depth * 16 + 8 + 24 // Base depth indentation + icon width + extra padding
|
||||
|
||||
return (
|
||||
<div className='mt-2 mr-4 mb-4 ml-8 space-y-3 overflow-hidden'>
|
||||
<div
|
||||
className='mt-2 mr-4 mb-4 space-y-3 overflow-hidden'
|
||||
style={{ marginLeft: `${leftMargin}px` }}
|
||||
>
|
||||
{/* Input Data - Collapsible */}
|
||||
{span.input && (
|
||||
<div>
|
||||
@@ -162,26 +169,30 @@ function BlockDataDisplay({
|
||||
if (value === undefined) return <span className='text-muted-foreground italic'>undefined</span>
|
||||
|
||||
if (typeof value === 'string') {
|
||||
return <span className='break-all text-green-700 dark:text-green-400'>"{value}"</span>
|
||||
return <span className='break-all text-emerald-700 dark:text-emerald-400'>"{value}"</span>
|
||||
}
|
||||
|
||||
if (typeof value === 'number') {
|
||||
return <span className='text-blue-700 dark:text-blue-400'>{value}</span>
|
||||
return <span className='font-mono text-blue-700 dark:text-blue-400'>{value}</span>
|
||||
}
|
||||
|
||||
if (typeof value === 'boolean') {
|
||||
return <span className='text-purple-700 dark:text-purple-400'>{value.toString()}</span>
|
||||
return (
|
||||
<span className='font-mono text-amber-700 dark:text-amber-400'>{value.toString()}</span>
|
||||
)
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) return <span className='text-muted-foreground'>[]</span>
|
||||
return (
|
||||
<div className='space-y-1'>
|
||||
<div className='space-y-0.5'>
|
||||
<span className='text-muted-foreground'>[</span>
|
||||
<div className='ml-4 space-y-1'>
|
||||
<div className='ml-2 space-y-0.5'>
|
||||
{value.map((item, index) => (
|
||||
<div key={index} className='flex min-w-0 gap-2'>
|
||||
<span className='flex-shrink-0 text-muted-foreground text-xs'>{index}:</span>
|
||||
<div key={index} className='flex min-w-0 gap-1.5'>
|
||||
<span className='flex-shrink-0 font-mono text-slate-600 text-xs dark:text-slate-400'>
|
||||
{index}:
|
||||
</span>
|
||||
<div className='min-w-0 flex-1 overflow-hidden'>{renderValue(item)}</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -196,10 +207,10 @@ function BlockDataDisplay({
|
||||
if (entries.length === 0) return <span className='text-muted-foreground'>{'{}'}</span>
|
||||
|
||||
return (
|
||||
<div className='space-y-1'>
|
||||
<div className='space-y-0.5'>
|
||||
{entries.map(([objKey, objValue]) => (
|
||||
<div key={objKey} className='flex min-w-0 gap-2'>
|
||||
<span className='flex-shrink-0 font-medium text-orange-700 dark:text-orange-400'>
|
||||
<div key={objKey} className='flex min-w-0 gap-1.5'>
|
||||
<span className='flex-shrink-0 font-medium text-indigo-700 dark:text-indigo-400'>
|
||||
{objKey}:
|
||||
</span>
|
||||
<div className='min-w-0 flex-1 overflow-hidden'>{renderValue(objValue, objKey)}</div>
|
||||
@@ -227,12 +238,12 @@ function BlockDataDisplay({
|
||||
{transformedData &&
|
||||
Object.keys(transformedData).filter((key) => key !== 'error' && key !== 'success')
|
||||
.length > 0 && (
|
||||
<div className='space-y-1'>
|
||||
<div className='space-y-0.5'>
|
||||
{Object.entries(transformedData)
|
||||
.filter(([key]) => key !== 'error' && key !== 'success')
|
||||
.map(([key, value]) => (
|
||||
<div key={key} className='flex gap-2'>
|
||||
<span className='font-medium text-orange-700 dark:text-orange-400'>{key}:</span>
|
||||
<div key={key} className='flex gap-1.5'>
|
||||
<span className='font-medium text-indigo-700 dark:text-indigo-400'>{key}:</span>
|
||||
{renderValue(value, key)}
|
||||
</div>
|
||||
))}
|
||||
@@ -592,7 +603,9 @@ function TraceSpanItem({
|
||||
{expanded && (
|
||||
<div>
|
||||
{/* Block Input/Output Data - Collapsible */}
|
||||
{(span.input || span.output) && <CollapsibleInputOutput span={span} spanId={spanId} />}
|
||||
{(span.input || span.output) && (
|
||||
<CollapsibleInputOutput span={span} spanId={spanId} depth={depth} />
|
||||
)}
|
||||
|
||||
{/* Children and tool calls */}
|
||||
{/* Render child spans */}
|
||||
|
||||
@@ -85,6 +85,10 @@ export default function Logs() {
|
||||
const [selectedLog, setSelectedLog] = useState<WorkflowLog | null>(null)
|
||||
const [selectedLogIndex, setSelectedLogIndex] = useState<number>(-1)
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(false)
|
||||
const [isDetailsLoading, setIsDetailsLoading] = useState(false)
|
||||
const detailsCacheRef = useRef<Map<string, any>>(new Map())
|
||||
const detailsAbortRef = useRef<AbortController | null>(null)
|
||||
const currentDetailsIdRef = useRef<string | null>(null)
|
||||
const selectedRowRef = useRef<HTMLTableRowElement | null>(null)
|
||||
const loaderRef = useRef<HTMLDivElement>(null)
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null)
|
||||
@@ -116,13 +120,122 @@ export default function Logs() {
|
||||
const index = logs.findIndex((l) => l.id === log.id)
|
||||
setSelectedLogIndex(index)
|
||||
setIsSidebarOpen(true)
|
||||
setIsDetailsLoading(true)
|
||||
|
||||
// Fetch details for current, previous, and next concurrently with cache
|
||||
const currentId = log.id
|
||||
const prevId = index > 0 ? logs[index - 1]?.id : undefined
|
||||
const nextId = index < logs.length - 1 ? logs[index + 1]?.id : undefined
|
||||
|
||||
// Abort any previous details fetch batch
|
||||
if (detailsAbortRef.current) {
|
||||
try {
|
||||
detailsAbortRef.current.abort()
|
||||
} catch {
|
||||
/* no-op */
|
||||
}
|
||||
}
|
||||
const controller = new AbortController()
|
||||
detailsAbortRef.current = controller
|
||||
currentDetailsIdRef.current = currentId
|
||||
|
||||
const idsToFetch: Array<{ id: string; merge: boolean }> = []
|
||||
const cachedCurrent = currentId ? detailsCacheRef.current.get(currentId) : undefined
|
||||
if (currentId && !cachedCurrent) idsToFetch.push({ id: currentId, merge: true })
|
||||
if (prevId && !detailsCacheRef.current.has(prevId))
|
||||
idsToFetch.push({ id: prevId, merge: false })
|
||||
if (nextId && !detailsCacheRef.current.has(nextId))
|
||||
idsToFetch.push({ id: nextId, merge: false })
|
||||
|
||||
// Merge cached current immediately
|
||||
if (cachedCurrent) {
|
||||
setSelectedLog((prev) =>
|
||||
prev && prev.id === currentId
|
||||
? ({ ...(prev as any), ...(cachedCurrent as any) } as any)
|
||||
: prev
|
||||
)
|
||||
setIsDetailsLoading(false)
|
||||
}
|
||||
if (idsToFetch.length === 0) return
|
||||
|
||||
Promise.all(
|
||||
idsToFetch.map(async ({ id, merge }) => {
|
||||
try {
|
||||
const res = await fetch(`/api/logs/by-id/${id}`, { signal: controller.signal })
|
||||
if (!res.ok) return
|
||||
const body = await res.json()
|
||||
const detailed = body?.data
|
||||
if (detailed) {
|
||||
detailsCacheRef.current.set(id, detailed)
|
||||
if (merge && id === currentId) {
|
||||
setSelectedLog((prev) =>
|
||||
prev && prev.id === id ? ({ ...(prev as any), ...(detailed as any) } as any) : prev
|
||||
)
|
||||
if (currentDetailsIdRef.current === id) setIsDetailsLoading(false)
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e?.name === 'AbortError') return
|
||||
}
|
||||
})
|
||||
).catch(() => {})
|
||||
}
|
||||
|
||||
const handleNavigateNext = useCallback(() => {
|
||||
if (selectedLogIndex < logs.length - 1) {
|
||||
const nextIndex = selectedLogIndex + 1
|
||||
setSelectedLogIndex(nextIndex)
|
||||
setSelectedLog(logs[nextIndex])
|
||||
const nextLog = logs[nextIndex]
|
||||
setSelectedLog(nextLog)
|
||||
// Abort any previous details fetch batch
|
||||
if (detailsAbortRef.current) {
|
||||
try {
|
||||
detailsAbortRef.current.abort()
|
||||
} catch {
|
||||
/* no-op */
|
||||
}
|
||||
}
|
||||
const controller = new AbortController()
|
||||
detailsAbortRef.current = controller
|
||||
|
||||
const cached = detailsCacheRef.current.get(nextLog.id)
|
||||
if (cached) {
|
||||
setSelectedLog((prev) =>
|
||||
prev && prev.id === nextLog.id ? ({ ...(prev as any), ...(cached as any) } as any) : prev
|
||||
)
|
||||
} else {
|
||||
const prevId = nextIndex > 0 ? logs[nextIndex - 1]?.id : undefined
|
||||
const afterId = nextIndex < logs.length - 1 ? logs[nextIndex + 1]?.id : undefined
|
||||
const idsToFetch: Array<{ id: string; merge: boolean }> = []
|
||||
if (nextLog.id && !detailsCacheRef.current.has(nextLog.id))
|
||||
idsToFetch.push({ id: nextLog.id, merge: true })
|
||||
if (prevId && !detailsCacheRef.current.has(prevId))
|
||||
idsToFetch.push({ id: prevId, merge: false })
|
||||
if (afterId && !detailsCacheRef.current.has(afterId))
|
||||
idsToFetch.push({ id: afterId, merge: false })
|
||||
Promise.all(
|
||||
idsToFetch.map(async ({ id, merge }) => {
|
||||
try {
|
||||
const res = await fetch(`/api/logs/by-id/${id}`, { signal: controller.signal })
|
||||
if (!res.ok) return
|
||||
const body = await res.json()
|
||||
const detailed = body?.data
|
||||
if (detailed) {
|
||||
detailsCacheRef.current.set(id, detailed)
|
||||
if (merge && id === nextLog.id) {
|
||||
setSelectedLog((prev) =>
|
||||
prev && prev.id === id
|
||||
? ({ ...(prev as any), ...(detailed as any) } as any)
|
||||
: prev
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e?.name === 'AbortError') return
|
||||
}
|
||||
})
|
||||
).catch(() => {})
|
||||
}
|
||||
}
|
||||
}, [selectedLogIndex, logs])
|
||||
|
||||
@@ -130,7 +243,57 @@ export default function Logs() {
|
||||
if (selectedLogIndex > 0) {
|
||||
const prevIndex = selectedLogIndex - 1
|
||||
setSelectedLogIndex(prevIndex)
|
||||
setSelectedLog(logs[prevIndex])
|
||||
const prevLog = logs[prevIndex]
|
||||
setSelectedLog(prevLog)
|
||||
// Abort any previous details fetch batch
|
||||
if (detailsAbortRef.current) {
|
||||
try {
|
||||
detailsAbortRef.current.abort()
|
||||
} catch {
|
||||
/* no-op */
|
||||
}
|
||||
}
|
||||
const controller = new AbortController()
|
||||
detailsAbortRef.current = controller
|
||||
|
||||
const cached = detailsCacheRef.current.get(prevLog.id)
|
||||
if (cached) {
|
||||
setSelectedLog((prev) =>
|
||||
prev && prev.id === prevLog.id ? ({ ...(prev as any), ...(cached as any) } as any) : prev
|
||||
)
|
||||
} else {
|
||||
const beforeId = prevIndex > 0 ? logs[prevIndex - 1]?.id : undefined
|
||||
const afterId = prevIndex < logs.length - 1 ? logs[prevIndex + 1]?.id : undefined
|
||||
const idsToFetch: Array<{ id: string; merge: boolean }> = []
|
||||
if (prevLog.id && !detailsCacheRef.current.has(prevLog.id))
|
||||
idsToFetch.push({ id: prevLog.id, merge: true })
|
||||
if (beforeId && !detailsCacheRef.current.has(beforeId))
|
||||
idsToFetch.push({ id: beforeId, merge: false })
|
||||
if (afterId && !detailsCacheRef.current.has(afterId))
|
||||
idsToFetch.push({ id: afterId, merge: false })
|
||||
Promise.all(
|
||||
idsToFetch.map(async ({ id, merge }) => {
|
||||
try {
|
||||
const res = await fetch(`/api/logs/by-id/${id}`, { signal: controller.signal })
|
||||
if (!res.ok) return
|
||||
const body = await res.json()
|
||||
const detailed = body?.data
|
||||
if (detailed) {
|
||||
detailsCacheRef.current.set(id, detailed)
|
||||
if (merge && id === prevLog.id) {
|
||||
setSelectedLog((prev) =>
|
||||
prev && prev.id === id
|
||||
? ({ ...(prev as any), ...(detailed as any) } as any)
|
||||
: prev
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e?.name === 'AbortError') return
|
||||
}
|
||||
})
|
||||
).catch(() => {})
|
||||
}
|
||||
}
|
||||
}, [selectedLogIndex, logs])
|
||||
|
||||
@@ -160,7 +323,7 @@ export default function Logs() {
|
||||
// Get fresh query params by calling buildQueryParams from store
|
||||
const { buildQueryParams: getCurrentQueryParams } = useFilterStore.getState()
|
||||
const queryParams = getCurrentQueryParams(pageNum, LOGS_PER_PAGE)
|
||||
const response = await fetch(`/api/logs?${queryParams}`)
|
||||
const response = await fetch(`/api/logs?${queryParams}&details=basic`)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Error fetching logs: ${response.statusText}`)
|
||||
@@ -262,7 +425,7 @@ export default function Logs() {
|
||||
|
||||
// Build query params inline to avoid dependency issues
|
||||
const params = new URLSearchParams()
|
||||
params.set('includeWorkflow', 'true')
|
||||
params.set('details', 'basic')
|
||||
params.set('limit', LOGS_PER_PAGE.toString())
|
||||
params.set('offset', '0') // Always start from page 1
|
||||
params.set('workspaceId', workspaceId)
|
||||
@@ -482,7 +645,7 @@ export default function Logs() {
|
||||
{/* Header */}
|
||||
<div>
|
||||
<div className='border-border border-b'>
|
||||
<div className='grid min-w-[600px] grid-cols-[120px_80px_120px_80px_1fr] gap-2 px-2 pb-3 md:grid-cols-[140px_90px_140px_90px_1fr] md:gap-3 lg:min-w-0 lg:grid-cols-[160px_100px_160px_100px_1fr] lg:gap-4 xl:grid-cols-[160px_100px_160px_100px_100px_1fr_100px]'>
|
||||
<div className='grid min-w-[600px] grid-cols-[120px_80px_120px_120px] gap-2 px-2 pb-3 md:grid-cols-[140px_90px_140px_120px] md:gap-3 lg:min-w-0 lg:grid-cols-[160px_100px_160px_120px] lg:gap-4 xl:grid-cols-[160px_100px_160px_120px_120px_100px]'>
|
||||
<div className='font-[480] font-sans text-[13px] text-muted-foreground leading-normal'>
|
||||
Time
|
||||
</div>
|
||||
@@ -493,14 +656,12 @@ export default function Logs() {
|
||||
Workflow
|
||||
</div>
|
||||
<div className='font-[480] font-sans text-[13px] text-muted-foreground leading-normal'>
|
||||
ID
|
||||
Cost
|
||||
</div>
|
||||
<div className='hidden font-[480] font-sans text-[13px] text-muted-foreground leading-normal xl:block'>
|
||||
Trigger
|
||||
</div>
|
||||
<div className='font-[480] font-sans text-[13px] text-muted-foreground leading-normal'>
|
||||
Message
|
||||
</div>
|
||||
|
||||
<div className='hidden font-[480] font-sans text-[13px] text-muted-foreground leading-normal xl:block'>
|
||||
Duration
|
||||
</div>
|
||||
@@ -547,7 +708,7 @@ export default function Logs() {
|
||||
}`}
|
||||
onClick={() => handleLogClick(log)}
|
||||
>
|
||||
<div className='grid min-w-[600px] grid-cols-[120px_80px_120px_80px_1fr] items-center gap-2 px-2 py-4 md:grid-cols-[140px_90px_140px_90px_1fr] md:gap-3 lg:min-w-0 lg:grid-cols-[160px_100px_160px_100px_1fr] lg:gap-4 xl:grid-cols-[160px_100px_160px_100px_100px_1fr_100px]'>
|
||||
<div className='grid min-w-[600px] grid-cols-[120px_80px_120px_120px] items-center gap-2 px-2 py-4 md:grid-cols-[140px_90px_140px_120px] md:gap-3 lg:min-w-0 lg:grid-cols-[160px_100px_160px_120px] lg:gap-4 xl:grid-cols-[160px_100px_160px_120px_120px_100px]'>
|
||||
{/* Time */}
|
||||
<div>
|
||||
<div className='text-[13px]'>
|
||||
@@ -584,10 +745,12 @@ export default function Logs() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ID */}
|
||||
{/* Cost */}
|
||||
<div>
|
||||
<div className='font-medium text-muted-foreground text-xs'>
|
||||
#{log.id.slice(-4)}
|
||||
{typeof (log as any)?.cost?.total === 'number'
|
||||
? `$${((log as any).cost.total as number).toFixed(4)}`
|
||||
: '—'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -614,11 +777,6 @@ export default function Logs() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Message */}
|
||||
<div className='min-w-0'>
|
||||
<div className='truncate font-[420] text-[13px]'>{log.message}</div>
|
||||
</div>
|
||||
|
||||
{/* Duration */}
|
||||
<div className='hidden xl:block'>
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
import React from 'react'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import { ThemeProvider } from '@/app/workspace/[workspaceId]/providers/theme-provider'
|
||||
import { WorkspacePermissionsProvider } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import { SettingsLoader } from './settings-loader'
|
||||
|
||||
interface ProvidersProps {
|
||||
children: React.ReactNode
|
||||
@@ -11,11 +11,12 @@ interface ProvidersProps {
|
||||
|
||||
const Providers = React.memo<ProvidersProps>(({ children }) => {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<>
|
||||
<SettingsLoader />
|
||||
<TooltipProvider delayDuration={100} skipDelayDuration={0}>
|
||||
<WorkspacePermissionsProvider>{children}</WorkspacePermissionsProvider>
|
||||
</TooltipProvider>
|
||||
</ThemeProvider>
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useSession } from '@/lib/auth-client'
|
||||
import { useGeneralStore } from '@/stores/settings/general/store'
|
||||
|
||||
/**
|
||||
* Loads user settings from database once per workspace session.
|
||||
* This ensures settings are synced from DB on initial load but uses
|
||||
* localStorage cache for subsequent navigation within the app.
|
||||
*/
|
||||
export function SettingsLoader() {
|
||||
const { data: session, isPending: isSessionPending } = useSession()
|
||||
const loadSettings = useGeneralStore((state) => state.loadSettings)
|
||||
const hasLoadedRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
// Only load settings once per session for authenticated users
|
||||
if (!isSessionPending && session?.user && !hasLoadedRef.current) {
|
||||
hasLoadedRef.current = true
|
||||
// Force load from DB on initial workspace entry
|
||||
loadSettings(true)
|
||||
}
|
||||
}, [isSessionPending, session?.user, loadSettings])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { useGeneralStore } from '@/stores/settings/general/store'
|
||||
|
||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
const theme = useGeneralStore((state) => state.theme)
|
||||
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement
|
||||
root.classList.remove('light', 'dark')
|
||||
|
||||
// If theme is system, check system preference
|
||||
if (theme === 'system') {
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
root.classList.add(prefersDark ? 'dark' : 'light')
|
||||
} else {
|
||||
root.classList.add(theme)
|
||||
}
|
||||
}, [theme])
|
||||
|
||||
return children
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Download } from 'lucide-react'
|
||||
import { Upload } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
@@ -81,7 +81,7 @@ export function ExportControls({ disabled = false }: ExportControlsProps) {
|
||||
<TooltipTrigger asChild>
|
||||
{isDisabled ? (
|
||||
<div className='inline-flex h-12 w-12 cursor-not-allowed items-center justify-center rounded-[11px] border bg-card text-card-foreground opacity-50 shadow-xs transition-colors'>
|
||||
<Download className='h-5 w-5' />
|
||||
<Upload className='h-5 w-5' />
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
@@ -89,7 +89,7 @@ export function ExportControls({ disabled = false }: ExportControlsProps) {
|
||||
onClick={handleExportYaml}
|
||||
className='h-12 w-12 rounded-[11px] border bg-card text-card-foreground shadow-xs hover:bg-secondary'
|
||||
>
|
||||
<Download className='h-5 w-5' />
|
||||
<Upload className='h-5 w-5' />
|
||||
<span className='sr-only'>Export as YAML</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -32,7 +32,6 @@ import {
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui'
|
||||
import { useSession } from '@/lib/auth-client'
|
||||
import { isDev } from '@/lib/environment'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
@@ -341,10 +340,11 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
|
||||
* Handle deleting the current workflow
|
||||
*/
|
||||
const handleDeleteWorkflow = () => {
|
||||
if (!activeWorkflowId || !userPermissions.canEdit) return
|
||||
const currentWorkflowId = params.workflowId as string
|
||||
if (!currentWorkflowId || !userPermissions.canEdit) return
|
||||
|
||||
const sidebarWorkflows = getSidebarOrderedWorkflows()
|
||||
const currentIndex = sidebarWorkflows.findIndex((w) => w.id === activeWorkflowId)
|
||||
const currentIndex = sidebarWorkflows.findIndex((w) => w.id === currentWorkflowId)
|
||||
|
||||
// Find next workflow: try next, then previous
|
||||
let nextWorkflowId: string | null = null
|
||||
@@ -363,8 +363,8 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
|
||||
router.push(`/workspace/${workspaceId}`)
|
||||
}
|
||||
|
||||
// Remove the workflow from the registry
|
||||
useWorkflowRegistry.getState().removeWorkflow(activeWorkflowId)
|
||||
// Remove the workflow from the registry using the URL parameter
|
||||
useWorkflowRegistry.getState().removeWorkflow(currentWorkflowId)
|
||||
}
|
||||
|
||||
// Helper function to open subscription settings
|
||||
@@ -413,7 +413,7 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className='inline-flex h-12 w-12 cursor-not-allowed items-center justify-center rounded-[11px] border bg-card text-card-foreground opacity-50 shadow-xs transition-colors'>
|
||||
<Trash2 className='h-5 w-5' />
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{getTooltipText()}</TooltipContent>
|
||||
@@ -498,7 +498,7 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
|
||||
<TooltipTrigger asChild>
|
||||
{isDisabled ? (
|
||||
<div className='inline-flex h-12 w-12 cursor-not-allowed items-center justify-center rounded-[11px] border bg-card text-card-foreground opacity-50 shadow-xs transition-colors'>
|
||||
<Copy className='h-5 w-5' />
|
||||
<Copy className='h-4 w-4' />
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
@@ -563,9 +563,9 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
|
||||
{isDisabled ? (
|
||||
<div className='inline-flex h-12 w-12 cursor-not-allowed items-center justify-center rounded-[11px] border bg-card text-card-foreground opacity-50 shadow-xs transition-colors'>
|
||||
{isAutoLayouting ? (
|
||||
<RefreshCw className='h-5 w-5 animate-spin' />
|
||||
<RefreshCw className='h-4 w-4 animate-spin' />
|
||||
) : (
|
||||
<Layers className='h-5 w-5' />
|
||||
<Layers className='h-4 w-4' />
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
@@ -721,7 +721,7 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
|
||||
<TooltipTrigger asChild>
|
||||
{isDisabled ? (
|
||||
<div className='inline-flex h-12 w-12 cursor-not-allowed items-center justify-center rounded-[11px] border bg-card text-card-foreground opacity-50 shadow-xs transition-colors'>
|
||||
<Store className='h-5 w-5' />
|
||||
<Store className='h-4 w-4' />
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
@@ -775,7 +775,7 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
|
||||
isDebugging && 'text-amber-500'
|
||||
)}
|
||||
>
|
||||
<Bug className='h-5 w-5' />
|
||||
<Bug className='h-4 w-4' />
|
||||
</div>
|
||||
) : (
|
||||
<Button variant='outline' onClick={handleDebugToggle} className={buttonClass}>
|
||||
@@ -999,14 +999,13 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
|
||||
return (
|
||||
<div className='fixed top-4 right-4 z-20 flex items-center gap-1'>
|
||||
{renderDisconnectionNotice()}
|
||||
{!isDev && renderToggleButton()}
|
||||
{isExpanded && !isDev && <ExportControls />}
|
||||
{isExpanded && !isDev && renderAutoLayoutButton()}
|
||||
{!isDev && isExpanded && renderDuplicateButton()}
|
||||
{isDev && renderDuplicateButton()}
|
||||
{renderToggleButton()}
|
||||
{isExpanded && <ExportControls />}
|
||||
{isExpanded && renderAutoLayoutButton()}
|
||||
{renderDuplicateButton()}
|
||||
{renderDeleteButton()}
|
||||
{!isDebugging && renderDebugModeToggle()}
|
||||
{renderPublishButton()}
|
||||
{isExpanded && renderPublishButton()}
|
||||
{renderDeployButton()}
|
||||
{isDebugging ? renderDebugControlsBar() : renderRunButton()}
|
||||
|
||||
|
||||
@@ -191,27 +191,27 @@ export function DiffControls() {
|
||||
logger.info('Accepting proposed changes with backup protection')
|
||||
|
||||
try {
|
||||
// Create a checkpoint before applying changes so it appears under the triggering user message
|
||||
await createCheckpoint().catch((error) => {
|
||||
logger.warn('Failed to create checkpoint before accept:', error)
|
||||
})
|
||||
|
||||
// Clear preview YAML immediately
|
||||
await clearPreviewYaml().catch((error) => {
|
||||
logger.warn('Failed to clear preview YAML:', error)
|
||||
})
|
||||
|
||||
// Accept changes with automatic backup and rollback on failure
|
||||
await acceptChanges()
|
||||
// Accept changes without blocking the UI; errors will be logged by the store handler
|
||||
acceptChanges().catch((error) => {
|
||||
logger.error('Failed to accept changes (background):', error)
|
||||
})
|
||||
|
||||
logger.info('Successfully accepted and saved workflow changes')
|
||||
// Show success feedback if needed
|
||||
logger.info('Accept triggered; UI will update optimistically')
|
||||
} catch (error) {
|
||||
logger.error('Failed to accept changes:', error)
|
||||
|
||||
// Show error notification to user
|
||||
// Note: The acceptChanges function has already rolled back the state
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'
|
||||
|
||||
// You could add toast notification here
|
||||
console.error('Workflow update failed:', errorMessage)
|
||||
|
||||
// Optionally show user-facing error dialog
|
||||
alert(`Failed to save workflow changes: ${errorMessage}`)
|
||||
}
|
||||
}
|
||||
@@ -224,10 +224,10 @@ export function DiffControls() {
|
||||
logger.warn('Failed to clear preview YAML:', error)
|
||||
})
|
||||
|
||||
// Reject is immediate (no server save needed)
|
||||
rejectChanges()
|
||||
|
||||
logger.info('Successfully rejected proposed changes')
|
||||
// Reject changes optimistically
|
||||
rejectChanges().catch((error) => {
|
||||
logger.error('Failed to reject changes (background):', error)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -4,6 +4,8 @@ import { Component, type ReactNode, useEffect } from 'react'
|
||||
import { BotIcon } from 'lucide-react'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { ControlBar } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/control-bar'
|
||||
import { Panel } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel'
|
||||
|
||||
const logger = createLogger('ErrorBoundary')
|
||||
|
||||
@@ -22,18 +24,32 @@ export function ErrorUI({
|
||||
fullScreen = false,
|
||||
}: ErrorUIProps) {
|
||||
const containerClass = fullScreen
|
||||
? 'flex items-center justify-center w-full h-screen bg-muted/40'
|
||||
: 'flex items-center justify-center w-full h-full bg-muted/40'
|
||||
? 'flex flex-col w-full h-screen bg-muted/40'
|
||||
: 'flex flex-col w-full h-full bg-muted/40'
|
||||
|
||||
return (
|
||||
<div className={containerClass}>
|
||||
<Card className='max-w-md space-y-4 p-6 text-center'>
|
||||
<div className='flex justify-center'>
|
||||
<BotIcon className='h-16 w-16 text-muted-foreground' />
|
||||
{/* Control bar */}
|
||||
<ControlBar hasValidationErrors={false} />
|
||||
|
||||
{/* Main content area */}
|
||||
<div className='relative flex flex-1'>
|
||||
{/* Error message */}
|
||||
<div className='flex flex-1 items-center justify-center'>
|
||||
<Card className='max-w-md space-y-4 p-6 text-center'>
|
||||
<div className='flex justify-center'>
|
||||
<BotIcon className='h-16 w-16 text-muted-foreground' />
|
||||
</div>
|
||||
<h3 className='font-semibold text-lg'>{title}</h3>
|
||||
<p className='text-muted-foreground'>{message}</p>
|
||||
</Card>
|
||||
</div>
|
||||
<h3 className='font-semibold text-lg'>{title}</h3>
|
||||
<p className='text-muted-foreground'>{message}</p>
|
||||
</Card>
|
||||
|
||||
{/* Console panel */}
|
||||
<div className='fixed top-0 right-0 z-10'>
|
||||
<Panel />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,8 +2,7 @@ export { ControlBar } from './control-bar/control-bar'
|
||||
export { ErrorBoundary } from './error/index'
|
||||
export { Panel } from './panel/panel'
|
||||
export { SkeletonLoading } from './skeleton-loading/skeleton-loading'
|
||||
export { LoopNodeComponent } from './subflows/loop/loop-node'
|
||||
export { ParallelNodeComponent } from './subflows/parallel/parallel-node'
|
||||
export { SubflowNodeComponent } from './subflows/subflow-node'
|
||||
export { WandPromptBar } from './wand-prompt-bar/wand-prompt-bar'
|
||||
export { WorkflowBlock } from './workflow-block/workflow-block'
|
||||
export { WorkflowEdge } from './workflow-edge/workflow-edge'
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import Image from 'next/image'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { redactApiKeys } from '@/lib/utils'
|
||||
import {
|
||||
CodeDisplay,
|
||||
JSONView,
|
||||
@@ -349,9 +350,10 @@ export function ConsoleEntry({ entry, consoleWidth }: ConsoleEntryProps) {
|
||||
// For code display, copy just the code string
|
||||
textToCopy = entry.input.code
|
||||
} else {
|
||||
// For regular JSON display, copy the full JSON
|
||||
// For regular JSON display, copy the full JSON with redaction applied
|
||||
const dataToCopy = showInput ? entry.input : entry.output
|
||||
textToCopy = JSON.stringify(dataToCopy, null, 2)
|
||||
const redactedData = redactApiKeys(dataToCopy)
|
||||
textToCopy = JSON.stringify(redactedData, null, 2)
|
||||
}
|
||||
|
||||
navigator.clipboard.writeText(textToCopy)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { redactApiKeys } from '@/lib/utils'
|
||||
|
||||
interface JSONViewProps {
|
||||
data: any
|
||||
@@ -154,6 +155,9 @@ export const JSONView = ({ data }: JSONViewProps) => {
|
||||
y: number
|
||||
} | null>(null)
|
||||
|
||||
// Apply redaction to the data before displaying
|
||||
const redactedData = redactApiKeys(data)
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
setContextMenuPosition({ x: e.clientX, y: e.clientY })
|
||||
@@ -167,18 +171,18 @@ export const JSONView = ({ data }: JSONViewProps) => {
|
||||
}
|
||||
}, [contextMenuPosition])
|
||||
|
||||
if (data === null)
|
||||
if (redactedData === null)
|
||||
return <span className='font-[380] text-muted-foreground leading-normal'>null</span>
|
||||
|
||||
// For non-object data, show simple JSON
|
||||
if (typeof data !== 'object') {
|
||||
const stringValue = JSON.stringify(data)
|
||||
if (typeof redactedData !== 'object') {
|
||||
const stringValue = JSON.stringify(redactedData)
|
||||
return (
|
||||
<span
|
||||
onContextMenu={handleContextMenu}
|
||||
className='relative max-w-full overflow-hidden break-all font-[380] font-mono text-muted-foreground leading-normal'
|
||||
>
|
||||
{typeof data === 'string' ? (
|
||||
{typeof redactedData === 'string' ? (
|
||||
<TruncatedValue value={stringValue} />
|
||||
) : (
|
||||
<span className='break-all font-[380] text-muted-foreground leading-normal'>
|
||||
@@ -192,7 +196,7 @@ export const JSONView = ({ data }: JSONViewProps) => {
|
||||
>
|
||||
<button
|
||||
className='w-full px-3 py-1.5 text-left font-[380] text-sm hover:bg-accent'
|
||||
onClick={() => copyToClipboard(data)}
|
||||
onClick={() => copyToClipboard(redactedData)}
|
||||
>
|
||||
Copy value
|
||||
</button>
|
||||
@@ -206,7 +210,7 @@ export const JSONView = ({ data }: JSONViewProps) => {
|
||||
return (
|
||||
<div onContextMenu={handleContextMenu}>
|
||||
<pre className='max-w-full overflow-hidden whitespace-pre-wrap break-all font-mono'>
|
||||
<CollapsibleJSON data={data} />
|
||||
<CollapsibleJSON data={redactedData} />
|
||||
</pre>
|
||||
{contextMenuPosition && (
|
||||
<div
|
||||
@@ -215,7 +219,7 @@ export const JSONView = ({ data }: JSONViewProps) => {
|
||||
>
|
||||
<button
|
||||
className='w-full px-3 py-1.5 text-left font-[380] text-sm hover:bg-accent'
|
||||
onClick={() => copyToClipboard(data)}
|
||||
onClick={() => copyToClipboard(redactedData)}
|
||||
>
|
||||
Copy object
|
||||
</button>
|
||||
|
||||
@@ -27,6 +27,18 @@ export function ThinkingBlock({
|
||||
}
|
||||
}, [persistedStartTime])
|
||||
|
||||
useEffect(() => {
|
||||
// Auto-collapse when streaming ends
|
||||
if (!isStreaming) {
|
||||
setIsExpanded(false)
|
||||
return
|
||||
}
|
||||
// Expand once there is visible content while streaming
|
||||
if (content && content.trim().length > 0) {
|
||||
setIsExpanded(true)
|
||||
}
|
||||
}, [isStreaming, content])
|
||||
|
||||
useEffect(() => {
|
||||
// If we already have a persisted duration, just use it
|
||||
if (typeof persistedDuration === 'number') {
|
||||
@@ -52,29 +64,10 @@ export function ThinkingBlock({
|
||||
return `${seconds}s`
|
||||
}
|
||||
|
||||
if (!isExpanded) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => setIsExpanded(true)}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 text-gray-400 text-xs transition-colors hover:text-gray-500',
|
||||
'font-normal italic'
|
||||
)}
|
||||
type='button'
|
||||
>
|
||||
<Brain className='h-3 w-3' />
|
||||
<span>Thought for {formatDuration(duration)}</span>
|
||||
{isStreaming && (
|
||||
<span className='inline-flex h-1 w-1 animate-pulse rounded-full bg-gray-400' />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='my-1'>
|
||||
<button
|
||||
onClick={() => setIsExpanded(false)}
|
||||
onClick={() => setIsExpanded((v) => !v)}
|
||||
className={cn(
|
||||
'mb-1 inline-flex items-center gap-1 text-gray-400 text-xs transition-colors hover:text-gray-500',
|
||||
'font-normal italic'
|
||||
@@ -82,14 +75,25 @@ export function ThinkingBlock({
|
||||
type='button'
|
||||
>
|
||||
<Brain className='h-3 w-3' />
|
||||
<span>Thought for {formatDuration(duration)} (click to collapse)</span>
|
||||
<span>
|
||||
Thought for {formatDuration(duration)}
|
||||
{isExpanded ? ' (click to collapse)' : ''}
|
||||
</span>
|
||||
{isStreaming && (
|
||||
<span className='inline-flex h-1 w-1 animate-pulse rounded-full bg-gray-400' />
|
||||
)}
|
||||
</button>
|
||||
<div className='ml-1 border-gray-200 border-l-2 pl-2 dark:border-gray-700'>
|
||||
<pre className='whitespace-pre-wrap font-mono text-gray-400 text-xs dark:text-gray-500'>
|
||||
{content}
|
||||
{isStreaming && <span className='ml-1 inline-block h-2 w-1 animate-pulse bg-gray-400' />}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className='ml-1 border-gray-200 border-l-2 pl-2 dark:border-gray-700'>
|
||||
<pre className='whitespace-pre-wrap font-mono text-gray-400 text-xs dark:text-gray-500'>
|
||||
{content}
|
||||
{isStreaming && (
|
||||
<span className='ml-1 inline-block h-2 w-1 animate-pulse bg-gray-400' />
|
||||
)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -643,41 +643,49 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
||||
{/* Checkpoints below message */}
|
||||
{hasCheckpoints && (
|
||||
<div className='mt-1 flex justify-end'>
|
||||
{showRestoreConfirmation ? (
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='text-muted-foreground text-xs'>Restore?</span>
|
||||
<button
|
||||
onClick={handleConfirmRevert}
|
||||
disabled={isRevertingCheckpoint}
|
||||
className='text-muted-foreground text-xs transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50'
|
||||
title='Confirm restore'
|
||||
>
|
||||
{isRevertingCheckpoint ? (
|
||||
<Loader2 className='h-3 w-3 animate-spin' />
|
||||
) : (
|
||||
<Check className='h-3 w-3' />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCancelRevert}
|
||||
disabled={isRevertingCheckpoint}
|
||||
className='text-muted-foreground text-xs transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50'
|
||||
title='Cancel restore'
|
||||
>
|
||||
<X className='h-3 w-3' />
|
||||
</button>
|
||||
<div className='inline-flex items-center gap-0.5 text-muted-foreground text-xs'>
|
||||
<span className='select-none'>
|
||||
Restore{showRestoreConfirmation && <span className='ml-0.5'>?</span>}
|
||||
</span>
|
||||
<div className='inline-flex w-8 items-center justify-center'>
|
||||
{showRestoreConfirmation ? (
|
||||
<div className='inline-flex items-center gap-1'>
|
||||
<button
|
||||
onClick={handleConfirmRevert}
|
||||
disabled={isRevertingCheckpoint}
|
||||
className='text-muted-foreground transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50'
|
||||
title='Confirm restore'
|
||||
aria-label='Confirm restore'
|
||||
>
|
||||
{isRevertingCheckpoint ? (
|
||||
<Loader2 className='h-3 w-3 animate-spin' />
|
||||
) : (
|
||||
<Check className='h-3 w-3' />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCancelRevert}
|
||||
disabled={isRevertingCheckpoint}
|
||||
className='text-muted-foreground transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50'
|
||||
title='Cancel restore'
|
||||
aria-label='Cancel restore'
|
||||
>
|
||||
<X className='h-3 w-3' />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleRevertToCheckpoint}
|
||||
disabled={isRevertingCheckpoint}
|
||||
className='text-muted-foreground transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50'
|
||||
title='Restore workflow to this checkpoint state'
|
||||
aria-label='Restore'
|
||||
>
|
||||
<RotateCcw className='h-3 w-3' />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleRevertToCheckpoint}
|
||||
disabled={isRevertingCheckpoint}
|
||||
className='flex items-center gap-1.5 rounded-md px-2 py-1 text-muted-foreground text-xs transition-colors hover:bg-muted hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50'
|
||||
title='Restore workflow to this checkpoint state'
|
||||
>
|
||||
<RotateCcw className='h-3 w-3' />
|
||||
Restore
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as SliderPrimitive from '@radix-ui/react-slider'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export const CopilotSlider = React.forwardRef<
|
||||
React.ElementRef<typeof SliderPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SliderPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex w-full cursor-pointer touch-none select-none items-center',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track className='relative h-2 w-full grow cursor-pointer overflow-hidden rounded-full bg-input'>
|
||||
<SliderPrimitive.Range className='absolute h-full bg-primary' />
|
||||
</SliderPrimitive.Track>
|
||||
<SliderPrimitive.Thumb className='block h-5 w-5 cursor-pointer rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50' />
|
||||
</SliderPrimitive.Root>
|
||||
))
|
||||
CopilotSlider.displayName = 'CopilotSlider'
|
||||
@@ -10,12 +10,13 @@ import {
|
||||
} from 'react'
|
||||
import {
|
||||
ArrowUp,
|
||||
Boxes,
|
||||
Brain,
|
||||
BrainCircuit,
|
||||
BrainCog,
|
||||
Check,
|
||||
FileText,
|
||||
Image,
|
||||
Infinity as InfinityIcon,
|
||||
Info,
|
||||
Loader2,
|
||||
MessageCircle,
|
||||
Package,
|
||||
@@ -30,11 +31,13 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { useSession } from '@/lib/auth-client'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useCopilotStore } from '@/stores/copilot/store'
|
||||
import { CopilotSlider as Slider } from './copilot-slider'
|
||||
|
||||
export interface MessageFileAttachment {
|
||||
id: string
|
||||
@@ -426,32 +429,31 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
}
|
||||
|
||||
// Depth toggle state comes from global store; access via useCopilotStore
|
||||
const { agentDepth, setAgentDepth } = useCopilotStore()
|
||||
const { agentDepth, agentPrefetch, setAgentDepth, setAgentPrefetch } = useCopilotStore()
|
||||
|
||||
const cycleDepth = () => {
|
||||
// Allowed UI values: 0 (Lite), 1 (Default), 2 (Pro), 3 (Max)
|
||||
const next = agentDepth === 0 ? 1 : agentDepth === 1 ? 2 : agentDepth === 2 ? 3 : 0
|
||||
setAgentDepth(next)
|
||||
// 8 modes: depths 0-3, each with prefetch off/on. Cycle depth, then toggle prefetch when wrapping.
|
||||
const nextDepth = agentDepth === 3 ? 0 : ((agentDepth + 1) as 0 | 1 | 2 | 3)
|
||||
if (nextDepth === 0 && agentDepth === 3) {
|
||||
setAgentPrefetch(!agentPrefetch)
|
||||
}
|
||||
setAgentDepth(nextDepth)
|
||||
}
|
||||
|
||||
const getDepthLabel = () => {
|
||||
if (agentDepth === 0) return 'Lite'
|
||||
if (agentDepth === 1) return 'Auto'
|
||||
if (agentDepth === 2) return 'Pro'
|
||||
return 'Max'
|
||||
const getCollapsedModeLabel = () => {
|
||||
const base = getDepthLabelFor(agentDepth)
|
||||
return !agentPrefetch ? `${base} MAX` : base
|
||||
}
|
||||
|
||||
const getDepthLabelFor = (value: 0 | 1 | 2 | 3) => {
|
||||
if (value === 0) return 'Lite'
|
||||
if (value === 1) return 'Auto'
|
||||
if (value === 2) return 'Pro'
|
||||
return 'Max'
|
||||
return value === 0 ? 'Fast' : value === 1 ? 'Balanced' : value === 2 ? 'Advanced' : 'Expert'
|
||||
}
|
||||
|
||||
// Removed descriptive suffixes; concise labels only
|
||||
const getDepthDescription = (value: 0 | 1 | 2 | 3) => {
|
||||
if (value === 0)
|
||||
return 'Fastest and cheapest. Good for small edits, simple workflows, and small tasks.'
|
||||
if (value === 1) return 'Automatically balances speed and reasoning. Good fit for most tasks.'
|
||||
if (value === 1) return 'Balances speed and reasoning. Good fit for most tasks.'
|
||||
if (value === 2)
|
||||
return 'More reasoning for larger workflows and complex edits, still balanced for speed.'
|
||||
return 'Maximum reasoning power. Best for complex workflow building and debugging.'
|
||||
@@ -459,9 +461,9 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
|
||||
const getDepthIconFor = (value: 0 | 1 | 2 | 3) => {
|
||||
if (value === 0) return <Zap className='h-3 w-3 text-muted-foreground' />
|
||||
if (value === 1) return <Boxes className='h-3 w-3 text-muted-foreground' />
|
||||
if (value === 2) return <BrainCircuit className='h-3 w-3 text-muted-foreground' />
|
||||
return <BrainCog className='h-3 w-3 text-muted-foreground' />
|
||||
if (value === 1) return <InfinityIcon className='h-3 w-3 text-muted-foreground' />
|
||||
if (value === 2) return <Brain className='h-3 w-3 text-muted-foreground' />
|
||||
return <BrainCircuit className='h-3 w-3 text-muted-foreground' />
|
||||
}
|
||||
|
||||
const getDepthIcon = () => getDepthIconFor(agentDepth)
|
||||
@@ -548,7 +550,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
placeholder={isDragging ? 'Drop files here...' : placeholder}
|
||||
disabled={disabled}
|
||||
rows={1}
|
||||
className='mb-2 min-h-[32px] w-full resize-none overflow-hidden border-0 bg-transparent px-[2px] py-1 text-muted-foreground focus-visible:ring-0 focus-visible:ring-offset-0'
|
||||
className='mb-2 min-h-[32px] w-full resize-none overflow-hidden border-0 bg-transparent px-[2px] py-1 text-foreground focus-visible:ring-0 focus-visible:ring-offset-0'
|
||||
style={{ height: 'auto' }}
|
||||
/>
|
||||
|
||||
@@ -635,126 +637,72 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='flex h-6 items-center gap-1.5 rounded-full border px-2 py-1 font-medium text-xs'
|
||||
title='Choose depth'
|
||||
title='Choose mode'
|
||||
>
|
||||
{getDepthIcon()}
|
||||
<span>{getDepthLabel()}</span>
|
||||
<span>{getCollapsedModeLabel()}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='start' className='p-0'>
|
||||
<TooltipProvider>
|
||||
<div className='w-[180px] p-1'>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => setAgentDepth(1)}
|
||||
className={cn(
|
||||
'flex items-center justify-between rounded-sm px-2 py-1.5 text-xs leading-4',
|
||||
agentDepth === 1 && 'bg-muted/40'
|
||||
)}
|
||||
>
|
||||
<span className='flex items-center gap-1.5'>
|
||||
<Boxes className='h-3 w-3 text-muted-foreground' />
|
||||
Auto
|
||||
</span>
|
||||
{agentDepth === 1 && (
|
||||
<Check className='h-3 w-3 text-muted-foreground' />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side='right'
|
||||
sideOffset={6}
|
||||
align='center'
|
||||
className='max-w-[220px] border bg-popover p-2 text-[11px] text-popover-foreground leading-snug shadow-md'
|
||||
>
|
||||
Automatically balances speed and reasoning. Good fit for most tasks.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => setAgentDepth(0)}
|
||||
className={cn(
|
||||
'flex items-center justify-between rounded-sm px-2 py-1.5 text-xs leading-4',
|
||||
agentDepth === 0 && 'bg-muted/40'
|
||||
)}
|
||||
>
|
||||
<span className='flex items-center gap-1.5'>
|
||||
<Zap className='h-3 w-3 text-muted-foreground' />
|
||||
Lite
|
||||
</span>
|
||||
{agentDepth === 0 && (
|
||||
<Check className='h-3 w-3 text-muted-foreground' />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side='right'
|
||||
sideOffset={6}
|
||||
align='center'
|
||||
className='max-w-[220px] border bg-popover p-2 text-[11px] text-popover-foreground leading-snug shadow-md'
|
||||
>
|
||||
Fastest and cheapest. Good for small edits, simple workflows, and small
|
||||
tasks.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => setAgentDepth(2)}
|
||||
className={cn(
|
||||
'flex items-center justify-between rounded-sm px-2 py-1.5 text-xs leading-4',
|
||||
agentDepth === 2 && 'bg-muted/40'
|
||||
)}
|
||||
>
|
||||
<span className='flex items-center gap-1.5'>
|
||||
<BrainCircuit className='h-3 w-3 text-muted-foreground' />
|
||||
Pro
|
||||
</span>
|
||||
{agentDepth === 2 && (
|
||||
<Check className='h-3 w-3 text-muted-foreground' />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side='right'
|
||||
sideOffset={6}
|
||||
align='center'
|
||||
className='max-w-[220px] border bg-popover p-2 text-[11px] text-popover-foreground leading-snug shadow-md'
|
||||
>
|
||||
More reasoning for larger workflows and complex edits, still balanced
|
||||
for speed.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => setAgentDepth(3)}
|
||||
className={cn(
|
||||
'flex items-center justify-between rounded-sm px-2 py-1.5 text-xs leading-4',
|
||||
agentDepth === 3 && 'bg-muted/40'
|
||||
)}
|
||||
>
|
||||
<span className='flex items-center gap-1.5'>
|
||||
<BrainCog className='h-3 w-3 text-muted-foreground' />
|
||||
Max
|
||||
</span>
|
||||
{agentDepth === 3 && (
|
||||
<Check className='h-3 w-3 text-muted-foreground' />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side='right'
|
||||
sideOffset={6}
|
||||
align='center'
|
||||
className='max-w-[220px] border bg-popover p-2 text-[11px] text-popover-foreground leading-snug shadow-md'
|
||||
>
|
||||
Maximum reasoning power. Best for complex workflow building and
|
||||
debugging.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<TooltipProvider delayDuration={100} skipDelayDuration={0}>
|
||||
<div className='w-[260px] p-3'>
|
||||
<div className='mb-3 flex items-center justify-between'>
|
||||
<div className='flex items-center gap-1.5'>
|
||||
<span className='font-medium text-xs'>MAX mode</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type='button'
|
||||
className='h-3.5 w-3.5 rounded text-muted-foreground transition-colors hover:text-foreground'
|
||||
aria-label='MAX mode info'
|
||||
>
|
||||
<Info className='h-3.5 w-3.5' />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side='right'
|
||||
sideOffset={6}
|
||||
align='center'
|
||||
className='max-w-[220px] border bg-popover p-2 text-[11px] text-popover-foreground leading-snug shadow-md'
|
||||
>
|
||||
Significantly increases depth of reasoning
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Switch
|
||||
checked={!agentPrefetch}
|
||||
onCheckedChange={(checked) => setAgentPrefetch(!checked)}
|
||||
/>
|
||||
</div>
|
||||
<div className='my-2 flex justify-center'>
|
||||
<div className='h-px w-[100%] bg-border' />
|
||||
</div>
|
||||
<div className='mb-3'>
|
||||
<div className='mb-2 flex items-center justify-between'>
|
||||
<span className='font-medium text-xs'>Mode</span>
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{getDepthLabelFor(agentDepth)}
|
||||
</span>
|
||||
</div>
|
||||
<div className='relative'>
|
||||
<Slider
|
||||
min={0}
|
||||
max={3}
|
||||
step={1}
|
||||
value={[agentDepth]}
|
||||
onValueChange={(val) =>
|
||||
setAgentDepth((val?.[0] ?? 0) as 0 | 1 | 2 | 3)
|
||||
}
|
||||
/>
|
||||
<div className='pointer-events-none absolute inset-0'>
|
||||
<div className='-translate-x-1/2 -translate-y-1/2 absolute top-1/2 left-[33.333%] h-2 w-[3px] bg-background' />
|
||||
<div className='-translate-x-1/2 -translate-y-1/2 absolute top-1/2 left-[66.667%] h-2 w-[3px] bg-background' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='mt-3 text-[11px] text-muted-foreground'>
|
||||
{getDepthDescription(agentDepth)}
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</DropdownMenuContent>
|
||||
|
||||
@@ -44,6 +44,9 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
||||
// Scroll state
|
||||
const [isNearBottom, setIsNearBottom] = useState(true)
|
||||
const [showScrollButton, setShowScrollButton] = useState(false)
|
||||
// New state to track if user has intentionally scrolled during streaming
|
||||
const [userHasScrolledDuringStream, setUserHasScrolledDuringStream] = useState(false)
|
||||
const isUserScrollingRef = useRef(false) // Track if scroll event is user-initiated
|
||||
|
||||
const { activeWorkflowId } = useWorkflowRegistry()
|
||||
|
||||
@@ -119,6 +122,8 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
||||
'[data-radix-scroll-area-viewport]'
|
||||
)
|
||||
if (scrollContainer) {
|
||||
// Mark that we're programmatically scrolling
|
||||
isUserScrollingRef.current = false
|
||||
scrollContainer.scrollTo({
|
||||
top: scrollContainer.scrollHeight,
|
||||
behavior: 'smooth',
|
||||
@@ -143,7 +148,15 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
||||
const nearBottom = distanceFromBottom <= 100
|
||||
setIsNearBottom(nearBottom)
|
||||
setShowScrollButton(!nearBottom)
|
||||
}, [])
|
||||
|
||||
// If user scrolled up during streaming, mark it
|
||||
if (isSendingMessage && !nearBottom && isUserScrollingRef.current) {
|
||||
setUserHasScrolledDuringStream(true)
|
||||
}
|
||||
|
||||
// Reset the user scrolling flag after processing
|
||||
isUserScrollingRef.current = true
|
||||
}, [isSendingMessage])
|
||||
|
||||
// Attach scroll listener
|
||||
useEffect(() => {
|
||||
@@ -154,7 +167,13 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
||||
const viewport = scrollArea.querySelector('[data-radix-scroll-area-viewport]')
|
||||
if (!viewport) return
|
||||
|
||||
viewport.addEventListener('scroll', handleScroll, { passive: true })
|
||||
// Mark user-initiated scrolls
|
||||
const handleUserScroll = () => {
|
||||
isUserScrollingRef.current = true
|
||||
handleScroll()
|
||||
}
|
||||
|
||||
viewport.addEventListener('scroll', handleUserScroll, { passive: true })
|
||||
|
||||
// Also listen for scrollend event if available (for smooth scrolling)
|
||||
if ('onscrollend' in viewport) {
|
||||
@@ -165,34 +184,63 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
||||
setTimeout(handleScroll, 100)
|
||||
|
||||
return () => {
|
||||
viewport.removeEventListener('scroll', handleScroll)
|
||||
viewport.removeEventListener('scroll', handleUserScroll)
|
||||
if ('onscrollend' in viewport) {
|
||||
viewport.removeEventListener('scrollend', handleScroll)
|
||||
}
|
||||
}
|
||||
}, [handleScroll])
|
||||
|
||||
// Smart auto-scroll: only scroll if user is near bottom or for user messages
|
||||
// Smart auto-scroll: only scroll if user hasn't intentionally scrolled up during streaming
|
||||
useEffect(() => {
|
||||
if (messages.length === 0) return
|
||||
|
||||
const lastMessage = messages[messages.length - 1]
|
||||
const isNewUserMessage = lastMessage?.role === 'user'
|
||||
|
||||
// Always scroll for new user messages, or only if near bottom for assistant messages
|
||||
if ((isNewUserMessage || isNearBottom) && scrollAreaRef.current) {
|
||||
// Conditions for auto-scrolling:
|
||||
// 1. Always scroll for new user messages (resets the user scroll state)
|
||||
// 2. For assistant messages during streaming: only if user hasn't scrolled up
|
||||
// 3. For assistant messages when not streaming: only if near bottom
|
||||
const shouldAutoScroll =
|
||||
isNewUserMessage ||
|
||||
(isSendingMessage && !userHasScrolledDuringStream) ||
|
||||
(!isSendingMessage && isNearBottom)
|
||||
|
||||
if (shouldAutoScroll && scrollAreaRef.current) {
|
||||
const scrollContainer = scrollAreaRef.current.querySelector(
|
||||
'[data-radix-scroll-area-viewport]'
|
||||
)
|
||||
if (scrollContainer) {
|
||||
// Mark that we're programmatically scrolling
|
||||
isUserScrollingRef.current = false
|
||||
scrollContainer.scrollTo({
|
||||
top: scrollContainer.scrollHeight,
|
||||
behavior: 'smooth',
|
||||
})
|
||||
// Let the scroll event handler update the state naturally after animation completes
|
||||
}
|
||||
}
|
||||
}, [messages, isNearBottom])
|
||||
}, [messages, isNearBottom, isSendingMessage, userHasScrolledDuringStream])
|
||||
|
||||
// Reset user scroll state when streaming starts or when user sends a message
|
||||
useEffect(() => {
|
||||
const lastMessage = messages[messages.length - 1]
|
||||
if (lastMessage?.role === 'user') {
|
||||
// User sent a new message - reset scroll state
|
||||
setUserHasScrolledDuringStream(false)
|
||||
isUserScrollingRef.current = false
|
||||
}
|
||||
}, [messages])
|
||||
|
||||
// Reset user scroll state when streaming completes
|
||||
const prevIsSendingRef = useRef(false)
|
||||
useEffect(() => {
|
||||
// When streaming transitions from true to false, reset the user scroll state
|
||||
if (prevIsSendingRef.current && !isSendingMessage) {
|
||||
setUserHasScrolledDuringStream(false)
|
||||
}
|
||||
prevIsSendingRef.current = isSendingMessage
|
||||
}, [isSendingMessage])
|
||||
|
||||
// Auto-scroll to bottom when chat loads in
|
||||
useEffect(() => {
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { isDev } from '@/lib/environment'
|
||||
import { useCopilotStore } from '@/stores/copilot/store'
|
||||
import { useChatStore } from '@/stores/panel/chat/store'
|
||||
import { useConsoleStore } from '@/stores/panel/console/store'
|
||||
@@ -305,16 +304,14 @@ export function Panel() {
|
||||
>
|
||||
Console
|
||||
</button>
|
||||
{!isDev && (
|
||||
<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('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 ${
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// Mock hooks
|
||||
const mockCollaborativeUpdates = {
|
||||
collaborativeUpdateLoopType: vi.fn(),
|
||||
collaborativeUpdateParallelType: vi.fn(),
|
||||
collaborativeUpdateIterationCount: vi.fn(),
|
||||
collaborativeUpdateIterationCollection: vi.fn(),
|
||||
}
|
||||
|
||||
const mockStoreData = {
|
||||
loops: {},
|
||||
parallels: {},
|
||||
}
|
||||
|
||||
vi.mock('@/hooks/use-collaborative-workflow', () => ({
|
||||
useCollaborativeWorkflow: () => mockCollaborativeUpdates,
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/workflows/workflow/store', () => ({
|
||||
useWorkflowStore: () => mockStoreData,
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/badge', () => ({
|
||||
Badge: ({ children, ...props }: any) => (
|
||||
<div data-testid='badge' {...props}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/input', () => ({
|
||||
Input: (props: any) => <input data-testid='input' {...props} />,
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/popover', () => ({
|
||||
Popover: ({ children }: any) => <div data-testid='popover'>{children}</div>,
|
||||
PopoverContent: ({ children }: any) => <div data-testid='popover-content'>{children}</div>,
|
||||
PopoverTrigger: ({ children }: any) => <div data-testid='popover-trigger'>{children}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/tag-dropdown', () => ({
|
||||
checkTagTrigger: vi.fn(() => ({ show: false })),
|
||||
TagDropdown: ({ children }: any) => <div data-testid='tag-dropdown'>{children}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('react-simple-code-editor', () => ({
|
||||
default: (props: any) => <textarea data-testid='code-editor' {...props} />,
|
||||
}))
|
||||
|
||||
describe('IterationBadges', () => {
|
||||
const defaultProps = {
|
||||
nodeId: 'test-node-1',
|
||||
data: {
|
||||
width: 500,
|
||||
height: 300,
|
||||
isPreview: false,
|
||||
},
|
||||
iterationType: 'loop' as const,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockStoreData.loops = {}
|
||||
mockStoreData.parallels = {}
|
||||
})
|
||||
|
||||
describe('Component Interface', () => {
|
||||
it.concurrent('should accept required props', () => {
|
||||
expect(defaultProps.nodeId).toBeDefined()
|
||||
expect(defaultProps.data).toBeDefined()
|
||||
expect(defaultProps.iterationType).toBeDefined()
|
||||
})
|
||||
|
||||
it.concurrent('should handle loop iteration type prop', () => {
|
||||
const loopProps = { ...defaultProps, iterationType: 'loop' as const }
|
||||
expect(loopProps.iterationType).toBe('loop')
|
||||
})
|
||||
|
||||
it.concurrent('should handle parallel iteration type prop', () => {
|
||||
const parallelProps = { ...defaultProps, iterationType: 'parallel' as const }
|
||||
expect(parallelProps.iterationType).toBe('parallel')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Configuration System', () => {
|
||||
it.concurrent('should use correct config for loop type', () => {
|
||||
const CONFIG = {
|
||||
loop: {
|
||||
typeLabels: { for: 'For Loop', forEach: 'For Each' },
|
||||
typeKey: 'loopType' as const,
|
||||
storeKey: 'loops' as const,
|
||||
maxIterations: 100,
|
||||
configKeys: {
|
||||
iterations: 'iterations' as const,
|
||||
items: 'forEachItems' as const,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
expect(CONFIG.loop.typeLabels.for).toBe('For Loop')
|
||||
expect(CONFIG.loop.typeLabels.forEach).toBe('For Each')
|
||||
expect(CONFIG.loop.maxIterations).toBe(100)
|
||||
expect(CONFIG.loop.storeKey).toBe('loops')
|
||||
})
|
||||
|
||||
it.concurrent('should use correct config for parallel type', () => {
|
||||
const CONFIG = {
|
||||
parallel: {
|
||||
typeLabels: { count: 'Parallel Count', collection: 'Parallel Each' },
|
||||
typeKey: 'parallelType' as const,
|
||||
storeKey: 'parallels' as const,
|
||||
maxIterations: 20,
|
||||
configKeys: {
|
||||
iterations: 'count' as const,
|
||||
items: 'distribution' as const,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
expect(CONFIG.parallel.typeLabels.count).toBe('Parallel Count')
|
||||
expect(CONFIG.parallel.typeLabels.collection).toBe('Parallel Each')
|
||||
expect(CONFIG.parallel.maxIterations).toBe(20)
|
||||
expect(CONFIG.parallel.storeKey).toBe('parallels')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Type Determination Logic', () => {
|
||||
it.concurrent('should default to "for" for loop type', () => {
|
||||
type IterationType = 'loop' | 'parallel'
|
||||
const determineDefaultType = (iterationType: IterationType) => {
|
||||
return iterationType === 'loop' ? 'for' : 'count'
|
||||
}
|
||||
|
||||
const currentType = determineDefaultType('loop')
|
||||
expect(currentType).toBe('for')
|
||||
})
|
||||
|
||||
it.concurrent('should default to "count" for parallel type', () => {
|
||||
type IterationType = 'loop' | 'parallel'
|
||||
const determineDefaultType = (iterationType: IterationType) => {
|
||||
return iterationType === 'loop' ? 'for' : 'count'
|
||||
}
|
||||
|
||||
const currentType = determineDefaultType('parallel')
|
||||
expect(currentType).toBe('count')
|
||||
})
|
||||
|
||||
it.concurrent('should use explicit loopType when provided', () => {
|
||||
type IterationType = 'loop' | 'parallel'
|
||||
const determineType = (explicitType: string | undefined, iterationType: IterationType) => {
|
||||
return explicitType || (iterationType === 'loop' ? 'for' : 'count')
|
||||
}
|
||||
|
||||
const currentType = determineType('forEach', 'loop')
|
||||
expect(currentType).toBe('forEach')
|
||||
})
|
||||
|
||||
it.concurrent('should use explicit parallelType when provided', () => {
|
||||
type IterationType = 'loop' | 'parallel'
|
||||
const determineType = (explicitType: string | undefined, iterationType: IterationType) => {
|
||||
return explicitType || (iterationType === 'loop' ? 'for' : 'count')
|
||||
}
|
||||
|
||||
const currentType = determineType('collection', 'parallel')
|
||||
expect(currentType).toBe('collection')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Count Mode Detection', () => {
|
||||
it.concurrent('should be in count mode for loop + for combination', () => {
|
||||
type IterationType = 'loop' | 'parallel'
|
||||
type LoopType = 'for' | 'forEach'
|
||||
type ParallelType = 'count' | 'collection'
|
||||
|
||||
const iterationType: IterationType = 'loop'
|
||||
const currentType: LoopType = 'for'
|
||||
const isCountMode = iterationType === 'loop' && currentType === 'for'
|
||||
|
||||
expect(isCountMode).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should be in count mode for parallel + count combination', () => {
|
||||
type IterationType = 'loop' | 'parallel'
|
||||
type ParallelType = 'count' | 'collection'
|
||||
|
||||
const iterationType: IterationType = 'parallel'
|
||||
const currentType: ParallelType = 'count'
|
||||
const isCountMode = iterationType === 'parallel' && currentType === 'count'
|
||||
|
||||
expect(isCountMode).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should not be in count mode for loop + forEach combination', () => {
|
||||
type IterationType = 'loop' | 'parallel'
|
||||
|
||||
const testCountMode = (iterationType: IterationType, currentType: string) => {
|
||||
return iterationType === 'loop' && currentType === 'for'
|
||||
}
|
||||
|
||||
const isCountMode = testCountMode('loop', 'forEach')
|
||||
expect(isCountMode).toBe(false)
|
||||
})
|
||||
|
||||
it.concurrent('should not be in count mode for parallel + collection combination', () => {
|
||||
type IterationType = 'loop' | 'parallel'
|
||||
|
||||
const testCountMode = (iterationType: IterationType, currentType: string) => {
|
||||
return iterationType === 'parallel' && currentType === 'count'
|
||||
}
|
||||
|
||||
const isCountMode = testCountMode('parallel', 'collection')
|
||||
expect(isCountMode).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Configuration Values', () => {
|
||||
it.concurrent('should handle default iteration count', () => {
|
||||
const data = { count: undefined }
|
||||
const configIterations = data.count ?? 5
|
||||
expect(configIterations).toBe(5)
|
||||
})
|
||||
|
||||
it.concurrent('should use provided iteration count', () => {
|
||||
const data = { count: 10 }
|
||||
const configIterations = data.count ?? 5
|
||||
expect(configIterations).toBe(10)
|
||||
})
|
||||
|
||||
it.concurrent('should handle string collection', () => {
|
||||
const collection = '[1, 2, 3, 4, 5]'
|
||||
const collectionString =
|
||||
typeof collection === 'string' ? collection : JSON.stringify(collection) || ''
|
||||
expect(collectionString).toBe('[1, 2, 3, 4, 5]')
|
||||
})
|
||||
|
||||
it.concurrent('should handle object collection', () => {
|
||||
const collection = { items: [1, 2, 3] }
|
||||
const collectionString =
|
||||
typeof collection === 'string' ? collection : JSON.stringify(collection) || ''
|
||||
expect(collectionString).toBe('{"items":[1,2,3]}')
|
||||
})
|
||||
|
||||
it.concurrent('should handle array collection', () => {
|
||||
const collection = [1, 2, 3, 4, 5]
|
||||
const collectionString =
|
||||
typeof collection === 'string' ? collection : JSON.stringify(collection) || ''
|
||||
expect(collectionString).toBe('[1,2,3,4,5]')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Preview Mode Handling', () => {
|
||||
it.concurrent('should handle preview mode for loops', () => {
|
||||
const previewProps = {
|
||||
...defaultProps,
|
||||
data: { ...defaultProps.data, isPreview: true },
|
||||
iterationType: 'loop' as const,
|
||||
}
|
||||
|
||||
expect(previewProps.data.isPreview).toBe(true)
|
||||
// In preview mode, collaborative functions shouldn't be called
|
||||
expect(mockCollaborativeUpdates.collaborativeUpdateLoopType).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.concurrent('should handle preview mode for parallels', () => {
|
||||
const previewProps = {
|
||||
...defaultProps,
|
||||
data: { ...defaultProps.data, isPreview: true },
|
||||
iterationType: 'parallel' as const,
|
||||
}
|
||||
|
||||
expect(previewProps.data.isPreview).toBe(true)
|
||||
// In preview mode, collaborative functions shouldn't be called
|
||||
expect(mockCollaborativeUpdates.collaborativeUpdateParallelType).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Store Integration', () => {
|
||||
it.concurrent('should access loops store for loop iteration type', () => {
|
||||
const nodeId = 'loop-node-1'
|
||||
;(mockStoreData.loops as any)[nodeId] = { iterations: 10 }
|
||||
|
||||
const nodeConfig = (mockStoreData.loops as any)[nodeId]
|
||||
expect(nodeConfig).toBeDefined()
|
||||
expect(nodeConfig.iterations).toBe(10)
|
||||
})
|
||||
|
||||
it.concurrent('should access parallels store for parallel iteration type', () => {
|
||||
const nodeId = 'parallel-node-1'
|
||||
;(mockStoreData.parallels as any)[nodeId] = { count: 5 }
|
||||
|
||||
const nodeConfig = (mockStoreData.parallels as any)[nodeId]
|
||||
expect(nodeConfig).toBeDefined()
|
||||
expect(nodeConfig.count).toBe(5)
|
||||
})
|
||||
|
||||
it.concurrent('should handle missing node configuration gracefully', () => {
|
||||
const nodeId = 'missing-node'
|
||||
const nodeConfig = (mockStoreData.loops as any)[nodeId]
|
||||
expect(nodeConfig).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Max Iterations Limits', () => {
|
||||
it.concurrent('should enforce max iterations for loops (100)', () => {
|
||||
const maxIterations = 100
|
||||
const testValue = 150
|
||||
const clampedValue = Math.min(maxIterations, testValue)
|
||||
expect(clampedValue).toBe(100)
|
||||
})
|
||||
|
||||
it.concurrent('should enforce max iterations for parallels (20)', () => {
|
||||
const maxIterations = 20
|
||||
const testValue = 50
|
||||
const clampedValue = Math.min(maxIterations, testValue)
|
||||
expect(clampedValue).toBe(20)
|
||||
})
|
||||
|
||||
it.concurrent('should allow values within limits', () => {
|
||||
const loopMaxIterations = 100
|
||||
const parallelMaxIterations = 20
|
||||
|
||||
expect(Math.min(loopMaxIterations, 50)).toBe(50)
|
||||
expect(Math.min(parallelMaxIterations, 10)).toBe(10)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Collaborative Update Functions', () => {
|
||||
it.concurrent('should have the correct collaborative functions available', () => {
|
||||
expect(mockCollaborativeUpdates.collaborativeUpdateLoopType).toBeDefined()
|
||||
expect(mockCollaborativeUpdates.collaborativeUpdateParallelType).toBeDefined()
|
||||
expect(mockCollaborativeUpdates.collaborativeUpdateIterationCount).toBeDefined()
|
||||
expect(mockCollaborativeUpdates.collaborativeUpdateIterationCollection).toBeDefined()
|
||||
})
|
||||
|
||||
it.concurrent('should call correct function for loop type updates', () => {
|
||||
const handleTypeChange = (newType: string, iterationType: string, nodeId: string) => {
|
||||
if (iterationType === 'loop') {
|
||||
mockCollaborativeUpdates.collaborativeUpdateLoopType(nodeId, newType)
|
||||
} else {
|
||||
mockCollaborativeUpdates.collaborativeUpdateParallelType(nodeId, newType)
|
||||
}
|
||||
}
|
||||
|
||||
handleTypeChange('forEach', 'loop', 'test-node')
|
||||
expect(mockCollaborativeUpdates.collaborativeUpdateLoopType).toHaveBeenCalledWith(
|
||||
'test-node',
|
||||
'forEach'
|
||||
)
|
||||
})
|
||||
|
||||
it.concurrent('should call correct function for parallel type updates', () => {
|
||||
const handleTypeChange = (newType: string, iterationType: string, nodeId: string) => {
|
||||
if (iterationType === 'loop') {
|
||||
mockCollaborativeUpdates.collaborativeUpdateLoopType(nodeId, newType)
|
||||
} else {
|
||||
mockCollaborativeUpdates.collaborativeUpdateParallelType(nodeId, newType)
|
||||
}
|
||||
}
|
||||
|
||||
handleTypeChange('collection', 'parallel', 'test-node')
|
||||
expect(mockCollaborativeUpdates.collaborativeUpdateParallelType).toHaveBeenCalledWith(
|
||||
'test-node',
|
||||
'collection'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Input Sanitization', () => {
|
||||
it.concurrent('should sanitize numeric input by removing non-digits', () => {
|
||||
const testInput = 'abc123def456'
|
||||
const sanitized = testInput.replace(/[^0-9]/g, '')
|
||||
expect(sanitized).toBe('123456')
|
||||
})
|
||||
|
||||
it.concurrent('should handle empty input', () => {
|
||||
const testInput = ''
|
||||
const sanitized = testInput.replace(/[^0-9]/g, '')
|
||||
expect(sanitized).toBe('')
|
||||
})
|
||||
|
||||
it.concurrent('should preserve valid numeric input', () => {
|
||||
const testInput = '42'
|
||||
const sanitized = testInput.replace(/[^0-9]/g, '')
|
||||
expect(sanitized).toBe('42')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,452 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { LoopNodeComponent } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/loop/loop-node'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
|
||||
vi.mock('@/stores/workflows/workflow/store', () => ({
|
||||
useWorkflowStore: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/logs/logger', () => ({
|
||||
createLogger: vi.fn(() => ({
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('reactflow', () => ({
|
||||
Handle: ({ id, type, position }: any) => ({ id, type, position }),
|
||||
Position: {
|
||||
Top: 'top',
|
||||
Bottom: 'bottom',
|
||||
Left: 'left',
|
||||
Right: 'right',
|
||||
},
|
||||
useReactFlow: () => ({
|
||||
getNodes: vi.fn(() => []),
|
||||
}),
|
||||
NodeResizer: ({ isVisible }: any) => ({ isVisible }),
|
||||
memo: (component: any) => component,
|
||||
}))
|
||||
|
||||
vi.mock('react', async () => {
|
||||
const actual = await vi.importActual('react')
|
||||
return {
|
||||
...actual,
|
||||
memo: (component: any) => component,
|
||||
useMemo: (fn: any) => fn(),
|
||||
useRef: () => ({ current: null }),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/components/ui/button', () => ({
|
||||
Button: ({ children, onClick, ...props }: any) => ({ children, onClick, ...props }),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/card', () => ({
|
||||
Card: ({ children, ...props }: any) => ({ children, ...props }),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/icons', async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as any
|
||||
return {
|
||||
...actual,
|
||||
// Override specific icons if needed for testing
|
||||
StartIcon: ({ className }: any) => ({ className }),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/lib/utils', () => ({
|
||||
cn: (...classes: any[]) => classes.filter(Boolean).join(' '),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/workspace/[workspaceId]/w/[workflowId]/components/loop-badges', () => ({
|
||||
LoopBadges: ({ loopId }: any) => ({ loopId }),
|
||||
}))
|
||||
|
||||
describe('LoopNodeComponent', () => {
|
||||
const mockRemoveBlock = vi.fn()
|
||||
const mockGetNodes = vi.fn()
|
||||
const defaultProps = {
|
||||
id: 'loop-1',
|
||||
type: 'loopNode',
|
||||
data: {
|
||||
width: 500,
|
||||
height: 300,
|
||||
state: 'valid',
|
||||
},
|
||||
selected: false,
|
||||
zIndex: 1,
|
||||
isConnectable: true,
|
||||
xPos: 0,
|
||||
yPos: 0,
|
||||
dragging: false,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
;(useWorkflowStore as any).mockImplementation((selector: any) => {
|
||||
const state = {
|
||||
removeBlock: mockRemoveBlock,
|
||||
}
|
||||
return selector(state)
|
||||
})
|
||||
|
||||
mockGetNodes.mockReturnValue([])
|
||||
})
|
||||
|
||||
describe('Component Definition and Structure', () => {
|
||||
it('should be defined as a function component', () => {
|
||||
expect(LoopNodeComponent).toBeDefined()
|
||||
expect(typeof LoopNodeComponent).toBe('function')
|
||||
})
|
||||
|
||||
it('should have correct display name', () => {
|
||||
expect(LoopNodeComponent.displayName).toBe('LoopNodeComponent')
|
||||
})
|
||||
|
||||
it('should be a memoized component', () => {
|
||||
expect(LoopNodeComponent).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props Validation and Type Safety', () => {
|
||||
it('should accept NodeProps interface', () => {
|
||||
const validProps = {
|
||||
id: 'test-id',
|
||||
type: 'loopNode' as const,
|
||||
data: {
|
||||
width: 400,
|
||||
height: 300,
|
||||
state: 'valid' as const,
|
||||
},
|
||||
selected: false,
|
||||
zIndex: 1,
|
||||
isConnectable: true,
|
||||
xPos: 0,
|
||||
yPos: 0,
|
||||
dragging: false,
|
||||
}
|
||||
|
||||
expect(() => {
|
||||
const _component: typeof LoopNodeComponent = LoopNodeComponent
|
||||
expect(_component).toBeDefined()
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('should handle different data configurations', () => {
|
||||
const configurations = [
|
||||
{ width: 500, height: 300, state: 'valid' },
|
||||
{ width: 800, height: 600, state: 'invalid' },
|
||||
{ width: 0, height: 0, state: 'pending' },
|
||||
{},
|
||||
]
|
||||
|
||||
configurations.forEach((data) => {
|
||||
const props = { ...defaultProps, data }
|
||||
expect(() => {
|
||||
const _component: typeof LoopNodeComponent = LoopNodeComponent
|
||||
expect(_component).toBeDefined()
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Store Integration', () => {
|
||||
it('should integrate with workflow store', () => {
|
||||
expect(useWorkflowStore).toBeDefined()
|
||||
|
||||
const mockState = { removeBlock: mockRemoveBlock }
|
||||
const selector = vi.fn((state) => state.removeBlock)
|
||||
|
||||
expect(() => {
|
||||
selector(mockState)
|
||||
}).not.toThrow()
|
||||
|
||||
expect(selector(mockState)).toBe(mockRemoveBlock)
|
||||
})
|
||||
|
||||
it('should handle removeBlock function', () => {
|
||||
expect(mockRemoveBlock).toBeDefined()
|
||||
expect(typeof mockRemoveBlock).toBe('function')
|
||||
|
||||
mockRemoveBlock('test-id')
|
||||
expect(mockRemoveBlock).toHaveBeenCalledWith('test-id')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Component Logic Tests', () => {
|
||||
it('should handle nesting level calculation logic', () => {
|
||||
const testCases = [
|
||||
{ nodes: [], parentId: undefined, expectedLevel: 0 },
|
||||
{ nodes: [{ id: 'parent', data: {} }], parentId: 'parent', expectedLevel: 1 },
|
||||
{
|
||||
nodes: [
|
||||
{ id: 'parent', data: { parentId: 'grandparent' } },
|
||||
{ id: 'grandparent', data: {} },
|
||||
],
|
||||
parentId: 'parent',
|
||||
expectedLevel: 2,
|
||||
},
|
||||
]
|
||||
|
||||
testCases.forEach(({ nodes, parentId, expectedLevel }) => {
|
||||
mockGetNodes.mockReturnValue(nodes)
|
||||
|
||||
// Simulate the nesting level calculation logic
|
||||
let level = 0
|
||||
let currentParentId = parentId
|
||||
|
||||
while (currentParentId) {
|
||||
level++
|
||||
const parentNode = nodes.find((n) => n.id === currentParentId)
|
||||
if (!parentNode) break
|
||||
currentParentId = parentNode.data?.parentId
|
||||
}
|
||||
|
||||
expect(level).toBe(expectedLevel)
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle nested styles generation', () => {
|
||||
// Test the nested styles logic
|
||||
const testCases = [
|
||||
{ nestingLevel: 0, state: 'valid', expectedBg: 'rgba(34,197,94,0.05)' },
|
||||
{ nestingLevel: 0, state: 'invalid', expectedBg: 'transparent' },
|
||||
{ nestingLevel: 1, state: 'valid', expectedBg: '#e2e8f030' },
|
||||
{ nestingLevel: 2, state: 'valid', expectedBg: '#cbd5e130' },
|
||||
]
|
||||
|
||||
testCases.forEach(({ nestingLevel, state, expectedBg }) => {
|
||||
// Simulate the getNestedStyles logic
|
||||
const styles: Record<string, string> = {
|
||||
backgroundColor: state === 'valid' ? 'rgba(34,197,94,0.05)' : 'transparent',
|
||||
}
|
||||
|
||||
if (nestingLevel > 0) {
|
||||
const colors = ['#e2e8f0', '#cbd5e1', '#94a3b8', '#64748b', '#475569']
|
||||
const colorIndex = (nestingLevel - 1) % colors.length
|
||||
styles.backgroundColor = `${colors[colorIndex]}30`
|
||||
}
|
||||
|
||||
expect(styles.backgroundColor).toBe(expectedBg)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Component Configuration', () => {
|
||||
it('should handle different dimensions', () => {
|
||||
const dimensionTests = [
|
||||
{ width: 500, height: 300 },
|
||||
{ width: 800, height: 600 },
|
||||
{ width: 0, height: 0 },
|
||||
{ width: 10000, height: 10000 },
|
||||
]
|
||||
|
||||
dimensionTests.forEach(({ width, height }) => {
|
||||
const data = { width, height, state: 'valid' }
|
||||
expect(data.width).toBe(width)
|
||||
expect(data.height).toBe(height)
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle different states', () => {
|
||||
const stateTests = ['valid', 'invalid', 'pending', 'executing']
|
||||
|
||||
stateTests.forEach((state) => {
|
||||
const data = { width: 500, height: 300, state }
|
||||
expect(data.state).toBe(state)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Event Handling Logic', () => {
|
||||
it('should handle delete button click logic', () => {
|
||||
const mockEvent = {
|
||||
stopPropagation: vi.fn(),
|
||||
}
|
||||
|
||||
// Simulate the delete button click handler
|
||||
const handleDelete = (e: any, nodeId: string) => {
|
||||
e.stopPropagation()
|
||||
mockRemoveBlock(nodeId)
|
||||
}
|
||||
|
||||
handleDelete(mockEvent, 'test-id')
|
||||
|
||||
expect(mockEvent.stopPropagation).toHaveBeenCalled()
|
||||
expect(mockRemoveBlock).toHaveBeenCalledWith('test-id')
|
||||
})
|
||||
|
||||
it('should handle event propagation prevention', () => {
|
||||
const mockEvent = {
|
||||
stopPropagation: vi.fn(),
|
||||
}
|
||||
|
||||
// Test that stopPropagation is called
|
||||
mockEvent.stopPropagation()
|
||||
expect(mockEvent.stopPropagation).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Component Data Handling', () => {
|
||||
it('should handle missing data properties gracefully', () => {
|
||||
const testCases = [
|
||||
undefined,
|
||||
{},
|
||||
{ width: 500 },
|
||||
{ height: 300 },
|
||||
{ state: 'valid' },
|
||||
{ width: 500, height: 300 },
|
||||
]
|
||||
|
||||
testCases.forEach((data) => {
|
||||
const props = { ...defaultProps, data }
|
||||
|
||||
// Test default values logic
|
||||
const width = Math.max(0, data?.width || 500)
|
||||
const height = Math.max(0, data?.height || 300)
|
||||
|
||||
expect(width).toBeGreaterThanOrEqual(0)
|
||||
expect(height).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle parent ID relationships', () => {
|
||||
const testCases = [
|
||||
{ parentId: undefined, hasParent: false },
|
||||
{ parentId: 'parent-1', hasParent: true },
|
||||
{ parentId: '', hasParent: false },
|
||||
]
|
||||
|
||||
testCases.forEach(({ parentId, hasParent }) => {
|
||||
const data = { ...defaultProps.data, parentId }
|
||||
expect(Boolean(data.parentId)).toBe(hasParent)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases and Error Handling', () => {
|
||||
it('should handle circular parent references', () => {
|
||||
// Test circular reference prevention
|
||||
const nodes = [
|
||||
{ id: 'node1', data: { parentId: 'node2' } },
|
||||
{ id: 'node2', data: { parentId: 'node1' } },
|
||||
]
|
||||
|
||||
mockGetNodes.mockReturnValue(nodes)
|
||||
|
||||
// Test the actual component's nesting level calculation logic
|
||||
// This simulates the real useMemo logic from the component
|
||||
let level = 0
|
||||
let currentParentId = 'node1'
|
||||
const visited = new Set<string>()
|
||||
|
||||
// This is the actual logic pattern used in the component
|
||||
while (currentParentId) {
|
||||
// If we've seen this parent before, we have a cycle - break immediately
|
||||
if (visited.has(currentParentId)) {
|
||||
break
|
||||
}
|
||||
|
||||
visited.add(currentParentId)
|
||||
level++
|
||||
|
||||
const parentNode = nodes.find((n) => n.id === currentParentId)
|
||||
if (!parentNode) break
|
||||
|
||||
currentParentId = parentNode.data?.parentId
|
||||
}
|
||||
|
||||
// With proper circular reference detection, we should stop at level 2
|
||||
// (node1 -> node2, then detect cycle when trying to go back to node1)
|
||||
expect(level).toBe(2)
|
||||
expect(visited.has('node1')).toBe(true)
|
||||
expect(visited.has('node2')).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle complex circular reference chains', () => {
|
||||
// Test more complex circular reference scenarios
|
||||
const nodes = [
|
||||
{ id: 'node1', data: { parentId: 'node2' } },
|
||||
{ id: 'node2', data: { parentId: 'node3' } },
|
||||
{ id: 'node3', data: { parentId: 'node1' } }, // Creates a 3-node cycle
|
||||
]
|
||||
|
||||
mockGetNodes.mockReturnValue(nodes)
|
||||
|
||||
let level = 0
|
||||
let currentParentId = 'node1'
|
||||
const visited = new Set<string>()
|
||||
|
||||
while (currentParentId) {
|
||||
if (visited.has(currentParentId)) {
|
||||
break // Cycle detected
|
||||
}
|
||||
|
||||
visited.add(currentParentId)
|
||||
level++
|
||||
|
||||
const parentNode = nodes.find((n) => n.id === currentParentId)
|
||||
if (!parentNode) break
|
||||
|
||||
currentParentId = parentNode.data?.parentId
|
||||
}
|
||||
|
||||
// Should traverse node1 -> node2 -> node3, then detect cycle
|
||||
expect(level).toBe(3)
|
||||
expect(visited.size).toBe(3)
|
||||
})
|
||||
|
||||
it('should handle self-referencing nodes', () => {
|
||||
// Test node that references itself
|
||||
const nodes = [
|
||||
{ id: 'node1', data: { parentId: 'node1' } }, // Self-reference
|
||||
]
|
||||
|
||||
mockGetNodes.mockReturnValue(nodes)
|
||||
|
||||
let level = 0
|
||||
let currentParentId = 'node1'
|
||||
const visited = new Set<string>()
|
||||
|
||||
while (currentParentId) {
|
||||
if (visited.has(currentParentId)) {
|
||||
break // Cycle detected immediately
|
||||
}
|
||||
|
||||
visited.add(currentParentId)
|
||||
level++
|
||||
|
||||
const parentNode = nodes.find((n) => n.id === currentParentId)
|
||||
if (!parentNode) break
|
||||
|
||||
currentParentId = parentNode.data?.parentId
|
||||
}
|
||||
|
||||
// Should detect self-reference immediately after first iteration
|
||||
expect(level).toBe(1)
|
||||
expect(visited.has('node1')).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle extreme values', () => {
|
||||
const extremeValues = [
|
||||
{ width: Number.MAX_SAFE_INTEGER, height: Number.MAX_SAFE_INTEGER },
|
||||
{ width: -1, height: -1 },
|
||||
{ width: 0, height: 0 },
|
||||
{ width: null, height: null },
|
||||
]
|
||||
|
||||
extremeValues.forEach((data) => {
|
||||
expect(() => {
|
||||
const width = data.width || 500
|
||||
const height = data.height || 300
|
||||
expect(typeof width).toBe('number')
|
||||
expect(typeof height).toBe('number')
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,585 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ParallelNodeComponent } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel/parallel-node'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
|
||||
vi.mock('@/stores/workflows/workflow/store', () => ({
|
||||
useWorkflowStore: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/logs/logger', () => ({
|
||||
createLogger: vi.fn(() => ({
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('reactflow', () => ({
|
||||
Handle: ({ id, type, position }: any) => ({ id, type, position }),
|
||||
Position: {
|
||||
Top: 'top',
|
||||
Bottom: 'bottom',
|
||||
Left: 'left',
|
||||
Right: 'right',
|
||||
},
|
||||
useReactFlow: () => ({
|
||||
getNodes: vi.fn(() => []),
|
||||
}),
|
||||
NodeResizer: ({ isVisible }: any) => ({ isVisible }),
|
||||
memo: (component: any) => component,
|
||||
}))
|
||||
|
||||
vi.mock('react', async () => {
|
||||
const actual = await vi.importActual('react')
|
||||
return {
|
||||
...actual,
|
||||
memo: (component: any) => component,
|
||||
useMemo: (fn: any) => fn(),
|
||||
useRef: () => ({ current: null }),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/components/ui/button', () => ({
|
||||
Button: ({ children, onClick, ...props }: any) => ({ children, onClick, ...props }),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/card', () => ({
|
||||
Card: ({ children, ...props }: any) => ({ children, ...props }),
|
||||
}))
|
||||
|
||||
vi.mock('@/blocks/registry', () => ({
|
||||
getBlock: vi.fn(() => ({
|
||||
name: 'Mock Block',
|
||||
description: 'Mock block description',
|
||||
icon: () => null,
|
||||
subBlocks: [],
|
||||
outputs: {},
|
||||
})),
|
||||
getAllBlocks: vi.fn(() => ({})),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/utils', () => ({
|
||||
cn: (...classes: any[]) => classes.filter(Boolean).join(' '),
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/app/workspace/[workspaceId]/w/[workflowId]/components/parallel-node/components/parallel-badges',
|
||||
() => ({
|
||||
ParallelBadges: ({ parallelId }: any) => ({ parallelId }),
|
||||
})
|
||||
)
|
||||
|
||||
describe('ParallelNodeComponent', () => {
|
||||
const mockRemoveBlock = vi.fn()
|
||||
const mockGetNodes = vi.fn()
|
||||
const defaultProps = {
|
||||
id: 'parallel-1',
|
||||
type: 'parallelNode',
|
||||
data: {
|
||||
width: 500,
|
||||
height: 300,
|
||||
state: 'valid',
|
||||
},
|
||||
selected: false,
|
||||
zIndex: 1,
|
||||
isConnectable: true,
|
||||
xPos: 0,
|
||||
yPos: 0,
|
||||
dragging: false,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
;(useWorkflowStore as any).mockImplementation((selector: any) => {
|
||||
const state = {
|
||||
removeBlock: mockRemoveBlock,
|
||||
}
|
||||
return selector(state)
|
||||
})
|
||||
|
||||
mockGetNodes.mockReturnValue([])
|
||||
})
|
||||
|
||||
describe('Component Definition and Structure', () => {
|
||||
it.concurrent('should be defined as a function component', () => {
|
||||
expect(ParallelNodeComponent).toBeDefined()
|
||||
expect(typeof ParallelNodeComponent).toBe('function')
|
||||
})
|
||||
|
||||
it.concurrent('should have correct display name', () => {
|
||||
expect(ParallelNodeComponent.displayName).toBe('ParallelNodeComponent')
|
||||
})
|
||||
|
||||
it.concurrent('should be a memoized component', () => {
|
||||
expect(ParallelNodeComponent).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props Validation and Type Safety', () => {
|
||||
it.concurrent('should accept NodeProps interface', () => {
|
||||
expect(() => {
|
||||
const _component: typeof ParallelNodeComponent = ParallelNodeComponent
|
||||
expect(_component).toBeDefined()
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it.concurrent('should handle different data configurations', () => {
|
||||
const configurations = [
|
||||
{ width: 500, height: 300, state: 'valid' },
|
||||
{ width: 800, height: 600, state: 'invalid' },
|
||||
{ width: 0, height: 0, state: 'pending' },
|
||||
{},
|
||||
]
|
||||
|
||||
configurations.forEach((data) => {
|
||||
const props = { ...defaultProps, data }
|
||||
expect(() => {
|
||||
const _component: typeof ParallelNodeComponent = ParallelNodeComponent
|
||||
expect(_component).toBeDefined()
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Store Integration', () => {
|
||||
it.concurrent('should integrate with workflow store', () => {
|
||||
expect(useWorkflowStore).toBeDefined()
|
||||
|
||||
const mockState = { removeBlock: mockRemoveBlock }
|
||||
const selector = vi.fn((state) => state.removeBlock)
|
||||
|
||||
expect(() => {
|
||||
selector(mockState)
|
||||
}).not.toThrow()
|
||||
|
||||
expect(selector(mockState)).toBe(mockRemoveBlock)
|
||||
})
|
||||
|
||||
it.concurrent('should handle removeBlock function', () => {
|
||||
expect(mockRemoveBlock).toBeDefined()
|
||||
expect(typeof mockRemoveBlock).toBe('function')
|
||||
|
||||
mockRemoveBlock('test-id')
|
||||
expect(mockRemoveBlock).toHaveBeenCalledWith('test-id')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Component Logic Tests', () => {
|
||||
it.concurrent('should handle nesting level calculation logic', () => {
|
||||
const testCases = [
|
||||
{ nodes: [], parentId: undefined, expectedLevel: 0 },
|
||||
{ nodes: [{ id: 'parent', data: {} }], parentId: 'parent', expectedLevel: 1 },
|
||||
{
|
||||
nodes: [
|
||||
{ id: 'parent', data: { parentId: 'grandparent' } },
|
||||
{ id: 'grandparent', data: {} },
|
||||
],
|
||||
parentId: 'parent',
|
||||
expectedLevel: 2,
|
||||
},
|
||||
]
|
||||
|
||||
testCases.forEach(({ nodes, parentId, expectedLevel }) => {
|
||||
mockGetNodes.mockReturnValue(nodes)
|
||||
|
||||
let level = 0
|
||||
let currentParentId = parentId
|
||||
|
||||
while (currentParentId) {
|
||||
level++
|
||||
const parentNode = nodes.find((n) => n.id === currentParentId)
|
||||
if (!parentNode) break
|
||||
currentParentId = parentNode.data?.parentId
|
||||
}
|
||||
|
||||
expect(level).toBe(expectedLevel)
|
||||
})
|
||||
})
|
||||
|
||||
it.concurrent('should handle nested styles generation for parallel nodes', () => {
|
||||
const testCases = [
|
||||
{ nestingLevel: 0, state: 'valid', expectedBg: 'rgba(254,225,43,0.05)' },
|
||||
{ nestingLevel: 0, state: 'invalid', expectedBg: 'transparent' },
|
||||
{ nestingLevel: 1, state: 'valid', expectedBg: '#e2e8f030' },
|
||||
{ nestingLevel: 2, state: 'valid', expectedBg: '#cbd5e130' },
|
||||
]
|
||||
|
||||
testCases.forEach(({ nestingLevel, state, expectedBg }) => {
|
||||
const styles: Record<string, string> = {
|
||||
backgroundColor: state === 'valid' ? 'rgba(254,225,43,0.05)' : 'transparent',
|
||||
}
|
||||
|
||||
if (nestingLevel > 0) {
|
||||
const colors = ['#e2e8f0', '#cbd5e1', '#94a3b8', '#64748b', '#475569']
|
||||
const colorIndex = (nestingLevel - 1) % colors.length
|
||||
styles.backgroundColor = `${colors[colorIndex]}30`
|
||||
}
|
||||
|
||||
expect(styles.backgroundColor).toBe(expectedBg)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Parallel-Specific Features', () => {
|
||||
it.concurrent('should handle parallel execution states', () => {
|
||||
const parallelStates = ['valid', 'invalid', 'executing', 'completed', 'pending']
|
||||
|
||||
parallelStates.forEach((state) => {
|
||||
const data = { width: 500, height: 300, state }
|
||||
expect(data.state).toBe(state)
|
||||
|
||||
const isExecuting = state === 'executing'
|
||||
const isCompleted = state === 'completed'
|
||||
|
||||
expect(typeof isExecuting).toBe('boolean')
|
||||
expect(typeof isCompleted).toBe('boolean')
|
||||
})
|
||||
})
|
||||
|
||||
it.concurrent('should handle parallel node color scheme', () => {
|
||||
const parallelColors = {
|
||||
background: 'rgba(254,225,43,0.05)',
|
||||
ring: '#FEE12B',
|
||||
startIcon: '#FEE12B',
|
||||
}
|
||||
|
||||
expect(parallelColors.background).toContain('254,225,43')
|
||||
expect(parallelColors.ring).toBe('#FEE12B')
|
||||
expect(parallelColors.startIcon).toBe('#FEE12B')
|
||||
})
|
||||
|
||||
it.concurrent('should differentiate from loop node styling', () => {
|
||||
const loopColors = {
|
||||
background: 'rgba(34,197,94,0.05)',
|
||||
ring: '#2FB3FF',
|
||||
startIcon: '#2FB3FF',
|
||||
}
|
||||
|
||||
const parallelColors = {
|
||||
background: 'rgba(254,225,43,0.05)',
|
||||
ring: '#FEE12B',
|
||||
startIcon: '#FEE12B',
|
||||
}
|
||||
|
||||
expect(parallelColors.background).not.toBe(loopColors.background)
|
||||
expect(parallelColors.ring).not.toBe(loopColors.ring)
|
||||
expect(parallelColors.startIcon).not.toBe(loopColors.startIcon)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Component Configuration', () => {
|
||||
it.concurrent('should handle different dimensions', () => {
|
||||
const dimensionTests = [
|
||||
{ width: 500, height: 300 },
|
||||
{ width: 800, height: 600 },
|
||||
{ width: 0, height: 0 },
|
||||
{ width: 10000, height: 10000 },
|
||||
]
|
||||
|
||||
dimensionTests.forEach(({ width, height }) => {
|
||||
const data = { width, height, state: 'valid' }
|
||||
expect(data.width).toBe(width)
|
||||
expect(data.height).toBe(height)
|
||||
})
|
||||
})
|
||||
|
||||
it.concurrent('should handle different states', () => {
|
||||
const stateTests = ['valid', 'invalid', 'pending', 'executing', 'completed']
|
||||
|
||||
stateTests.forEach((state) => {
|
||||
const data = { width: 500, height: 300, state }
|
||||
expect(data.state).toBe(state)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Event Handling Logic', () => {
|
||||
it.concurrent('should handle delete button click logic', () => {
|
||||
const mockEvent = {
|
||||
stopPropagation: vi.fn(),
|
||||
}
|
||||
|
||||
const handleDelete = (e: any, nodeId: string) => {
|
||||
e.stopPropagation()
|
||||
mockRemoveBlock(nodeId)
|
||||
}
|
||||
|
||||
handleDelete(mockEvent, 'test-id')
|
||||
|
||||
expect(mockEvent.stopPropagation).toHaveBeenCalled()
|
||||
expect(mockRemoveBlock).toHaveBeenCalledWith('test-id')
|
||||
})
|
||||
|
||||
it.concurrent('should handle event propagation prevention', () => {
|
||||
const mockEvent = {
|
||||
stopPropagation: vi.fn(),
|
||||
}
|
||||
|
||||
mockEvent.stopPropagation()
|
||||
expect(mockEvent.stopPropagation).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Component Data Handling', () => {
|
||||
it.concurrent('should handle missing data properties gracefully', () => {
|
||||
const testCases = [
|
||||
undefined,
|
||||
{},
|
||||
{ width: 500 },
|
||||
{ height: 300 },
|
||||
{ state: 'valid' },
|
||||
{ width: 500, height: 300 },
|
||||
]
|
||||
|
||||
testCases.forEach((data) => {
|
||||
const props = { ...defaultProps, data }
|
||||
|
||||
// Test default values logic
|
||||
const width = data?.width || 500
|
||||
const height = data?.height || 300
|
||||
|
||||
expect(width).toBeGreaterThanOrEqual(0)
|
||||
expect(height).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
})
|
||||
|
||||
it.concurrent('should handle parent ID relationships', () => {
|
||||
const testCases = [
|
||||
{ parentId: undefined, hasParent: false },
|
||||
{ parentId: 'parent-1', hasParent: true },
|
||||
{ parentId: '', hasParent: false },
|
||||
]
|
||||
|
||||
testCases.forEach(({ parentId, hasParent }) => {
|
||||
const data = { ...defaultProps.data, parentId }
|
||||
expect(Boolean(data.parentId)).toBe(hasParent)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Handle Configuration', () => {
|
||||
it.concurrent('should have correct handle IDs for parallel nodes', () => {
|
||||
const handleIds = {
|
||||
startSource: 'parallel-start-source',
|
||||
endSource: 'parallel-end-source',
|
||||
}
|
||||
|
||||
expect(handleIds.startSource).toContain('parallel')
|
||||
expect(handleIds.endSource).toContain('parallel')
|
||||
expect(handleIds.startSource).not.toContain('loop')
|
||||
expect(handleIds.endSource).not.toContain('loop')
|
||||
})
|
||||
|
||||
it.concurrent('should handle different handle positions', () => {
|
||||
const positions = {
|
||||
left: 'left',
|
||||
right: 'right',
|
||||
top: 'top',
|
||||
bottom: 'bottom',
|
||||
}
|
||||
|
||||
Object.values(positions).forEach((position) => {
|
||||
expect(typeof position).toBe('string')
|
||||
expect(position.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases and Error Handling', () => {
|
||||
it.concurrent('should handle circular parent references', () => {
|
||||
// Test circular reference prevention
|
||||
const nodes = [
|
||||
{ id: 'node1', data: { parentId: 'node2' } },
|
||||
{ id: 'node2', data: { parentId: 'node1' } },
|
||||
]
|
||||
|
||||
mockGetNodes.mockReturnValue(nodes)
|
||||
|
||||
// Test the actual component's nesting level calculation logic
|
||||
// This simulates the real useMemo logic from the component
|
||||
let level = 0
|
||||
let currentParentId = 'node1'
|
||||
const visited = new Set<string>()
|
||||
|
||||
// This is the actual logic pattern used in the component
|
||||
while (currentParentId) {
|
||||
// If we've seen this parent before, we have a cycle - break immediately
|
||||
if (visited.has(currentParentId)) {
|
||||
break
|
||||
}
|
||||
|
||||
visited.add(currentParentId)
|
||||
level++
|
||||
|
||||
const parentNode = nodes.find((n) => n.id === currentParentId)
|
||||
if (!parentNode) break
|
||||
|
||||
currentParentId = parentNode.data?.parentId
|
||||
}
|
||||
|
||||
// With proper circular reference detection, we should stop at level 2
|
||||
// (node1 -> node2, then detect cycle when trying to go back to node1)
|
||||
expect(level).toBe(2)
|
||||
expect(visited.has('node1')).toBe(true)
|
||||
expect(visited.has('node2')).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should handle complex circular reference chains', () => {
|
||||
// Test more complex circular reference scenarios
|
||||
const nodes = [
|
||||
{ id: 'node1', data: { parentId: 'node2' } },
|
||||
{ id: 'node2', data: { parentId: 'node3' } },
|
||||
{ id: 'node3', data: { parentId: 'node1' } }, // Creates a 3-node cycle
|
||||
]
|
||||
|
||||
mockGetNodes.mockReturnValue(nodes)
|
||||
|
||||
let level = 0
|
||||
let currentParentId = 'node1'
|
||||
const visited = new Set<string>()
|
||||
|
||||
while (currentParentId) {
|
||||
if (visited.has(currentParentId)) {
|
||||
break // Cycle detected
|
||||
}
|
||||
|
||||
visited.add(currentParentId)
|
||||
level++
|
||||
|
||||
const parentNode = nodes.find((n) => n.id === currentParentId)
|
||||
if (!parentNode) break
|
||||
|
||||
currentParentId = parentNode.data?.parentId
|
||||
}
|
||||
|
||||
// Should traverse node1 -> node2 -> node3, then detect cycle
|
||||
expect(level).toBe(3)
|
||||
expect(visited.size).toBe(3)
|
||||
})
|
||||
|
||||
it.concurrent('should handle self-referencing nodes', () => {
|
||||
// Test node that references itself
|
||||
const nodes = [
|
||||
{ id: 'node1', data: { parentId: 'node1' } }, // Self-reference
|
||||
]
|
||||
|
||||
mockGetNodes.mockReturnValue(nodes)
|
||||
|
||||
let level = 0
|
||||
let currentParentId = 'node1'
|
||||
const visited = new Set<string>()
|
||||
|
||||
while (currentParentId) {
|
||||
if (visited.has(currentParentId)) {
|
||||
break // Cycle detected immediately
|
||||
}
|
||||
|
||||
visited.add(currentParentId)
|
||||
level++
|
||||
|
||||
const parentNode = nodes.find((n) => n.id === currentParentId)
|
||||
if (!parentNode) break
|
||||
|
||||
currentParentId = parentNode.data?.parentId
|
||||
}
|
||||
|
||||
// Should detect self-reference immediately after first iteration
|
||||
expect(level).toBe(1)
|
||||
expect(visited.has('node1')).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should handle extreme values', () => {
|
||||
const extremeValues = [
|
||||
{ width: Number.MAX_SAFE_INTEGER, height: Number.MAX_SAFE_INTEGER },
|
||||
{ width: -1, height: -1 },
|
||||
{ width: 0, height: 0 },
|
||||
{ width: null, height: null },
|
||||
]
|
||||
|
||||
extremeValues.forEach((data) => {
|
||||
expect(() => {
|
||||
const width = data.width || 500
|
||||
const height = data.height || 300
|
||||
expect(typeof width).toBe('number')
|
||||
expect(typeof height).toBe('number')
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
it.concurrent('should handle negative position values', () => {
|
||||
const positions = [
|
||||
{ xPos: -100, yPos: -200 },
|
||||
{ xPos: 0, yPos: 0 },
|
||||
{ xPos: 1000, yPos: 2000 },
|
||||
]
|
||||
|
||||
positions.forEach(({ xPos, yPos }) => {
|
||||
const props = { ...defaultProps, xPos, yPos }
|
||||
expect(props.xPos).toBe(xPos)
|
||||
expect(props.yPos).toBe(yPos)
|
||||
expect(typeof props.xPos).toBe('number')
|
||||
expect(typeof props.yPos).toBe('number')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Component Comparison with Loop Node', () => {
|
||||
it.concurrent('should have similar structure to loop node but different type', () => {
|
||||
expect(defaultProps.type).toBe('parallelNode')
|
||||
expect(defaultProps.id).toContain('parallel')
|
||||
|
||||
// Should not be a loop node
|
||||
expect(defaultProps.type).not.toBe('loopNode')
|
||||
expect(defaultProps.id).not.toContain('loop')
|
||||
})
|
||||
|
||||
it.concurrent('should handle the same prop structure as loop node', () => {
|
||||
// Test that parallel node accepts the same prop structure as loop node
|
||||
const sharedPropStructure = {
|
||||
id: 'test-parallel',
|
||||
type: 'parallelNode' as const,
|
||||
data: {
|
||||
width: 400,
|
||||
height: 300,
|
||||
state: 'valid' as const,
|
||||
},
|
||||
selected: false,
|
||||
zIndex: 1,
|
||||
isConnectable: true,
|
||||
xPos: 0,
|
||||
yPos: 0,
|
||||
dragging: false,
|
||||
}
|
||||
|
||||
expect(() => {
|
||||
const _component: typeof ParallelNodeComponent = ParallelNodeComponent
|
||||
expect(_component).toBeDefined()
|
||||
}).not.toThrow()
|
||||
|
||||
// Verify the structure
|
||||
expect(sharedPropStructure.type).toBe('parallelNode')
|
||||
expect(sharedPropStructure.data.width).toBe(400)
|
||||
expect(sharedPropStructure.data.height).toBe(300)
|
||||
})
|
||||
|
||||
it.concurrent('should maintain consistency with loop node interface', () => {
|
||||
const baseProps = [
|
||||
'id',
|
||||
'type',
|
||||
'data',
|
||||
'selected',
|
||||
'zIndex',
|
||||
'isConnectable',
|
||||
'xPos',
|
||||
'yPos',
|
||||
'dragging',
|
||||
]
|
||||
|
||||
baseProps.forEach((prop) => {
|
||||
expect(defaultProps).toHaveProperty(prop)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,273 +0,0 @@
|
||||
import type React from 'react'
|
||||
import { memo, useMemo, useRef } from 'react'
|
||||
import { Trash2 } from 'lucide-react'
|
||||
import { Handle, type NodeProps, Position, useReactFlow } from 'reactflow'
|
||||
import { StartIcon } from '@/components/icons'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { IterationBadges } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/components/iteration-badges/iteration-badges'
|
||||
import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
|
||||
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
||||
|
||||
const ParallelNodeStyles: React.FC = () => {
|
||||
return (
|
||||
<style jsx global>{`
|
||||
@keyframes parallel-node-pulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(139, 195, 74, 0.3);
|
||||
}
|
||||
70% {
|
||||
box-shadow: 0 0 0 6px rgba(139, 195, 74, 0);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(139, 195, 74, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.parallel-node-drag-over {
|
||||
animation: parallel-node-pulse 1.2s cubic-bezier(0.4, 0, 0.6, 1)
|
||||
infinite;
|
||||
border-style: solid !important;
|
||||
background-color: rgba(139, 195, 74, 0.08) !important;
|
||||
box-shadow: 0 0 0 8px rgba(139, 195, 74, 0.1);
|
||||
}
|
||||
|
||||
/* Make resizer handles more visible */
|
||||
.react-flow__resize-control {
|
||||
z-index: 10;
|
||||
pointer-events: all !important;
|
||||
}
|
||||
|
||||
/* Ensure parent borders are visible when hovering over resize controls */
|
||||
.react-flow__node-group:hover,
|
||||
.hover-highlight {
|
||||
border-color: #1e293b !important;
|
||||
}
|
||||
|
||||
/* Ensure hover effects work well */
|
||||
.group-node-container:hover .react-flow__resize-control.bottom-right {
|
||||
opacity: 1 !important;
|
||||
visibility: visible !important;
|
||||
}
|
||||
|
||||
/* React Flow position transitions within parallel blocks */
|
||||
.react-flow__node[data-parent-node-id] {
|
||||
transition: transform 0.05s ease;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
/* Prevent jumpy drag behavior */
|
||||
.parallel-drop-container .react-flow__node {
|
||||
transform-origin: center;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
/* Remove default border from React Flow group nodes */
|
||||
.react-flow__node-group {
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* Ensure child nodes stay within parent bounds */
|
||||
.react-flow__node[data-parent-node-id] .react-flow__handle {
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
/* Enhanced drag detection */
|
||||
.react-flow__node-group.dragging-over {
|
||||
background-color: rgba(139, 195, 74, 0.05);
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
`}</style>
|
||||
)
|
||||
}
|
||||
|
||||
export const ParallelNodeComponent = memo(({ data, selected, id }: NodeProps) => {
|
||||
const { getNodes } = useReactFlow()
|
||||
const { collaborativeRemoveBlock } = useCollaborativeWorkflow()
|
||||
const blockRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Use the clean abstraction for current workflow state
|
||||
const currentWorkflow = useCurrentWorkflow()
|
||||
const currentBlock = currentWorkflow.getBlockById(id)
|
||||
const diffStatus =
|
||||
currentWorkflow.isDiffMode && currentBlock ? (currentBlock as any).is_diff : undefined
|
||||
|
||||
// Check if this is preview mode
|
||||
const isPreview = data?.isPreview || false
|
||||
|
||||
// Determine nesting level by counting parents
|
||||
const nestingLevel = useMemo(() => {
|
||||
const maxDepth = 100 // Prevent infinite loops
|
||||
let level = 0
|
||||
let currentParentId = data?.parentId
|
||||
|
||||
while (currentParentId && level < maxDepth) {
|
||||
level++
|
||||
const parentNode = getNodes().find((n) => n.id === currentParentId)
|
||||
if (!parentNode) break
|
||||
currentParentId = parentNode.data?.parentId
|
||||
}
|
||||
|
||||
return level
|
||||
}, [id, data?.parentId, getNodes])
|
||||
|
||||
// Generate different background styles based on nesting level
|
||||
const getNestedStyles = () => {
|
||||
// Base styles
|
||||
const styles: Record<string, string> = {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.02)',
|
||||
}
|
||||
|
||||
// Apply nested styles
|
||||
if (nestingLevel > 0) {
|
||||
// Each nesting level gets a different color
|
||||
const colors = ['#e2e8f0', '#cbd5e1', '#94a3b8', '#64748b', '#475569']
|
||||
const colorIndex = (nestingLevel - 1) % colors.length
|
||||
|
||||
styles.backgroundColor = `${colors[colorIndex]}30` // Slightly more visible background
|
||||
}
|
||||
|
||||
return styles
|
||||
}
|
||||
|
||||
const nestedStyles = getNestedStyles()
|
||||
|
||||
return (
|
||||
<>
|
||||
<ParallelNodeStyles />
|
||||
<div className='group relative'>
|
||||
<Card
|
||||
ref={blockRef}
|
||||
className={cn(
|
||||
'relative cursor-default select-none',
|
||||
'transition-block-bg transition-ring',
|
||||
'z-[20]',
|
||||
data?.state === 'valid',
|
||||
nestingLevel > 0 &&
|
||||
`border border-[0.5px] ${nestingLevel % 2 === 0 ? 'border-slate-300/60' : 'border-slate-400/60'}`,
|
||||
data?.hasNestedError && 'border-2 border-red-500 bg-red-50/50',
|
||||
// Diff highlighting
|
||||
diffStatus === 'new' && 'bg-green-50/50 ring-2 ring-green-500 dark:bg-green-900/10',
|
||||
diffStatus === 'edited' &&
|
||||
'bg-orange-50/50 ring-2 ring-orange-500 dark:bg-orange-900/10'
|
||||
)}
|
||||
style={{
|
||||
width: data.width || 500,
|
||||
height: data.height || 300,
|
||||
position: 'relative',
|
||||
overflow: 'visible',
|
||||
...nestedStyles,
|
||||
pointerEvents: isPreview ? 'none' : 'all',
|
||||
}}
|
||||
data-node-id={id}
|
||||
data-type='parallelNode'
|
||||
data-nesting-level={nestingLevel}
|
||||
>
|
||||
{/* Critical drag handle that controls only the parallel node movement */}
|
||||
{!isPreview && (
|
||||
<div
|
||||
className='workflow-drag-handle absolute top-0 right-0 left-0 z-10 h-10 cursor-move'
|
||||
style={{ pointerEvents: 'auto' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Custom visible resize handle */}
|
||||
{!isPreview && (
|
||||
<div
|
||||
className='absolute right-2 bottom-2 z-20 flex h-8 w-8 cursor-se-resize items-center justify-center text-muted-foreground'
|
||||
style={{ pointerEvents: 'auto' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Child nodes container - Set pointerEvents to allow dragging of children */}
|
||||
<div
|
||||
className='h-[calc(100%-10px)] p-4'
|
||||
data-dragarea='true'
|
||||
style={{
|
||||
position: 'relative',
|
||||
minHeight: '100%',
|
||||
pointerEvents: isPreview ? 'none' : 'auto',
|
||||
}}
|
||||
>
|
||||
{/* Delete button - styled like in action-bar.tsx */}
|
||||
{!isPreview && (
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
collaborativeRemoveBlock(id)
|
||||
}}
|
||||
className='absolute top-2 right-2 z-20 text-gray-500 opacity-0 transition-opacity duration-200 hover:text-red-600 group-hover:opacity-100'
|
||||
style={{ pointerEvents: 'auto' }}
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Parallel Start Block */}
|
||||
<div
|
||||
className='-translate-y-1/2 absolute top-1/2 left-8 flex h-10 w-10 transform items-center justify-center rounded-md bg-[#FEE12B] p-2'
|
||||
style={{ pointerEvents: isPreview ? 'none' : 'auto' }}
|
||||
data-parent-id={id}
|
||||
data-node-role='parallel-start'
|
||||
data-extent='parent'
|
||||
>
|
||||
<StartIcon className='h-6 w-6 text-white' />
|
||||
|
||||
<Handle
|
||||
type='source'
|
||||
position={Position.Right}
|
||||
id='parallel-start-source'
|
||||
className='!w-[6px] !h-4 !bg-slate-300 dark:!bg-slate-500 !rounded-[2px] !border-none !z-[30] hover:!w-[10px] hover:!right-[-10px] hover:!rounded-r-full hover:!rounded-l-none !cursor-crosshair transition-[colors] duration-150'
|
||||
style={{
|
||||
right: '-6px',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
pointerEvents: 'auto',
|
||||
}}
|
||||
data-parent-id={id}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Input handle on left middle */}
|
||||
<Handle
|
||||
type='target'
|
||||
position={Position.Left}
|
||||
className='!w-[7px] !h-5 !bg-slate-300 dark:!bg-slate-500 !rounded-[2px] !border-none !z-[30] hover:!w-[10px] hover:!left-[-10px] hover:!rounded-l-full hover:!rounded-r-none !cursor-crosshair transition-[colors] duration-150'
|
||||
style={{
|
||||
left: '-7px',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
pointerEvents: 'auto',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Output handle on right middle */}
|
||||
<Handle
|
||||
type='source'
|
||||
position={Position.Right}
|
||||
className='!w-[7px] !h-5 !bg-slate-300 dark:!bg-slate-500 !rounded-[2px] !border-none !z-[30] hover:!w-[10px] hover:!right-[-10px] hover:!rounded-r-full hover:!rounded-l-none !cursor-crosshair transition-[colors] duration-150'
|
||||
style={{
|
||||
right: '-7px',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
pointerEvents: 'auto',
|
||||
}}
|
||||
id='parallel-end-source'
|
||||
/>
|
||||
|
||||
{/* Parallel Configuration Badges */}
|
||||
<IterationBadges nodeId={id} data={data} iterationType='parallel' />
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
ParallelNodeComponent.displayName = 'ParallelNodeComponent'
|
||||
@@ -0,0 +1,579 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { SubflowNodeComponent } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node'
|
||||
|
||||
// Shared spies used across mocks
|
||||
const mockRemoveBlock = vi.fn()
|
||||
const mockGetNodes = vi.fn()
|
||||
|
||||
// Mocks
|
||||
vi.mock('@/hooks/use-collaborative-workflow', () => ({
|
||||
useCollaborativeWorkflow: vi.fn(() => ({
|
||||
collaborativeRemoveBlock: mockRemoveBlock,
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/logs/console/logger', () => ({
|
||||
createLogger: vi.fn(() => ({
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('reactflow', () => ({
|
||||
Handle: ({ id, type, position }: any) => ({ id, type, position }),
|
||||
Position: {
|
||||
Top: 'top',
|
||||
Bottom: 'bottom',
|
||||
Left: 'left',
|
||||
Right: 'right',
|
||||
},
|
||||
useReactFlow: () => ({
|
||||
getNodes: mockGetNodes,
|
||||
}),
|
||||
memo: (component: any) => component,
|
||||
}))
|
||||
|
||||
vi.mock('react', async () => {
|
||||
const actual = await vi.importActual<any>('react')
|
||||
return {
|
||||
...actual,
|
||||
memo: (component: any) => component,
|
||||
useMemo: (fn: any) => fn(),
|
||||
useRef: () => ({ current: null }),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/components/ui/button', () => ({
|
||||
Button: ({ children, onClick, ...props }: any) => ({ children, onClick, ...props }),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/card', () => ({
|
||||
Card: ({ children, ...props }: any) => ({ children, ...props }),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/icons', async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as any
|
||||
return {
|
||||
...actual,
|
||||
StartIcon: ({ className }: any) => ({ className }),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/lib/utils', () => ({
|
||||
cn: (...classes: any[]) => classes.filter(Boolean).join(' '),
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/components/iteration-badges/iteration-badges',
|
||||
() => ({
|
||||
IterationBadges: ({ nodeId, iterationType }: any) => ({ nodeId, iterationType }),
|
||||
})
|
||||
)
|
||||
|
||||
describe('SubflowNodeComponent', () => {
|
||||
const defaultProps = {
|
||||
id: 'subflow-1',
|
||||
type: 'subflowNode',
|
||||
data: {
|
||||
width: 500,
|
||||
height: 300,
|
||||
isPreview: false,
|
||||
kind: 'loop' as const,
|
||||
},
|
||||
selected: false,
|
||||
zIndex: 1,
|
||||
isConnectable: true,
|
||||
xPos: 0,
|
||||
yPos: 0,
|
||||
dragging: false,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockGetNodes.mockReturnValue([])
|
||||
})
|
||||
|
||||
describe('Component Definition and Structure', () => {
|
||||
it.concurrent('should be defined as a function component', () => {
|
||||
expect(SubflowNodeComponent).toBeDefined()
|
||||
expect(typeof SubflowNodeComponent).toBe('function')
|
||||
})
|
||||
|
||||
it.concurrent('should have correct display name', () => {
|
||||
expect(SubflowNodeComponent.displayName).toBe('SubflowNodeComponent')
|
||||
})
|
||||
|
||||
it.concurrent('should be a memoized component', () => {
|
||||
expect(SubflowNodeComponent).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props Validation and Type Safety', () => {
|
||||
it.concurrent('should accept NodeProps interface', () => {
|
||||
const validProps = {
|
||||
id: 'test-id',
|
||||
type: 'subflowNode' as const,
|
||||
data: {
|
||||
width: 400,
|
||||
height: 300,
|
||||
isPreview: true,
|
||||
kind: 'parallel' as const,
|
||||
},
|
||||
selected: false,
|
||||
zIndex: 1,
|
||||
isConnectable: true,
|
||||
xPos: 0,
|
||||
yPos: 0,
|
||||
dragging: false,
|
||||
}
|
||||
|
||||
expect(() => {
|
||||
const _component: typeof SubflowNodeComponent = SubflowNodeComponent
|
||||
expect(_component).toBeDefined()
|
||||
expect(validProps.type).toBe('subflowNode')
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it.concurrent('should handle different data configurations', () => {
|
||||
const configurations = [
|
||||
{ width: 500, height: 300, isPreview: false, kind: 'loop' as const },
|
||||
{ width: 800, height: 600, isPreview: true, kind: 'parallel' as const },
|
||||
{ width: 0, height: 0, isPreview: false, kind: 'loop' as const },
|
||||
{ kind: 'loop' as const },
|
||||
]
|
||||
|
||||
configurations.forEach((data) => {
|
||||
const props = { ...defaultProps, data }
|
||||
expect(() => {
|
||||
const _component: typeof SubflowNodeComponent = SubflowNodeComponent
|
||||
expect(_component).toBeDefined()
|
||||
expect(props.data).toBeDefined()
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Hook Integration', () => {
|
||||
it.concurrent('should provide collaborativeRemoveBlock', () => {
|
||||
expect(mockRemoveBlock).toBeDefined()
|
||||
expect(typeof mockRemoveBlock).toBe('function')
|
||||
mockRemoveBlock('test-id')
|
||||
expect(mockRemoveBlock).toHaveBeenCalledWith('test-id')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Component Logic Tests', () => {
|
||||
it.concurrent('should handle nesting level calculation logic', () => {
|
||||
const testCases = [
|
||||
{ nodes: [], parentId: undefined, expectedLevel: 0 },
|
||||
{ nodes: [{ id: 'parent', data: {} }], parentId: 'parent', expectedLevel: 1 },
|
||||
{
|
||||
nodes: [
|
||||
{ id: 'parent', data: { parentId: 'grandparent' } },
|
||||
{ id: 'grandparent', data: {} },
|
||||
],
|
||||
parentId: 'parent',
|
||||
expectedLevel: 2,
|
||||
},
|
||||
]
|
||||
|
||||
testCases.forEach(({ nodes, parentId, expectedLevel }) => {
|
||||
mockGetNodes.mockReturnValue(nodes)
|
||||
|
||||
// Simulate the nesting level calculation logic
|
||||
let level = 0
|
||||
let currentParentId = parentId
|
||||
|
||||
while (currentParentId) {
|
||||
level++
|
||||
const parentNode = nodes.find((n) => n.id === currentParentId)
|
||||
if (!parentNode) break
|
||||
currentParentId = parentNode.data?.parentId
|
||||
}
|
||||
|
||||
expect(level).toBe(expectedLevel)
|
||||
})
|
||||
})
|
||||
|
||||
it.concurrent('should handle nested styles generation', () => {
|
||||
// Test the nested styles logic
|
||||
const testCases = [
|
||||
{ nestingLevel: 0, expectedBg: 'rgba(34,197,94,0.05)' },
|
||||
{ nestingLevel: 1, expectedBg: '#e2e8f030' },
|
||||
{ nestingLevel: 2, expectedBg: '#cbd5e130' },
|
||||
]
|
||||
|
||||
testCases.forEach(({ nestingLevel, expectedBg }) => {
|
||||
// Simulate the getNestedStyles logic
|
||||
const styles: Record<string, string> = {
|
||||
backgroundColor: 'rgba(34,197,94,0.05)',
|
||||
}
|
||||
|
||||
if (nestingLevel > 0) {
|
||||
const colors = ['#e2e8f0', '#cbd5e1', '#94a3b8', '#64748b', '#475569']
|
||||
const colorIndex = (nestingLevel - 1) % colors.length
|
||||
styles.backgroundColor = `${colors[colorIndex]}30`
|
||||
}
|
||||
|
||||
expect(styles.backgroundColor).toBe(expectedBg)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Component Configuration', () => {
|
||||
it.concurrent('should handle different dimensions', () => {
|
||||
const dimensionTests = [
|
||||
{ width: 500, height: 300 },
|
||||
{ width: 800, height: 600 },
|
||||
{ width: 0, height: 0 },
|
||||
{ width: 10000, height: 10000 },
|
||||
]
|
||||
|
||||
dimensionTests.forEach(({ width, height }) => {
|
||||
const data = { width, height }
|
||||
expect(data.width).toBe(width)
|
||||
expect(data.height).toBe(height)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Event Handling Logic', () => {
|
||||
it.concurrent('should handle delete button click logic (simulated)', () => {
|
||||
const mockEvent = { stopPropagation: vi.fn() }
|
||||
|
||||
const handleDelete = (e: any, nodeId: string) => {
|
||||
e.stopPropagation()
|
||||
mockRemoveBlock(nodeId)
|
||||
}
|
||||
|
||||
handleDelete(mockEvent, 'test-id')
|
||||
|
||||
expect(mockEvent.stopPropagation).toHaveBeenCalled()
|
||||
expect(mockRemoveBlock).toHaveBeenCalledWith('test-id')
|
||||
})
|
||||
|
||||
it.concurrent('should handle event propagation prevention', () => {
|
||||
const mockEvent = { stopPropagation: vi.fn() }
|
||||
mockEvent.stopPropagation()
|
||||
expect(mockEvent.stopPropagation).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Component Data Handling', () => {
|
||||
it.concurrent('should handle missing data properties gracefully', () => {
|
||||
const testCases = [
|
||||
undefined,
|
||||
{},
|
||||
{ width: 500 },
|
||||
{ height: 300 },
|
||||
{ width: 500, height: 300 },
|
||||
]
|
||||
|
||||
testCases.forEach((data: any) => {
|
||||
const props = { ...defaultProps, data }
|
||||
const width = Math.max(0, data?.width || 500)
|
||||
const height = Math.max(0, data?.height || 300)
|
||||
expect(width).toBeGreaterThanOrEqual(0)
|
||||
expect(height).toBeGreaterThanOrEqual(0)
|
||||
expect(props.type).toBe('subflowNode')
|
||||
})
|
||||
})
|
||||
|
||||
it.concurrent('should handle parent ID relationships', () => {
|
||||
const testCases = [
|
||||
{ parentId: undefined, hasParent: false },
|
||||
{ parentId: 'parent-1', hasParent: true },
|
||||
{ parentId: '', hasParent: false },
|
||||
]
|
||||
|
||||
testCases.forEach(({ parentId, hasParent }) => {
|
||||
const data = { ...defaultProps.data, parentId }
|
||||
expect(Boolean(data.parentId)).toBe(hasParent)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Loop vs Parallel Kind Specific Tests', () => {
|
||||
it.concurrent('should generate correct handle IDs for loop kind', () => {
|
||||
const loopData = { ...defaultProps.data, kind: 'loop' as const }
|
||||
const startHandleId = loopData.kind === 'loop' ? 'loop-start-source' : 'parallel-start-source'
|
||||
const endHandleId = loopData.kind === 'loop' ? 'loop-end-source' : 'parallel-end-source'
|
||||
|
||||
expect(startHandleId).toBe('loop-start-source')
|
||||
expect(endHandleId).toBe('loop-end-source')
|
||||
})
|
||||
|
||||
it.concurrent('should generate correct handle IDs for parallel kind', () => {
|
||||
type SubflowKind = 'loop' | 'parallel'
|
||||
const testHandleGeneration = (kind: SubflowKind) => {
|
||||
const startHandleId = kind === 'loop' ? 'loop-start-source' : 'parallel-start-source'
|
||||
const endHandleId = kind === 'loop' ? 'loop-end-source' : 'parallel-end-source'
|
||||
return { startHandleId, endHandleId }
|
||||
}
|
||||
|
||||
const result = testHandleGeneration('parallel')
|
||||
expect(result.startHandleId).toBe('parallel-start-source')
|
||||
expect(result.endHandleId).toBe('parallel-end-source')
|
||||
})
|
||||
|
||||
it.concurrent('should generate correct background colors for loop kind', () => {
|
||||
const loopData = { ...defaultProps.data, kind: 'loop' as const }
|
||||
const startBg = loopData.kind === 'loop' ? '#2FB3FF' : '#FEE12B'
|
||||
|
||||
expect(startBg).toBe('#2FB3FF')
|
||||
})
|
||||
|
||||
it.concurrent('should generate correct background colors for parallel kind', () => {
|
||||
type SubflowKind = 'loop' | 'parallel'
|
||||
const testBgGeneration = (kind: SubflowKind) => {
|
||||
return kind === 'loop' ? '#2FB3FF' : '#FEE12B'
|
||||
}
|
||||
|
||||
const startBg = testBgGeneration('parallel')
|
||||
expect(startBg).toBe('#FEE12B')
|
||||
})
|
||||
|
||||
it.concurrent('should demonstrate handle ID generation for any kind', () => {
|
||||
type SubflowKind = 'loop' | 'parallel'
|
||||
const testKind = (kind: SubflowKind) => {
|
||||
const data = { kind }
|
||||
const startHandleId = data.kind === 'loop' ? 'loop-start-source' : 'parallel-start-source'
|
||||
const endHandleId = data.kind === 'loop' ? 'loop-end-source' : 'parallel-end-source'
|
||||
return { startHandleId, endHandleId }
|
||||
}
|
||||
|
||||
const loopResult = testKind('loop')
|
||||
expect(loopResult.startHandleId).toBe('loop-start-source')
|
||||
expect(loopResult.endHandleId).toBe('loop-end-source')
|
||||
|
||||
const parallelResult = testKind('parallel')
|
||||
expect(parallelResult.startHandleId).toBe('parallel-start-source')
|
||||
expect(parallelResult.endHandleId).toBe('parallel-end-source')
|
||||
})
|
||||
|
||||
it.concurrent('should pass correct iterationType to IterationBadges for loop', () => {
|
||||
const loopProps = { ...defaultProps, data: { ...defaultProps.data, kind: 'loop' as const } }
|
||||
// Mock IterationBadges should receive the kind as iterationType
|
||||
expect(loopProps.data.kind).toBe('loop')
|
||||
})
|
||||
|
||||
it.concurrent('should pass correct iterationType to IterationBadges for parallel', () => {
|
||||
const parallelProps = {
|
||||
...defaultProps,
|
||||
data: { ...defaultProps.data, kind: 'parallel' as const },
|
||||
}
|
||||
// Mock IterationBadges should receive the kind as iterationType
|
||||
expect(parallelProps.data.kind).toBe('parallel')
|
||||
})
|
||||
|
||||
it.concurrent('should handle both kinds in configuration arrays', () => {
|
||||
const bothKinds = ['loop', 'parallel'] as const
|
||||
bothKinds.forEach((kind) => {
|
||||
const data = { ...defaultProps.data, kind }
|
||||
expect(['loop', 'parallel']).toContain(data.kind)
|
||||
|
||||
// Test handle ID generation for both kinds
|
||||
const startHandleId = data.kind === 'loop' ? 'loop-start-source' : 'parallel-start-source'
|
||||
const endHandleId = data.kind === 'loop' ? 'loop-end-source' : 'parallel-end-source'
|
||||
const startBg = data.kind === 'loop' ? '#2FB3FF' : '#FEE12B'
|
||||
|
||||
if (kind === 'loop') {
|
||||
expect(startHandleId).toBe('loop-start-source')
|
||||
expect(endHandleId).toBe('loop-end-source')
|
||||
expect(startBg).toBe('#2FB3FF')
|
||||
} else {
|
||||
expect(startHandleId).toBe('parallel-start-source')
|
||||
expect(endHandleId).toBe('parallel-end-source')
|
||||
expect(startBg).toBe('#FEE12B')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it.concurrent('should maintain consistent styling behavior across both kinds', () => {
|
||||
const loopProps = { ...defaultProps, data: { ...defaultProps.data, kind: 'loop' as const } }
|
||||
const parallelProps = {
|
||||
...defaultProps,
|
||||
data: { ...defaultProps.data, kind: 'parallel' as const },
|
||||
}
|
||||
|
||||
// Both should have same base properties except kind-specific ones
|
||||
expect(loopProps.data.width).toBe(parallelProps.data.width)
|
||||
expect(loopProps.data.height).toBe(parallelProps.data.height)
|
||||
expect(loopProps.data.isPreview).toBe(parallelProps.data.isPreview)
|
||||
|
||||
// But different kinds
|
||||
expect(loopProps.data.kind).toBe('loop')
|
||||
expect(parallelProps.data.kind).toBe('parallel')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Integration with IterationBadges', () => {
|
||||
it.concurrent('should pass nodeId to IterationBadges', () => {
|
||||
const testId = 'test-subflow-123'
|
||||
const props = { ...defaultProps, id: testId }
|
||||
|
||||
// Verify the props would be passed correctly
|
||||
expect(props.id).toBe(testId)
|
||||
})
|
||||
|
||||
it.concurrent('should pass data object to IterationBadges', () => {
|
||||
const testData = { ...defaultProps.data, customProperty: 'test' }
|
||||
const props = { ...defaultProps, data: testData }
|
||||
|
||||
// Verify the data object structure
|
||||
expect(props.data).toEqual(testData)
|
||||
expect(props.data.kind).toBeDefined()
|
||||
})
|
||||
|
||||
it.concurrent('should pass iterationType matching the kind', () => {
|
||||
const loopProps = { ...defaultProps, data: { ...defaultProps.data, kind: 'loop' as const } }
|
||||
const parallelProps = {
|
||||
...defaultProps,
|
||||
data: { ...defaultProps.data, kind: 'parallel' as const },
|
||||
}
|
||||
|
||||
// The iterationType should match the kind
|
||||
expect(loopProps.data.kind).toBe('loop')
|
||||
expect(parallelProps.data.kind).toBe('parallel')
|
||||
})
|
||||
})
|
||||
|
||||
describe('CSS Class Generation', () => {
|
||||
it.concurrent('should generate proper CSS classes for nested loops', () => {
|
||||
const nestingLevel = 2
|
||||
const expectedBorderClass =
|
||||
nestingLevel > 0 &&
|
||||
`border border-[0.5px] ${nestingLevel % 2 === 0 ? 'border-slate-300/60' : 'border-slate-400/60'}`
|
||||
|
||||
expect(expectedBorderClass).toBeTruthy()
|
||||
expect(expectedBorderClass).toContain('border-slate-300/60') // even nesting level
|
||||
})
|
||||
|
||||
it.concurrent('should generate proper CSS classes for odd nested levels', () => {
|
||||
const nestingLevel = 3
|
||||
const expectedBorderClass =
|
||||
nestingLevel > 0 &&
|
||||
`border border-[0.5px] ${nestingLevel % 2 === 0 ? 'border-slate-300/60' : 'border-slate-400/60'}`
|
||||
|
||||
expect(expectedBorderClass).toBeTruthy()
|
||||
expect(expectedBorderClass).toContain('border-slate-400/60') // odd nesting level
|
||||
})
|
||||
|
||||
it.concurrent('should handle error state styling', () => {
|
||||
const hasNestedError = true
|
||||
const errorClasses = hasNestedError && 'border-2 border-red-500 bg-red-50/50'
|
||||
|
||||
expect(errorClasses).toBe('border-2 border-red-500 bg-red-50/50')
|
||||
})
|
||||
|
||||
it.concurrent('should handle diff status styling', () => {
|
||||
const diffStatuses = ['new', 'edited'] as const
|
||||
|
||||
diffStatuses.forEach((status) => {
|
||||
let diffClass = ''
|
||||
if (status === 'new') {
|
||||
diffClass = 'bg-green-50/50 ring-2 ring-green-500 dark:bg-green-900/10'
|
||||
} else if (status === 'edited') {
|
||||
diffClass = 'bg-orange-50/50 ring-2 ring-orange-500 dark:bg-orange-900/10'
|
||||
}
|
||||
|
||||
expect(diffClass).toBeTruthy()
|
||||
if (status === 'new') {
|
||||
expect(diffClass).toContain('ring-green-500')
|
||||
} else {
|
||||
expect(diffClass).toContain('ring-orange-500')
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases and Error Handling', () => {
|
||||
it.concurrent('should handle circular parent references', () => {
|
||||
const nodes = [
|
||||
{ id: 'node1', data: { parentId: 'node2' } },
|
||||
{ id: 'node2', data: { parentId: 'node1' } },
|
||||
]
|
||||
|
||||
mockGetNodes.mockReturnValue(nodes)
|
||||
|
||||
let level = 0
|
||||
let currentParentId = 'node1'
|
||||
const visited = new Set<string>()
|
||||
|
||||
while (currentParentId) {
|
||||
if (visited.has(currentParentId)) {
|
||||
break
|
||||
}
|
||||
|
||||
visited.add(currentParentId)
|
||||
level++
|
||||
|
||||
const parentNode = nodes.find((n) => n.id === currentParentId)
|
||||
if (!parentNode) break
|
||||
currentParentId = parentNode.data?.parentId
|
||||
}
|
||||
|
||||
expect(level).toBe(2)
|
||||
expect(visited.has('node1')).toBe(true)
|
||||
expect(visited.has('node2')).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should handle complex circular reference chains', () => {
|
||||
const nodes = [
|
||||
{ id: 'node1', data: { parentId: 'node2' } },
|
||||
{ id: 'node2', data: { parentId: 'node3' } },
|
||||
{ id: 'node3', data: { parentId: 'node1' } },
|
||||
]
|
||||
|
||||
mockGetNodes.mockReturnValue(nodes)
|
||||
|
||||
let level = 0
|
||||
let currentParentId = 'node1'
|
||||
const visited = new Set<string>()
|
||||
|
||||
while (currentParentId) {
|
||||
if (visited.has(currentParentId)) {
|
||||
break
|
||||
}
|
||||
|
||||
visited.add(currentParentId)
|
||||
level++
|
||||
|
||||
const parentNode = nodes.find((n) => n.id === currentParentId)
|
||||
if (!parentNode) break
|
||||
currentParentId = parentNode.data?.parentId
|
||||
}
|
||||
|
||||
expect(level).toBe(3)
|
||||
expect(visited.size).toBe(3)
|
||||
})
|
||||
|
||||
it.concurrent('should handle self-referencing nodes', () => {
|
||||
const nodes = [{ id: 'node1', data: { parentId: 'node1' } }]
|
||||
|
||||
mockGetNodes.mockReturnValue(nodes)
|
||||
|
||||
let level = 0
|
||||
let currentParentId = 'node1'
|
||||
const visited = new Set<string>()
|
||||
|
||||
while (currentParentId) {
|
||||
if (visited.has(currentParentId)) {
|
||||
break
|
||||
}
|
||||
|
||||
visited.add(currentParentId)
|
||||
level++
|
||||
|
||||
const parentNode = nodes.find((n) => n.id === currentParentId)
|
||||
if (!parentNode) break
|
||||
currentParentId = parentNode.data?.parentId
|
||||
}
|
||||
|
||||
expect(level).toBe(1)
|
||||
expect(visited.has('node1')).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -6,60 +6,54 @@ import { StartIcon } from '@/components/icons'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { type DiffStatus, hasDiffStatus } from '@/lib/workflows/diff/types'
|
||||
import { IterationBadges } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/components/iteration-badges/iteration-badges'
|
||||
import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
|
||||
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
||||
|
||||
// Add these styles to your existing global CSS file or create a separate CSS module
|
||||
const LoopNodeStyles: React.FC = () => {
|
||||
const SubflowNodeStyles: React.FC = () => {
|
||||
return (
|
||||
<style jsx global>{`
|
||||
@keyframes loop-node-pulse {
|
||||
0% { box-shadow: 0 0 0 0 rgba(64, 224, 208, 0.3); }
|
||||
70% { box-shadow: 0 0 0 6px rgba(64, 224, 208, 0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(64, 224, 208, 0); }
|
||||
0% { box-shadow: 0 0 0 0 rgba(47, 179, 255, 0.3); }
|
||||
70% { box-shadow: 0 0 0 6px rgba(47, 179, 255, 0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(47, 179, 255, 0); }
|
||||
}
|
||||
|
||||
|
||||
@keyframes parallel-node-pulse {
|
||||
0% { box-shadow: 0 0 0 0 rgba(139, 195, 74, 0.3); }
|
||||
70% { box-shadow: 0 0 0 6px rgba(139, 195, 74, 0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(139, 195, 74, 0); }
|
||||
}
|
||||
|
||||
.loop-node-drag-over {
|
||||
animation: loop-node-pulse 1.2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
border-style: solid !important;
|
||||
background-color: rgba(47, 179, 255, 0.08) !important;
|
||||
box-shadow: 0 0 0 8px rgba(47, 179, 255, 0.1);
|
||||
}
|
||||
|
||||
/* Ensure parent borders are visible when hovering over resize controls */
|
||||
|
||||
.parallel-node-drag-over {
|
||||
animation: parallel-node-pulse 1.2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
border-style: solid !important;
|
||||
background-color: rgba(139, 195, 74, 0.08) !important;
|
||||
box-shadow: 0 0 0 8px rgba(139, 195, 74, 0.1);
|
||||
}
|
||||
|
||||
.react-flow__node-group:hover,
|
||||
.hover-highlight {
|
||||
border-color: #1e293b !important;
|
||||
}
|
||||
|
||||
/* Ensure hover effects work well */
|
||||
|
||||
.group-node-container:hover .react-flow__resize-control.bottom-right {
|
||||
opacity: 1 !important;
|
||||
visibility: visible !important;
|
||||
}
|
||||
|
||||
|
||||
/* Prevent jumpy drag behavior */
|
||||
.loop-drop-container .react-flow__node {
|
||||
transform-origin: center;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
/* Remove default border from React Flow group nodes */
|
||||
.react-flow__node-group {
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* Ensure child nodes stay within parent bounds */
|
||||
|
||||
.react-flow__node[data-parent-node-id] .react-flow__handle {
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
/* Enhanced drag detection */
|
||||
|
||||
.react-flow__node-group.dragging-over {
|
||||
background-color: rgba(34,197,94,0.05);
|
||||
transition: all 0.2s ease-in-out;
|
||||
@@ -68,21 +62,30 @@ const LoopNodeStyles: React.FC = () => {
|
||||
)
|
||||
}
|
||||
|
||||
export const LoopNodeComponent = memo(({ data, selected, id }: NodeProps) => {
|
||||
export interface SubflowNodeData {
|
||||
width?: number
|
||||
height?: number
|
||||
parentId?: string
|
||||
extent?: 'parent'
|
||||
hasNestedError?: boolean
|
||||
isPreview?: boolean
|
||||
kind: 'loop' | 'parallel'
|
||||
}
|
||||
|
||||
export const SubflowNodeComponent = memo(({ data, id }: NodeProps<SubflowNodeData>) => {
|
||||
const { getNodes } = useReactFlow()
|
||||
const { collaborativeRemoveBlock } = useCollaborativeWorkflow()
|
||||
const blockRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Use the clean abstraction for current workflow state
|
||||
const currentWorkflow = useCurrentWorkflow()
|
||||
const currentBlock = currentWorkflow.getBlockById(id)
|
||||
const diffStatus =
|
||||
currentWorkflow.isDiffMode && currentBlock ? (currentBlock as any).is_diff : undefined
|
||||
const diffStatus: DiffStatus =
|
||||
currentWorkflow.isDiffMode && currentBlock && hasDiffStatus(currentBlock)
|
||||
? currentBlock.is_diff
|
||||
: undefined
|
||||
|
||||
// Check if this is preview mode
|
||||
const isPreview = data?.isPreview || false
|
||||
|
||||
// Determine nesting level by counting parents
|
||||
const nestingLevel = useMemo(() => {
|
||||
let level = 0
|
||||
let currentParentId = data?.parentId
|
||||
@@ -97,42 +100,37 @@ export const LoopNodeComponent = memo(({ data, selected, id }: NodeProps) => {
|
||||
return level
|
||||
}, [id, data?.parentId, getNodes])
|
||||
|
||||
// Generate different background styles based on nesting level
|
||||
const getNestedStyles = () => {
|
||||
// Base styles
|
||||
const styles: Record<string, string> = {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.02)',
|
||||
}
|
||||
|
||||
// Apply nested styles
|
||||
if (nestingLevel > 0) {
|
||||
// Each nesting level gets a different color
|
||||
const colors = ['#e2e8f0', '#cbd5e1', '#94a3b8', '#64748b', '#475569']
|
||||
const colorIndex = (nestingLevel - 1) % colors.length
|
||||
|
||||
styles.backgroundColor = `${colors[colorIndex]}30` // Slightly more visible background
|
||||
styles.backgroundColor = `${colors[colorIndex]}30`
|
||||
}
|
||||
|
||||
return styles
|
||||
}
|
||||
|
||||
const nestedStyles = getNestedStyles()
|
||||
|
||||
const startHandleId = data.kind === 'loop' ? 'loop-start-source' : 'parallel-start-source'
|
||||
const endHandleId = data.kind === 'loop' ? 'loop-end-source' : 'parallel-end-source'
|
||||
const startBg = data.kind === 'loop' ? '#2FB3FF' : '#FEE12B'
|
||||
|
||||
return (
|
||||
<>
|
||||
<LoopNodeStyles />
|
||||
<SubflowNodeStyles />
|
||||
<div className='group relative'>
|
||||
<Card
|
||||
ref={blockRef}
|
||||
className={cn(
|
||||
' relative cursor-default select-none',
|
||||
'relative cursor-default select-none',
|
||||
'transition-block-bg transition-ring',
|
||||
'z-[20]',
|
||||
data?.state === 'valid',
|
||||
nestingLevel > 0 &&
|
||||
`border border-[0.5px] ${nestingLevel % 2 === 0 ? 'border-slate-300/60' : 'border-slate-400/60'}`,
|
||||
data?.hasNestedError && 'border-2 border-red-500 bg-red-50/50',
|
||||
// Diff highlighting
|
||||
diffStatus === 'new' && 'bg-green-50/50 ring-2 ring-green-500 dark:bg-green-900/10',
|
||||
diffStatus === 'edited' &&
|
||||
'bg-orange-50/50 ring-2 ring-orange-500 dark:bg-orange-900/10'
|
||||
@@ -146,10 +144,9 @@ export const LoopNodeComponent = memo(({ data, selected, id }: NodeProps) => {
|
||||
pointerEvents: isPreview ? 'none' : 'all',
|
||||
}}
|
||||
data-node-id={id}
|
||||
data-type='loopNode'
|
||||
data-type='subflowNode'
|
||||
data-nesting-level={nestingLevel}
|
||||
>
|
||||
{/* Critical drag handle that controls only the loop node movement */}
|
||||
{!isPreview && (
|
||||
<div
|
||||
className='workflow-drag-handle absolute top-0 right-0 left-0 z-10 h-10 cursor-move'
|
||||
@@ -157,7 +154,6 @@ export const LoopNodeComponent = memo(({ data, selected, id }: NodeProps) => {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Custom visible resize handle */}
|
||||
{!isPreview && (
|
||||
<div
|
||||
className='absolute right-2 bottom-2 z-20 flex h-8 w-8 cursor-se-resize items-center justify-center text-muted-foreground'
|
||||
@@ -165,7 +161,6 @@ export const LoopNodeComponent = memo(({ data, selected, id }: NodeProps) => {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Child nodes container - Enable pointer events to allow dragging of children */}
|
||||
<div
|
||||
className='h-[calc(100%-10px)] p-4'
|
||||
data-dragarea='true'
|
||||
@@ -175,7 +170,6 @@ export const LoopNodeComponent = memo(({ data, selected, id }: NodeProps) => {
|
||||
pointerEvents: isPreview ? 'none' : 'auto',
|
||||
}}
|
||||
>
|
||||
{/* Delete button - styled like in action-bar.tsx */}
|
||||
{!isPreview && (
|
||||
<Button
|
||||
variant='ghost'
|
||||
@@ -191,12 +185,12 @@ export const LoopNodeComponent = memo(({ data, selected, id }: NodeProps) => {
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Loop Start Block */}
|
||||
{/* Subflow Start */}
|
||||
<div
|
||||
className='-translate-y-1/2 absolute top-1/2 left-8 flex h-10 w-10 transform items-center justify-center rounded-md bg-[#2FB3FF] p-2'
|
||||
style={{ pointerEvents: isPreview ? 'none' : 'auto' }}
|
||||
className='-translate-y-1/2 absolute top-1/2 left-8 flex h-10 w-10 transform items-center justify-center rounded-md p-2'
|
||||
style={{ pointerEvents: isPreview ? 'none' : 'auto', backgroundColor: startBg }}
|
||||
data-parent-id={id}
|
||||
data-node-role='loop-start'
|
||||
data-node-role={`${data.kind}-start`}
|
||||
data-extent='parent'
|
||||
>
|
||||
<StartIcon className='h-6 w-6 text-white' />
|
||||
@@ -204,7 +198,7 @@ export const LoopNodeComponent = memo(({ data, selected, id }: NodeProps) => {
|
||||
<Handle
|
||||
type='source'
|
||||
position={Position.Right}
|
||||
id='loop-start-source'
|
||||
id={startHandleId}
|
||||
className='!w-[6px] !h-4 !bg-slate-300 dark:!bg-slate-500 !rounded-[2px] !border-none !z-[30] hover:!w-[10px] hover:!right-[-10px] hover:!rounded-r-full hover:!rounded-l-none !cursor-crosshair transition-[colors] duration-150'
|
||||
style={{
|
||||
right: '-6px',
|
||||
@@ -241,15 +235,14 @@ export const LoopNodeComponent = memo(({ data, selected, id }: NodeProps) => {
|
||||
transform: 'translateY(-50%)',
|
||||
pointerEvents: 'auto',
|
||||
}}
|
||||
id='loop-end-source'
|
||||
id={endHandleId}
|
||||
/>
|
||||
|
||||
{/* Loop Configuration Badges */}
|
||||
<IterationBadges nodeId={id} data={data} iterationType='loop' />
|
||||
<IterationBadges nodeId={id} data={data} iterationType={data.kind} />
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
LoopNodeComponent.displayName = 'LoopNodeComponent'
|
||||
SubflowNodeComponent.displayName = 'SubflowNodeComponent'
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ArrowLeftRight, ArrowUpDown, Circle, CircleOff, Copy, Trash2 } from 'lucide-react'
|
||||
import { ArrowLeftRight, ArrowUpDown, Circle, CircleOff, Copy, LogOut, Trash2 } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -23,6 +23,10 @@ export function ActionBar({ blockId, blockType, disabled = false }: ActionBarPro
|
||||
const horizontalHandles = useWorkflowStore(
|
||||
(state) => state.blocks[blockId]?.horizontalHandles ?? false
|
||||
)
|
||||
const parentId = useWorkflowStore((state) => state.blocks[blockId]?.data?.parentId)
|
||||
const parentType = useWorkflowStore((state) =>
|
||||
parentId ? state.blocks[parentId]?.type : undefined
|
||||
)
|
||||
const userPermissions = useUserPermissionsContext()
|
||||
|
||||
const isStarterBlock = blockType === 'starter'
|
||||
@@ -102,6 +106,33 @@ export function ActionBar({ blockId, blockType, disabled = false }: ActionBarPro
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Remove from subflow - only show when inside loop/parallel */}
|
||||
{!isStarterBlock && parentId && (parentType === 'loop' || parentType === 'parallel') && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => {
|
||||
if (!disabled && userPermissions.canEdit) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('remove-from-subflow', { detail: { blockId } })
|
||||
)
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'text-gray-500',
|
||||
(disabled || !userPermissions.canEdit) && 'cursor-not-allowed opacity-50'
|
||||
)}
|
||||
disabled={disabled || !userPermissions.canEdit}
|
||||
>
|
||||
<LogOut className='h-4 w-4' />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='right'>{getTooltipMessage('Remove From Subflow')}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
|
||||
@@ -6,9 +6,9 @@ import {
|
||||
type SlackChannelInfo,
|
||||
SlackChannelSelector,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/channel-selector/components/slack-channel-selector'
|
||||
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-depends-on-gate'
|
||||
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-sub-block-value'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
|
||||
interface ChannelSelectorInputProps {
|
||||
blockId: string
|
||||
@@ -29,28 +29,29 @@ export function ChannelSelectorInput({
|
||||
isPreview = false,
|
||||
previewValue,
|
||||
}: ChannelSelectorInputProps) {
|
||||
const { getValue } = useSubBlockStore()
|
||||
|
||||
// Use the proper hook to get the current value and setter (same as file-selector)
|
||||
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlock.id)
|
||||
// Reactive upstream fields
|
||||
const [authMethod] = useSubBlockValue(blockId, 'authMethod')
|
||||
const [botToken] = useSubBlockValue(blockId, 'botToken')
|
||||
const [connectedCredential] = useSubBlockValue(blockId, 'credential')
|
||||
const [selectedChannelId, setSelectedChannelId] = useState<string>('')
|
||||
const [_channelInfo, setChannelInfo] = useState<SlackChannelInfo | null>(null)
|
||||
|
||||
// Get provider-specific values
|
||||
const provider = subBlock.provider || 'slack'
|
||||
const isSlack = provider === 'slack'
|
||||
// Central dependsOn gating
|
||||
const { finalDisabled } = useDependsOnGate(blockId, subBlock, { disabled, isPreview })
|
||||
|
||||
// Get the credential for the provider - use provided credential or fall back to store
|
||||
const authMethod = getValue(blockId, 'authMethod') as string
|
||||
const botToken = getValue(blockId, 'botToken') as string
|
||||
|
||||
// Get the credential for the provider - use provided credential or fall back to reactive values
|
||||
let credential: string
|
||||
if (providedCredential) {
|
||||
credential = providedCredential
|
||||
} else if (authMethod === 'bot_token' && botToken) {
|
||||
credential = botToken
|
||||
} else if ((authMethod as string) === 'bot_token' && (botToken as string)) {
|
||||
credential = botToken as string
|
||||
} else {
|
||||
credential = (getValue(blockId, 'credential') as string) || ''
|
||||
credential = (connectedCredential as string) || ''
|
||||
}
|
||||
|
||||
// Use preview value when in preview mode, otherwise use store value
|
||||
@@ -58,18 +59,11 @@ export function ChannelSelectorInput({
|
||||
|
||||
// Get the current value from the store or prop value if in preview mode (same pattern as file-selector)
|
||||
useEffect(() => {
|
||||
if (isPreview && previewValue !== undefined) {
|
||||
const value = previewValue
|
||||
if (value && typeof value === 'string') {
|
||||
setSelectedChannelId(value)
|
||||
}
|
||||
} else {
|
||||
const value = getValue(blockId, subBlock.id)
|
||||
if (value && typeof value === 'string') {
|
||||
setSelectedChannelId(value)
|
||||
}
|
||||
const val = isPreview && previewValue !== undefined ? previewValue : storeValue
|
||||
if (val && typeof val === 'string') {
|
||||
setSelectedChannelId(val)
|
||||
}
|
||||
}, [blockId, subBlock.id, getValue, isPreview, previewValue])
|
||||
}, [isPreview, previewValue, storeValue])
|
||||
|
||||
// Handle channel selection (same pattern as file-selector)
|
||||
const handleChannelChange = (channelId: string, info?: SlackChannelInfo) => {
|
||||
@@ -95,15 +89,10 @@ export function ChannelSelectorInput({
|
||||
}}
|
||||
credential={credential}
|
||||
label={subBlock.placeholder || 'Select Slack channel'}
|
||||
disabled={disabled || !credential}
|
||||
disabled={finalDisabled}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
{!credential && (
|
||||
<TooltipContent side='top'>
|
||||
<p>Please select a Slack account or enter a bot token first</p>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
|
||||
@@ -26,7 +26,6 @@ import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/c
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
|
||||
const logger = createLogger('CredentialSelector')
|
||||
|
||||
@@ -124,12 +123,10 @@ export function CredentialSelector({
|
||||
}
|
||||
}, [effectiveProviderId, selectedId, activeWorkflowId])
|
||||
|
||||
// Fetch credentials on initial mount
|
||||
// Fetch credentials on initial mount and whenever the subblock value changes externally
|
||||
useEffect(() => {
|
||||
fetchCredentials()
|
||||
// This effect should only run once on mount, so empty dependency array
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
}, [fetchCredentials, effectiveValue])
|
||||
|
||||
// When the selectedId changes (e.g., collaborator saved a credential), determine if it's foreign
|
||||
useEffect(() => {
|
||||
@@ -180,6 +177,19 @@ export function CredentialSelector({
|
||||
}
|
||||
}, [fetchCredentials])
|
||||
|
||||
// Also handle BFCache restores (back/forward navigation) where visibility change may not fire reliably
|
||||
useEffect(() => {
|
||||
const handlePageShow = (event: any) => {
|
||||
if (event?.persisted) {
|
||||
fetchCredentials()
|
||||
}
|
||||
}
|
||||
window.addEventListener('pageshow', handlePageShow)
|
||||
return () => {
|
||||
window.removeEventListener('pageshow', handlePageShow)
|
||||
}
|
||||
}, [fetchCredentials])
|
||||
|
||||
// Handle popover open to fetch fresh credentials
|
||||
const handleOpenChange = (isOpen: boolean) => {
|
||||
setOpen(isOpen)
|
||||
@@ -193,23 +203,19 @@ export function CredentialSelector({
|
||||
const selectedCredential = credentials.find((cred) => cred.id === selectedId)
|
||||
const isForeign = !!(selectedId && !selectedCredential && hasForeignMeta)
|
||||
|
||||
// If the list doesn’t contain the effective value but meta says it exists, synthesize a non-leaky placeholder to render stable UI
|
||||
const displayName = selectedCredential
|
||||
? selectedCredential.name
|
||||
: isForeign
|
||||
? 'Saved by collaborator'
|
||||
: undefined
|
||||
|
||||
// Handle selection
|
||||
const handleSelect = (credentialId: string) => {
|
||||
const previousId = selectedId || (effectiveValue as string) || ''
|
||||
setSelectedId(credentialId)
|
||||
if (!isPreview) {
|
||||
setStoreValue(credentialId)
|
||||
// If credential changed, clear other sub-block fields for a clean state
|
||||
if (previousId && previousId !== credentialId) {
|
||||
const wfId = (activeWorkflowId as string) || ''
|
||||
const workflowValues = useSubBlockStore.getState().workflowValues[wfId] || {}
|
||||
const blockValues = workflowValues[blockId] || {}
|
||||
Object.keys(blockValues).forEach((key) => {
|
||||
if (key !== subBlock.id) {
|
||||
collaborativeSetSubblockValue(blockId, key, '')
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
setOpen(false)
|
||||
}
|
||||
@@ -263,15 +269,9 @@ export function CredentialSelector({
|
||||
<div className='flex max-w-[calc(100%-20px)] items-center gap-2 overflow-hidden'>
|
||||
{getProviderIcon(provider)}
|
||||
<span
|
||||
className={
|
||||
selectedCredential ? 'truncate font-normal' : 'truncate text-muted-foreground'
|
||||
}
|
||||
className={displayName ? 'truncate font-normal' : 'truncate text-muted-foreground'}
|
||||
>
|
||||
{selectedCredential
|
||||
? selectedCredential.name
|
||||
: isForeign
|
||||
? 'Saved by collaborator'
|
||||
: label}
|
||||
{displayName || label}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronDown className='absolute right-3 h-4 w-4 shrink-0 opacity-50' />
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
CommandList,
|
||||
} from '@/components/ui/command'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-depends-on-gate'
|
||||
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-sub-block-value'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
|
||||
@@ -65,6 +66,9 @@ export function DocumentSelector({
|
||||
// Use preview value when in preview mode, otherwise use store value
|
||||
const value = isPreview ? previewValue : storeValue
|
||||
|
||||
const { finalDisabled } = useDependsOnGate(blockId, subBlock, { disabled, isPreview })
|
||||
const isDisabled = finalDisabled
|
||||
|
||||
// Fetch documents for the selected knowledge base
|
||||
const fetchDocuments = useCallback(async () => {
|
||||
if (!knowledgeBaseId) {
|
||||
@@ -103,6 +107,7 @@ export function DocumentSelector({
|
||||
// Handle dropdown open/close - fetch documents when opening
|
||||
const handleOpenChange = (isOpen: boolean) => {
|
||||
if (isPreview) return
|
||||
if (isDisabled) return
|
||||
|
||||
setOpen(isOpen)
|
||||
|
||||
@@ -124,13 +129,14 @@ export function DocumentSelector({
|
||||
|
||||
// Sync selected document with value prop
|
||||
useEffect(() => {
|
||||
if (isDisabled) return
|
||||
if (value && documents.length > 0) {
|
||||
const docInfo = documents.find((doc) => doc.id === value)
|
||||
setSelectedDocument(docInfo || null)
|
||||
} else {
|
||||
setSelectedDocument(null)
|
||||
}
|
||||
}, [value, documents])
|
||||
}, [value, documents, isDisabled])
|
||||
|
||||
// Reset documents when knowledge base changes
|
||||
useEffect(() => {
|
||||
@@ -141,10 +147,10 @@ export function DocumentSelector({
|
||||
|
||||
// Fetch documents when knowledge base is available
|
||||
useEffect(() => {
|
||||
if (knowledgeBaseId && !isPreview) {
|
||||
if (knowledgeBaseId && !isPreview && !isDisabled) {
|
||||
fetchDocuments()
|
||||
}
|
||||
}, [knowledgeBaseId, isPreview, fetchDocuments])
|
||||
}, [knowledgeBaseId, isPreview, isDisabled, fetchDocuments])
|
||||
|
||||
const formatDocumentName = (document: DocumentData) => {
|
||||
return document.filename
|
||||
@@ -166,9 +172,6 @@ export function DocumentSelector({
|
||||
|
||||
const label = subBlock.placeholder || 'Select document'
|
||||
|
||||
// Show disabled state if no knowledge base is selected
|
||||
const isDisabled = disabled || isPreview || !knowledgeBaseId
|
||||
|
||||
return (
|
||||
<div className='w-full'>
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
|
||||
@@ -22,6 +22,7 @@ interface DropdownProps {
|
||||
previewValue?: string | null
|
||||
disabled?: boolean
|
||||
placeholder?: string
|
||||
config?: import('@/blocks/types').SubBlockConfig
|
||||
}
|
||||
|
||||
export function Dropdown({
|
||||
@@ -34,6 +35,7 @@ export function Dropdown({
|
||||
previewValue,
|
||||
disabled,
|
||||
placeholder = 'Select an option...',
|
||||
config,
|
||||
}: DropdownProps) {
|
||||
const [storeValue, setStoreValue] = useSubBlockValue<string>(blockId, subBlockId)
|
||||
const [storeInitialized, setStoreInitialized] = useState(false)
|
||||
@@ -281,7 +283,7 @@ export function Dropdown({
|
||||
|
||||
{/* Dropdown */}
|
||||
{open && (
|
||||
<div className='absolute top-full left-0 z-[100] mt-1 w-full min-w-[286px]'>
|
||||
<div className='absolute top-full left-0 z-[100] mt-1 w-full'>
|
||||
<div className='allow-scroll fade-in-0 zoom-in-95 animate-in rounded-md border bg-popover text-popover-foreground shadow-lg'>
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user