Compare commits

...

2 Commits

Author SHA1 Message Date
waleed
5ef91e56b8 fixed cred selector 2026-03-25 09:14:42 -07:00
waleed
1b25460a62 feat(agents): added dedicated agents tab, credentials/mcp/tools modal dispatch, ui updates 2026-03-24 15:41:41 -07:00
52 changed files with 21399 additions and 257 deletions

View File

@@ -1,16 +1,20 @@
<p align="center">
<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"/>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="apps/sim/public/logo/wordmark.svg">
<source media="(prefers-color-scheme: light)" srcset="apps/sim/public/logo/wordmark-dark.svg">
<img src="apps/sim/public/logo/wordmark-dark.svg" alt="Sim Logo" width="300"/>
</picture>
</a>
</p>
<p align="center">The open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to orchestrate agentic workflows.</p>
<p align="center">
<a href="https://sim.ai" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/badge/sim.ai-6F3DFA" alt="Sim.ai"></a>
<a href="https://sim.ai" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/badge/sim.ai-33c482" alt="Sim.ai"></a>
<a href="https://discord.gg/Hr4UWYEcTT" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/badge/Discord-Join%20Server-5865F2?logo=discord&logoColor=white" alt="Discord"></a>
<a href="https://x.com/simdotai" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/twitter/follow/simdotai?style=social" alt="Twitter"></a>
<a href="https://docs.sim.ai" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/badge/Docs-6F3DFA.svg" alt="Documentation"></a>
<a href="https://docs.sim.ai" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/badge/Docs-33c482.svg" alt="Documentation"></a>
</p>
<p align="center">
@@ -42,7 +46,7 @@ Upload documents to a vector store and let agents answer questions grounded in y
### Cloud-hosted: [sim.ai](https://sim.ai)
<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>
<a href="https://sim.ai" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/badge/sim.ai-33c482?logo=data:image/svg%2bxml;base64,PHN2ZyB3aWR0aD0iNjE2IiBoZWlnaHQ9IjYxNiIgdmlld0JveD0iMCAwIDYxNiA2MTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMF8xMTU5XzMxMykiPgo8cGF0aCBkPSJNNjE2IDBIMFY2MTZINjE2VjBaIiBmaWxsPSIjMzNjNDgyIi8+CjxwYXRoIGQ9Ik04MyAzNjUuNTY3SDExM0MxMTMgMzczLjgwNSAxMTYgMzgwLjM3MyAxMjIgMzg1LjI3MkMxMjggMzg5Ljk0OCAxMzYuMTExIDM5Mi4yODUgMTQ2LjMzMyAzOTIuMjg1QzE1Ny40NDQgMzkyLjI4NSAxNjYgMzkwLjE3MSAxNzIgMzg1LjkzOUMxNzcuOTk5IDM4MS40ODcgMTgxIDM3NS41ODYgMTgxIDM2OC4yMzlDMTgxIDM2Mi44OTUgMTc5LjMzMyAzNTguNDQyIDE3NiAzNTQuODhDMTcyLjg4OSAzNTEuMzE4IDE2Ny4xMTEgMzQ4LjQyMiAxNTguNjY3IDM0Ni4xOTZMMTMwIDMzOS41MTdDMTE1LjU1NSAzMzUuOTU1IDEwNC43NzggMzMwLjQ5OSA5Ny42NjY1IDMyMy4xNTFDOTAuNzc3NSAzMTUuODA0IDg3LjMzMzQgMzA2LjExOSA4Ny4zMzM0IDI5NC4wOTZDODcuMzMzNCAyODQuMDc2IDg5Ljg4OSAyNzUuMzkyIDk0Ljk5OTYgMjY4LjA0NUMxMDAuMzMzIDI2MC42OTcgMTA3LjU1NSAyNTUuMDIgMTE2LjY2NiAyNTEuMDEyQzEyNiAyNDcuMDA0IDEzNi42NjcgMjQ1IDE0OC42NjYgMjQ1QzE2MC42NjcgMjQ1IDE3MSAyNDcuMTE2IDE3OS42NjcgMjUxLjM0NkMxODguNTU1IDI1NS41NzYgMTk1LjQ0NCAyNjEuNDc3IDIwMC4zMzMgMjY5LjA0N0MyMDUuNDQ0IDI3Ni42MTcgMjA4LjExMSAyODUuNjM0IDIwOC4zMzMgMjk2LjA5OUgxNzguMzMzQzE3OC4xMTEgMjg3LjYzOCAxNzUuMzMzIDI4MS4wNyAxNjkuOTk5IDI3Ni4zOTRDMTY0LjY2NiAyNzEuNzE5IDE1Ny4yMjIgMjY5LjM4MSAxNDcuNjY3IDI2OS4zODFDMTM3Ljg4OSAyNjkuMzgxIDEzMC4zMzMgMjcxLjQ5NiAxMjUgMjc1LjcyNkMxMTkuNjY2IDI3OS45NTcgMTE3IDI4NS43NDYgMTE3IDI5My4wOTNDMTE3IDMwNC4wMDMgMTI1IDMxMS40NjIgMTQxIDMxNS40N0wxNjkuNjY3IDMyMi40ODNDMTgzLjQ0NSAzMjUuNiAxOTMuNzc4IDMzMC43MjIgMjAwLjY2NyAzMzcuODQ3QzIwNy41NTUgMzQ0Ljc0OSAyMTEgMzU0LjIxMiAyMTEgMzY2LjIzNUMyMTEgMzc2LjQ3NyAyMDguMjIyIDM4NS40OTQgMjAyLjY2NiAzOTMuMjg3QzE5Ny4xMTEgNDAwLjg1NyAxODkuNDQ0IDQwNi43NTggMTc5LjY2NyA0MTAuOTg5QzE3MC4xMTEgNDE0Ljk5NiAxNTguNzc4IDQxNyAxNDUuNjY3IDQxN0MxMjYuNTU1IDQxNyAxMTEuMzMzIDQxMi4zMjUgOTkuOTk5NyA0MDIuOTczQzg4LjY2NjggMzkzLjYyMSA4MyAzODEuMTUzIDgzIDM2NS41NjdaIiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBkPSJNMjMyLjI5MSA0MTNWMjUwLjA4MkMyNDQuNjg0IDI1NC42MTQgMjUwLjE0OCAyNTQuNjE0IDI2My4zNzEgMjUwLjA4MlY0MTNIMjMyLjI5MVpNMjQ3LjUgMjM5LjMxM0MyNDEuOTkgMjM5LjMxMyAyMzcuMTQgMjM3LjMxMyAyMzIuOTUyIDIzMy4zMTZDMjI4Ljk4NCAyMjkuMDk1IDIyNyAyMjQuMjA5IDIyNyAyMTguNjU2QzIyNyAyMTIuODgyIDIyOC45ODQgMjA3Ljk5NSAyMzIuOTUyIDIwMy45OTdDMjM3LjE0IDE5OS45OTkgMjQxLjk5IDE5OCAyNDcuNSAxOThDMjUzLjIzMSAxOTggMjU4LjA4IDE5OS45OTkgMjYyLjA0OSAyMDMuOTk3QzI2Ni4wMTYgMjA3Ljk5NSAyNjggMjEyLjg4MiAyNjggMjE4LjY1NkMyNjggMjI0LjIwOSAyNjYuMDE2IDIyOS4wOTUgMjYyLjA0OSAyMzMuMzE2QzI1OC4wOCAyMzcuMzEzIDI1My4yMzEgMjM5LjMxMyAyNDcuNSAyMzkuMzEzWiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZD0iTTMxOS4zMzMgNDEzSDI4OFYyNDkuNjc2SDMxNlYyNzcuMjMzQzMxOS4zMzMgMjY4LjEwNCAzMjUuNzc4IDI2MC4zNjQgMzM0LjY2NyAyNTQuMzUyQzM0My43NzggMjQ4LjExNyAzNTQuNzc4IDI0NSAzNjcuNjY3IDI0NUMzODIuMTExIDI0NSAzOTQuMTEyIDI0OC44OTcgNDAzLjY2NyAyNTYuNjlDNDEzLjIyMiAyNjQuNDg0IDQxOS40NDQgMjc0LjgzNyA0MjIuMzM0IDI4Ny43NTJINDE2LjY2N0M0MTguODg5IDI3NC44MzcgNDI1IDI2NC40ODQgNDM1IDI1Ni42OUM0NDUgMjQ4Ljg5NyA0NTcuMzM0IDI0NSA0NzIgMjQ1QzQ5MC42NjYgMjQ1IDUwNS4zMzQgMjUwLjQ1NSA1MTYgMjYxLjM2NkM1MjYuNjY3IDI3Mi4yNzYgNTMyIDI4Ny4xOTUgNTMyIDMwNi4xMjFWNDEzSDUwMS4zMzNWMzEzLjgwNEM1MDEuMzMzIDMwMC44ODkgNDk4IDI5MC45ODEgNDkxLjMzMyAyODQuMDc4QzQ4NC44ODkgMjc2Ljk1MiA0NzYuMTExIDI3My4zOSA0NjUgMjczLjM5QzQ1Ny4yMjIgMjczLjM5IDQ1MC4zMzMgMjc1LjE3MSA0NDQuMzM0IDI3OC43MzRDNDM4LjU1NiAyODIuMDc0IDQzNCAyODYuOTcyIDQzMC42NjcgMjkzLjQzQzQyNy4zMzMgMjk5Ljg4NyA0MjUuNjY3IDMwNy40NTcgNDI1LjY2NyAzMTYuMTQxVjQxM0gzOTQuNjY3VjMxMy40NjlDMzk0LjY2NyAzMDAuNTU1IDM5MS40NDUgMjkwLjc1OCAzODUgMjg0LjA3OEMzNzguNTU2IDI3Ny4xNzUgMzY5Ljc3OCAyNzMuNzI0IDM1OC42NjcgMjczLjcyNEMzNTAuODg5IDI3My43MjQgMzQ0IDI3NS41MDUgMzM4IDI3OS4wNjhDMzMyLjIyMiAyODIuNDA4IDMyNy42NjcgMjg3LjMwNyAzMjQuMzMzIDI5My43NjNDMzIxIDI5OS45OTggMzE5LjMzMyAzMDcuNDU3IDMxOS4zMzMgMzE2LjE0MVY0MTNaIiBmaWxsPSJ3aGl0ZSIvPgo8L2c+CjxkZWZzPgo8Y2xpcFBhdGggaWQ9ImNsaXAwXzExNTlfMzEzIj4KPHJlY3Qgd2lkdGg9IjYxNiIgaGVpZ2h0PSI2MTYiIGZpbGw9IndoaXRlIi8+CjwvY2xpcFBhdGg+CjwvZGVmcz4KPC9zdmc+&logoColor=white" alt="Sim.ai"></a>
### Self-hosted: NPM Package
@@ -70,43 +74,7 @@ docker compose -f docker-compose.prod.yml up -d
Open [http://localhost:3000](http://localhost:3000)
#### Using Local Models with Ollama
Run Sim with local AI models using [Ollama](https://ollama.ai) - no external APIs required:
```bash
# Start with GPU support (automatically downloads gemma3:4b model)
docker compose -f docker-compose.ollama.yml --profile setup up -d
# For CPU-only systems:
docker compose -f docker-compose.ollama.yml --profile cpu --profile setup up -d
```
Wait for the model to download, then visit [http://localhost:3000](http://localhost:3000). Add more models with:
```bash
docker compose -f docker-compose.ollama.yml exec ollama ollama pull llama3.1:8b
```
#### Using an External Ollama Instance
If Ollama is running on your host machine, use `host.docker.internal` instead of `localhost`:
```bash
OLLAMA_URL=http://host.docker.internal:11434 docker compose -f docker-compose.prod.yml up -d
```
On Linux, use your host's IP address or add `extra_hosts: ["host.docker.internal:host-gateway"]` to the compose file.
#### Using vLLM
Sim supports [vLLM](https://docs.vllm.ai/) for self-hosted models. Set `VLLM_BASE_URL` and optionally `VLLM_API_KEY` in your environment.
### 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
Sim also supports local models via [Ollama](https://ollama.ai) and [vLLM](https://docs.vllm.ai/) — see the [Docker self-hosting docs](https://docs.sim.ai/self-hosting/docker) for setup details.
### Self-hosted: Manual Setup
@@ -159,18 +127,7 @@ Copilot is a Sim-managed service. To use Copilot on a self-hosted instance:
## Environment Variables
Key environment variables for self-hosted deployments. See [`.env.example`](apps/sim/.env.example) for defaults or [`env.ts`](apps/sim/lib/core/config/env.ts) for the full list.
| Variable | Required | Description |
|----------|----------|-------------|
| `DATABASE_URL` | Yes | PostgreSQL connection string with pgvector |
| `BETTER_AUTH_SECRET` | Yes | Auth secret (`openssl rand -hex 32`) |
| `BETTER_AUTH_URL` | Yes | Your app URL (e.g., `http://localhost:3000`) |
| `NEXT_PUBLIC_APP_URL` | Yes | Public app URL (same as above) |
| `ENCRYPTION_KEY` | Yes | Encrypts environment variables (`openssl rand -hex 32`) |
| `INTERNAL_API_SECRET` | Yes | Encrypts internal API routes (`openssl rand -hex 32`) |
| `API_ENCRYPTION_KEY` | Yes | Encrypts API keys (`openssl rand -hex 32`) |
| `COPILOT_API_KEY` | No | API key from sim.ai for Copilot features |
See the [environment variables reference](https://docs.sim.ai/self-hosting/environment-variables) for the full list, or [`apps/sim/.env.example`](apps/sim/.env.example) for defaults.
## Tech Stack

View File

@@ -0,0 +1,236 @@
import { db } from '@sim/db'
import { account, agent, agentDeployment, credential } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, isNull } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { v4 as uuidv4 } from 'uuid'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { generateRequestId } from '@/lib/core/utils/request'
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('AgentSlackDeployAPI')
export const dynamic = 'force-dynamic'
interface RouteParams {
agentId: string
}
const DeploySlackSchema = z.object({
credentialId: z.string().min(1, 'Credential ID is required'),
channelIds: z.array(z.string().min(1)).default([]),
respondTo: z.enum(['mentions', 'all', 'threads', 'dm']),
botName: z.string().max(80).optional(),
replyInThread: z.boolean().default(true),
})
/**
* POST /api/agents/{agentId}/deployments/slack
* Configure a Slack deployment for an agent.
*/
export async function POST(request: NextRequest, { params }: { params: Promise<RouteParams> }) {
const requestId = generateRequestId()
const { agentId } = await params
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const parsed = DeploySlackSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.errors[0]?.message ?? 'Invalid request' },
{ status: 400 }
)
}
if (parsed.data.respondTo !== 'dm' && parsed.data.channelIds.length === 0) {
return NextResponse.json({ error: 'At least one channel is required' }, { status: 400 })
}
const [agentRow] = await db
.select()
.from(agent)
.where(and(eq(agent.id, agentId), isNull(agent.archivedAt)))
.limit(1)
if (!agentRow) {
return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
}
const access = await checkWorkspaceAccess(agentRow.workspaceId, session.user.id)
if (!access.hasAccess) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const { credentialId, channelIds, respondTo, botName, replyInThread } = parsed.data
const [credentialRow] = await db
.select({
id: credential.id,
accountId: credential.accountId,
workspaceId: credential.workspaceId,
})
.from(credential)
.where(
and(
eq(credential.id, credentialId),
eq(credential.workspaceId, agentRow.workspaceId),
eq(credential.type, 'oauth'),
eq(credential.providerId, 'slack')
)
)
.limit(1)
if (!credentialRow?.accountId) {
return NextResponse.json({ error: 'Slack credential not found' }, { status: 404 })
}
const [accountRow] = await db
.select({ accessToken: account.accessToken })
.from(account)
.where(eq(account.id, credentialRow.accountId))
.limit(1)
if (!accountRow?.accessToken) {
return NextResponse.json({ error: 'Slack token not available' }, { status: 400 })
}
const authTest = await fetch('https://slack.com/api/auth.test', {
headers: { Authorization: `Bearer ${accountRow.accessToken}` },
})
const authData = await authTest.json()
if (!authData.ok) {
logger.warn(`[${requestId}] Slack auth.test failed`, { error: authData.error })
return NextResponse.json({ error: 'Failed to verify Slack token' }, { status: 400 })
}
const teamId: string = authData.team_id
const botUserId: string = authData.user_id ?? ''
if (!teamId || !botUserId) {
logger.warn(`[${requestId}] Slack auth.test returned incomplete identity`, {
teamId,
botUserId,
})
return NextResponse.json(
{ error: 'Could not determine Slack workspace or bot identity' },
{ status: 400 }
)
}
const existingDeployment = await db
.select({ id: agentDeployment.id })
.from(agentDeployment)
.where(and(eq(agentDeployment.agentId, agentId), eq(agentDeployment.platform, 'slack')))
.limit(1)
const deploymentConfig: import('@/lib/agents/types').SlackDeploymentConfig = {
teamId,
botUserId,
channelIds,
respondTo,
replyInThread,
...(botName ? { botName } : {}),
}
let deploymentRow: typeof agentDeployment.$inferSelect
if (existingDeployment.length > 0) {
const [updated] = await db
.update(agentDeployment)
.set({
credentialId,
config: deploymentConfig,
isActive: true,
updatedAt: new Date(),
})
.where(eq(agentDeployment.id, existingDeployment[0].id))
.returning()
deploymentRow = updated
} else {
const [inserted] = await db
.insert(agentDeployment)
.values({
id: uuidv4(),
agentId,
platform: 'slack',
credentialId,
config: deploymentConfig,
isActive: true,
})
.returning()
deploymentRow = inserted
}
await db
.update(agent)
.set({ isDeployed: true, deployedAt: new Date(), updatedAt: new Date() })
.where(eq(agent.id, agentId))
logger.info(`[${requestId}] Agent ${agentId} deployed to Slack`, { teamId, channelIds })
return NextResponse.json({ success: true, data: deploymentRow })
} catch (error) {
logger.error(`[${requestId}] Failed to deploy agent ${agentId} to Slack`, { error })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
/**
* DELETE /api/agents/{agentId}/deployments/slack
* Remove a Slack deployment from an agent.
*/
export async function DELETE(request: NextRequest, { params }: { params: Promise<RouteParams> }) {
const requestId = generateRequestId()
const { agentId } = await params
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const [agentRow] = await db
.select({ id: agent.id, workspaceId: agent.workspaceId })
.from(agent)
.where(and(eq(agent.id, agentId), isNull(agent.archivedAt)))
.limit(1)
if (!agentRow) {
return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
}
const access = await checkWorkspaceAccess(agentRow.workspaceId, session.user.id)
if (!access.hasAccess) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
await db
.delete(agentDeployment)
.where(and(eq(agentDeployment.agentId, agentId), eq(agentDeployment.platform, 'slack')))
const remainingDeployments = await db
.select({ id: agentDeployment.id })
.from(agentDeployment)
.where(and(eq(agentDeployment.agentId, agentId), eq(agentDeployment.isActive, true)))
.limit(1)
if (remainingDeployments.length === 0) {
await db
.update(agent)
.set({ isDeployed: false, updatedAt: new Date() })
.where(eq(agent.id, agentId))
}
logger.info(`[${requestId}] Slack deployment removed for agent ${agentId}`)
return NextResponse.json({ success: true })
} catch (error) {
logger.error(`[${requestId}] Failed to remove Slack deployment for agent ${agentId}`, { error })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -0,0 +1,99 @@
import { db } from '@sim/db'
import { agent } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, isNull } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { executeAgent } from '@/lib/agents/execute'
import type { AgentConfig } from '@/lib/agents/types'
import { getSession } from '@/lib/auth'
import { generateRequestId } from '@/lib/core/utils/request'
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('AgentExecuteAPI')
export const dynamic = 'force-dynamic'
interface RouteParams {
agentId: string
}
const ExecuteAgentSchema = z.object({
message: z.string().min(1, 'Message is required'),
conversationId: z.string().optional(),
})
/**
* POST /api/agents/{agentId}/execute
* Test-execute an agent from the UI. Requires an active session.
*/
export async function POST(request: NextRequest, { params }: { params: Promise<RouteParams> }) {
const requestId = generateRequestId()
const { agentId } = await params
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const parsed = ExecuteAgentSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.errors[0]?.message ?? 'Invalid request' },
{ status: 400 }
)
}
const [row] = await db
.select()
.from(agent)
.where(and(eq(agent.id, agentId), isNull(agent.archivedAt)))
.limit(1)
if (!row) {
return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
}
const access = await checkWorkspaceAccess(row.workspaceId, session.user.id)
if (!access.hasAccess) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const { message, conversationId } = parsed.data
const memoryConversationId = conversationId ?? `agent:${agentId}:test:${session.user.id}`
logger.info(`[${requestId}] Executing agent ${agentId}`, { userId: session.user.id })
const result = await executeAgent({
config: row.config as AgentConfig,
message,
conversationId: memoryConversationId,
agentId,
workspaceId: row.workspaceId,
userId: session.user.id,
isDeployedContext: false,
})
const streamingResult =
result && typeof result === 'object' && 'stream' in result
? (result as { stream: unknown }).stream
: null
if (streamingResult instanceof ReadableStream) {
return new NextResponse(streamingResult, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
},
})
}
return NextResponse.json({ success: true, data: result as Record<string, unknown> })
} catch (error) {
logger.error(`[${requestId}] Agent execution failed for ${agentId}`, { error })
return NextResponse.json({ error: 'Execution failed' }, { status: 500 })
}
}

View File

@@ -0,0 +1,171 @@
import { db } from '@sim/db'
import { agent, agentDeployment } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, isNull } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { generateRequestId } from '@/lib/core/utils/request'
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('AgentByIdAPI')
export const dynamic = 'force-dynamic'
interface RouteParams {
agentId: string
}
const UpdateAgentSchema = z.object({
name: z.string().min(1).max(100).optional(),
description: z.string().optional(),
config: z.record(z.unknown()).optional(),
isDeployed: z.boolean().optional(),
})
/**
* GET /api/agents/{agentId}
* Get a single agent with its deployments.
*/
export async function GET(request: NextRequest, { params }: { params: Promise<RouteParams> }) {
const requestId = generateRequestId()
const { agentId } = await params
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const [row] = await db
.select()
.from(agent)
.where(and(eq(agent.id, agentId), isNull(agent.archivedAt)))
.limit(1)
if (!row) {
return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
}
const access = await checkWorkspaceAccess(row.workspaceId, session.user.id)
if (!access.hasAccess) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const deployments = await db
.select()
.from(agentDeployment)
.where(eq(agentDeployment.agentId, agentId))
return NextResponse.json({ success: true, data: { ...row, deployments } })
} catch (error) {
logger.error(`[${requestId}] Failed to get agent ${agentId}`, { error })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
/**
* PATCH /api/agents/{agentId}
* Update an agent's name, description, or config.
*/
export async function PATCH(request: NextRequest, { params }: { params: Promise<RouteParams> }) {
const requestId = generateRequestId()
const { agentId } = await params
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const parsed = UpdateAgentSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.errors[0]?.message ?? 'Invalid request' },
{ status: 400 }
)
}
const [row] = await db
.select({ id: agent.id, workspaceId: agent.workspaceId })
.from(agent)
.where(and(eq(agent.id, agentId), isNull(agent.archivedAt)))
.limit(1)
if (!row) {
return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
}
const access = await checkWorkspaceAccess(row.workspaceId, session.user.id)
if (!access.hasAccess) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const updates: Record<string, unknown> = { updatedAt: new Date() }
if (parsed.data.name !== undefined) updates.name = parsed.data.name
if (parsed.data.description !== undefined) updates.description = parsed.data.description
if (parsed.data.config !== undefined) updates.config = parsed.data.config
if (parsed.data.isDeployed !== undefined) {
updates.isDeployed = parsed.data.isDeployed
if (parsed.data.isDeployed) updates.deployedAt = new Date()
}
const [updated] = await db.update(agent).set(updates).where(eq(agent.id, agentId)).returning()
logger.info(`[${requestId}] Agent updated`, { agentId })
return NextResponse.json({ success: true, data: updated })
} catch (error) {
logger.error(`[${requestId}] Failed to update agent ${agentId}`, { error })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
/**
* DELETE /api/agents/{agentId}
* Soft-delete an agent and deactivate all its deployments.
*/
export async function DELETE(request: NextRequest, { params }: { params: Promise<RouteParams> }) {
const requestId = generateRequestId()
const { agentId } = await params
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const [row] = await db
.select({ id: agent.id, workspaceId: agent.workspaceId })
.from(agent)
.where(and(eq(agent.id, agentId), isNull(agent.archivedAt)))
.limit(1)
if (!row) {
return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
}
const access = await checkWorkspaceAccess(row.workspaceId, session.user.id)
if (!access.hasAccess) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
await db
.update(agentDeployment)
.set({ isActive: false, updatedAt: new Date() })
.where(eq(agentDeployment.agentId, agentId))
await db
.update(agent)
.set({ archivedAt: new Date(), isDeployed: false, updatedAt: new Date() })
.where(eq(agent.id, agentId))
logger.info(`[${requestId}] Agent archived`, { agentId })
return NextResponse.json({ success: true })
} catch (error) {
logger.error(`[${requestId}] Failed to delete agent ${agentId}`, { error })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -0,0 +1,128 @@
import { db } from '@sim/db'
import { agent } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, isNotNull, isNull } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { v4 as uuidv4 } from 'uuid'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { generateRequestId } from '@/lib/core/utils/request'
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('AgentsAPI')
export const dynamic = 'force-dynamic'
type AgentQueryScope = 'active' | 'archived' | 'all'
const CreateAgentSchema = z.object({
workspaceId: z.string().min(1, 'Workspace ID is required'),
name: z.string().min(1, 'Name is required').max(100, 'Name must be 100 characters or fewer'),
description: z.string().optional(),
config: z.record(z.unknown()).default({}),
})
/**
* GET /api/agents?workspaceId={id}&scope=active|archived|all
* List agents for a workspace.
*/
export async function GET(request: NextRequest) {
const requestId = generateRequestId()
try {
const session = await getSession()
if (!session?.user?.id) {
logger.warn(`[${requestId}] Unauthorized agents list access`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const workspaceId = searchParams.get('workspaceId')
const scopeParam = (searchParams.get('scope') ?? 'active') as AgentQueryScope
if (!workspaceId) {
return NextResponse.json({ error: 'workspaceId is required' }, { status: 400 })
}
if (!['active', 'archived', 'all'].includes(scopeParam)) {
return NextResponse.json({ error: 'Invalid scope' }, { status: 400 })
}
const access = await checkWorkspaceAccess(workspaceId, session.user.id)
if (!access.exists) {
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
}
if (!access.hasAccess) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const conditions = [eq(agent.workspaceId, workspaceId)]
if (scopeParam === 'active') conditions.push(isNull(agent.archivedAt))
if (scopeParam === 'archived') conditions.push(isNotNull(agent.archivedAt))
const agents = await db
.select()
.from(agent)
.where(and(...conditions))
.orderBy(agent.updatedAt)
return NextResponse.json({ success: true, data: agents })
} catch (error) {
logger.error(`[${requestId}] Failed to list agents`, { error })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
/**
* POST /api/agents
* Create a new agent.
*/
export async function POST(request: NextRequest) {
const requestId = generateRequestId()
try {
const session = await getSession()
if (!session?.user?.id) {
logger.warn(`[${requestId}] Unauthorized agent create attempt`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const parsed = CreateAgentSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.errors[0]?.message ?? 'Invalid request' },
{ status: 400 }
)
}
const { workspaceId, name, description, config } = parsed.data
const access = await checkWorkspaceAccess(workspaceId, session.user.id)
if (!access.exists) {
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
}
if (!access.hasAccess) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const [created] = await db
.insert(agent)
.values({
id: uuidv4(),
workspaceId,
createdBy: session.user.id,
name,
description,
config,
})
.returning()
logger.info(`[${requestId}] Agent created`, { agentId: created.id, workspaceId })
return NextResponse.json({ success: true, data: created }, { status: 201 })
} catch (error) {
logger.error(`[${requestId}] Failed to create agent`, { error })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -0,0 +1,489 @@
import crypto from 'crypto'
import { db } from '@sim/db'
import { account, agent, agentConversation, agentDeployment, credential } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, isNull } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { v4 as uuidv4 } from 'uuid'
import { executeAgent } from '@/lib/agents/execute'
import type { AgentConfig, SlackDeploymentConfig } from '@/lib/agents/types'
import { env } from '@/lib/core/config/env'
import { generateRequestId } from '@/lib/core/utils/request'
import { handleSlackChallenge } from '@/lib/webhooks/utils.server'
const logger = createLogger('AgentSlackWebhook')
export const dynamic = 'force-dynamic'
/** Verify the Slack request signature using HMAC-SHA256. */
async function verifySlackSignature(
signingSecret: string,
rawBody: string,
timestamp: string,
signature: string
): Promise<boolean> {
const now = Math.floor(Date.now() / 1000)
if (Math.abs(now - Number.parseInt(timestamp, 10)) > 300) return false
const baseString = `v0:${timestamp}:${rawBody}`
const hmac = crypto.createHmac('sha256', signingSecret)
hmac.update(baseString)
const computed = `v0=${hmac.digest('hex')}`
return crypto.timingSafeEqual(Buffer.from(computed), Buffer.from(signature))
}
/**
* Consume a StreamingExecution ReadableStream (SSE-formatted).
* Calls onChunk with (delta, accumulated) on each new token.
* Returns the final complete text.
*/
async function consumeAgentStream(
stream: ReadableStream,
onChunk?: (delta: string, accumulated: string) => void | Promise<void>
): Promise<string> {
const reader = stream.getReader()
const decoder = new TextDecoder()
let buf = ''
let accumulated = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buf += decoder.decode(value, { stream: true })
const lines = buf.split('\n')
buf = lines.pop() ?? ''
for (const line of lines) {
if (!line.startsWith('data: ')) continue
const data = line.slice(6).trim()
if (data === '[DONE]') return accumulated
try {
const parsed = JSON.parse(data) as { content?: string }
if (parsed.content) {
const delta = parsed.content
accumulated += delta
await onChunk?.(delta, accumulated)
}
} catch {}
}
}
return accumulated
}
/** Call Slack's chat.postMessage. Returns the message ts or null on failure. */
async function postMessage(
botToken: string,
payload: {
channel: string
text: string
thread_ts?: string
username?: string
}
): Promise<string | null> {
const res = await fetch('https://slack.com/api/chat.postMessage', {
method: 'POST',
headers: { Authorization: `Bearer ${botToken}`, 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
const data = (await res.json()) as { ok: boolean; ts?: string; error?: string }
if (!data.ok) {
logger.warn('chat.postMessage failed', { error: data.error })
return null
}
return data.ts ?? null
}
/** Call Slack's chat.update. */
async function updateMessage(
botToken: string,
channel: string,
ts: string,
text: string,
username?: string
): Promise<void> {
const res = await fetch('https://slack.com/api/chat.update', {
method: 'POST',
headers: { Authorization: `Bearer ${botToken}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ channel, ts, text, ...(username ? { username } : {}) }),
})
const data = (await res.json()) as { ok: boolean; error?: string }
if (!data.ok) logger.warn('chat.update failed', { error: data.error })
}
/**
* Start a native Slack stream (Oct 2025 streaming API).
* Returns the stream ts, or null if unsupported (e.g. Enterprise Grid).
*/
async function startStream(
botToken: string,
channel: string,
threadTs: string,
recipientUserId: string,
recipientTeamId: string,
username?: string
): Promise<string | null> {
const res = await fetch('https://slack.com/api/chat.startStream', {
method: 'POST',
headers: { Authorization: `Bearer ${botToken}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
channel,
thread_ts: threadTs,
recipient_user_id: recipientUserId,
recipient_team_id: recipientTeamId,
...(username ? { username } : {}),
}),
})
const data = (await res.json()) as { ok: boolean; ts?: string; error?: string }
if (!data.ok) {
// enterprise_is_restricted = Enterprise Grid; fall back silently
if (data.error !== 'enterprise_is_restricted') {
logger.warn('chat.startStream failed', { error: data.error })
}
return null
}
return data.ts ?? null
}
/** Append a markdown chunk to a native Slack stream. */
async function appendStream(
botToken: string,
channel: string,
ts: string,
markdownText: string
): Promise<void> {
const res = await fetch('https://slack.com/api/chat.appendStream', {
method: 'POST',
headers: { Authorization: `Bearer ${botToken}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ channel, ts, markdown_text: markdownText }),
})
const data = (await res.json()) as { ok: boolean; error?: string }
if (!data.ok) logger.warn('chat.appendStream failed', { error: data.error })
}
/** Finalize a native Slack stream. */
async function stopStream(botToken: string, channel: string, ts: string): Promise<void> {
const res = await fetch('https://slack.com/api/chat.stopStream', {
method: 'POST',
headers: { Authorization: `Bearer ${botToken}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ channel, ts }),
})
const data = (await res.json()) as { ok: boolean; error?: string }
if (!data.ok) logger.warn('chat.stopStream failed', { error: data.error })
}
/**
* POST /api/agents/slack/webhook
* Receives Slack event callbacks for all agent deployments.
* Responds 200 immediately and processes events asynchronously.
*/
export async function POST(request: NextRequest) {
const requestId = generateRequestId()
const signingSecret = env.SLACK_SIGNING_SECRET
if (!signingSecret) {
logger.error(`[${requestId}] SLACK_SIGNING_SECRET is not configured`)
return NextResponse.json({ error: 'Webhook not configured' }, { status: 500 })
}
const rawBody = await request.text()
const timestamp = request.headers.get('x-slack-request-timestamp') ?? ''
const signature = request.headers.get('x-slack-signature') ?? ''
const isValid = await verifySlackSignature(signingSecret, rawBody, timestamp, signature)
if (!isValid) {
logger.warn(`[${requestId}] Invalid Slack signature`)
return NextResponse.json({ error: 'Invalid signature' }, { status: 401 })
}
let body: Record<string, unknown>
try {
body = JSON.parse(rawBody) as Record<string, unknown>
} catch {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
}
const challengeResponse = handleSlackChallenge(body)
if (challengeResponse) return challengeResponse
if (body.type !== 'event_callback') {
return new NextResponse(null, { status: 200 })
}
// Deduplicate: if Slack retried (infrastructure-level duplicate delivery), skip
const retryNum = request.headers.get('x-slack-retry-num')
if (retryNum) {
return new NextResponse(null, { status: 200 })
}
const event = (body.event ?? {}) as Record<string, unknown>
const teamId = String(body.team_id ?? '')
const channel = String(event.channel ?? '')
const eventTs = String(event.ts ?? '')
const threadTs = String(event.thread_ts ?? '')
const text = String(event.text ?? '')
const userId = String(event.user ?? '')
const botId = event.bot_id as string | undefined
const subtype = event.subtype as string | undefined
const eventType = String(event.type ?? '')
// Ignore bot messages (our own replies or other bots)
if (botId || subtype === 'bot_message') {
return new NextResponse(null, { status: 200 })
}
// Respond 200 immediately — Slack never retries a 200, so no header needed
void processSlackEvent({ requestId, teamId, channel, eventTs, threadTs, text, userId, eventType })
return new NextResponse(null, { status: 200 })
}
interface SlackEventContext {
requestId: string
teamId: string
channel: string
eventTs: string
threadTs: string
text: string
userId: string
eventType: string
}
async function processSlackEvent(ctx: SlackEventContext): Promise<void> {
const { requestId, teamId, channel, eventTs, threadTs, text, userId, eventType } = ctx
try {
const deployments = await db
.select({ deployment: agentDeployment, agentRow: agent })
.from(agentDeployment)
.innerJoin(agent, and(eq(agent.id, agentDeployment.agentId), isNull(agent.archivedAt)))
.where(and(eq(agentDeployment.platform, 'slack'), eq(agentDeployment.isActive, true)))
const isDm = channel.startsWith('D')
const match = deployments.find((row) => {
const config = row.deployment.config as SlackDeploymentConfig
if (config.teamId !== teamId) return false
const respondTo = config.respondTo
if (respondTo === 'dm') return isDm
if (!config.channelIds.includes(channel)) return false
if (respondTo === 'mentions' && eventType !== 'app_mention') return false
if (respondTo === 'threads' && !threadTs) return false
return true
})
if (!match) {
logger.info(`[${requestId}] No agent matched for team=${teamId} channel=${channel}`)
return
}
const { deployment, agentRow } = match
const agentId = agentRow.id
const workspaceId = agentRow.workspaceId
const config = deployment.config as SlackDeploymentConfig
let botToken: string | undefined
if (deployment.credentialId) {
const [row] = await db
.select({ accessToken: account.accessToken })
.from(credential)
.innerJoin(account, eq(account.id, credential.accountId))
.where(eq(credential.id, deployment.credentialId))
.limit(1)
botToken = row?.accessToken ?? undefined
}
if (!botToken) {
logger.warn(`[${requestId}] No bot token for agent ${agentId}`)
return
}
// Thread → thread-scoped memory; DM or main channel → channel-scoped memory
const externalId = threadTs ? `${channel}:${threadTs}` : channel
const conversationId = `agent:${agentId}:slack:${externalId}`
await db
.insert(agentConversation)
.values({
id: uuidv4(),
agentId,
platform: 'slack',
externalId,
conversationId,
metadata: { channel, threadTs, teamId },
})
.onConflictDoUpdate({
target: [
agentConversation.agentId,
agentConversation.platform,
agentConversation.externalId,
],
set: { updatedAt: new Date(), metadata: { channel, threadTs, teamId } },
})
// Strip bot mention (use stored botUserId if available, else strip any @mention)
const mentionPattern = config.botUserId
? new RegExp(`<@${config.botUserId}>`, 'g')
: /<@[UW][A-Z0-9]+>/g
const cleanedText = text.replace(mentionPattern, '').trim()
if (!cleanedText) return
const replyInThread = config.replyInThread !== false
const replyThreadTs = replyInThread ? threadTs || eventTs : undefined
const botName = config.botName || undefined
logger.info(`[${requestId}] Executing agent ${agentId} for Slack event`, {
channel,
threadTs,
eventType,
})
const timeout = AbortSignal.timeout(30_000)
const result = await executeAgent({
config: agentRow.config as AgentConfig,
message: cleanedText,
conversationId,
agentId,
workspaceId,
isDeployedContext: true,
abortSignal: timeout,
})
const streamingResult =
result && typeof result === 'object' && 'stream' in result
? (result as { stream: ReadableStream }).stream
: null
let responseText: string
if (streamingResult instanceof ReadableStream) {
responseText = await streamResponse({
botToken,
channel,
replyThreadTs,
teamId,
userId,
botName,
stream: streamingResult,
requestId,
})
} else {
responseText = String((result as Record<string, unknown>).content ?? '')
if (responseText) {
await postMessage(botToken, {
channel,
text: responseText,
thread_ts: replyThreadTs,
...(botName ? { username: botName } : {}),
})
}
}
if (!responseText) {
await postMessage(botToken, {
channel,
text: '_No response._',
thread_ts: replyThreadTs,
...(botName ? { username: botName } : {}),
})
return
}
logger.info(`[${requestId}] Agent ${agentId} responded to Slack event`)
} catch (error) {
logger.error(`[${requestId}] Error processing Slack event`, { error })
}
}
interface StreamResponseParams {
botToken: string
channel: string
replyThreadTs: string | undefined
teamId: string
userId: string
botName: string | undefined
stream: ReadableStream
requestId: string
}
/**
* Deliver a streaming agent response to Slack.
*
* Prefers the native streaming API (chat.startStream / appendStream / stopStream)
* introduced in October 2025, which renders a real-time typewriter effect in
* Slack clients and uses the higher Tier 4 rate limit (100+/min).
*
* Falls back to chat.postMessage + throttled chat.update (~1/sec) when:
* - The workspace is on Enterprise Grid (startStream returns enterprise_is_restricted)
* - No thread context is available (startStream requires thread_ts)
*/
async function streamResponse({
botToken,
channel,
replyThreadTs,
teamId,
userId,
botName,
stream,
requestId,
}: StreamResponseParams): Promise<string> {
// Native streaming API requires a thread context
if (replyThreadTs) {
const streamTs = await startStream(botToken, channel, replyThreadTs, userId, teamId, botName)
if (streamTs) {
// Native streaming path — Tier 4 (100+/min), flush every ~600ms
let pendingDelta = ''
let lastFlushTime = 0
const responseText = await consumeAgentStream(stream, async (delta) => {
pendingDelta += delta
const now = Date.now()
if (now - lastFlushTime >= 600 && pendingDelta) {
lastFlushTime = now
const toSend = pendingDelta
pendingDelta = ''
await appendStream(botToken, channel, streamTs, toSend)
}
})
// Flush remaining buffer
if (pendingDelta) {
await appendStream(botToken, channel, streamTs, pendingDelta)
}
await stopStream(botToken, channel, streamTs)
logger.info(`[${requestId}] Streamed via native streaming API`)
return responseText
}
}
// Fallback: post placeholder → throttled chat.update (~1/sec, Tier 3)
const placeholderTs = await postMessage(botToken, {
channel,
text: '_Thinking…_',
thread_ts: replyThreadTs,
...(botName ? { username: botName } : {}),
})
let lastUpdateTime = 0
const responseText = await consumeAgentStream(stream, async (_, accumulated) => {
const now = Date.now()
if (placeholderTs && now - lastUpdateTime >= 1200) {
lastUpdateTime = now
void updateMessage(botToken, channel, placeholderTs, `${accumulated}`, botName)
}
})
if (placeholderTs) {
await updateMessage(botToken, channel, placeholderTs, responseText || '_No response._', botName)
} else if (responseText) {
await postMessage(botToken, {
channel,
text: responseText,
thread_ts: replyThreadTs,
...(botName ? { username: botName } : {}),
})
}
logger.info(`[${requestId}] Streamed via chat.update fallback`)
return responseText
}

View File

@@ -0,0 +1,110 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
const logger = createLogger('SkillsImportAPI')
const FETCH_TIMEOUT_MS = 15_000
const ImportSchema = z.object({
url: z.string().url('A valid URL is required'),
})
/**
* Converts a standard GitHub file URL to its raw.githubusercontent.com equivalent.
*
* Supported formats:
* github.com/{owner}/{repo}/blob/{branch}/{path}
* raw.githubusercontent.com/{owner}/{repo}/{branch}/{path} (passthrough)
*/
function toRawGitHubUrl(url: string): string {
const parsed = new URL(url)
if (parsed.hostname === 'raw.githubusercontent.com') {
return url
}
if (parsed.hostname !== 'github.com') {
throw new Error('Only GitHub URLs are supported')
}
// /owner/repo/blob/branch/path...
const segments = parsed.pathname.split('/').filter(Boolean)
if (segments.length < 5 || segments[2] !== 'blob') {
throw new Error(
'Invalid GitHub URL format. Expected: https://github.com/{owner}/{repo}/blob/{branch}/{path}'
)
}
const [owner, repo, , branch, ...pathParts] = segments
return `https://raw.githubusercontent.com/${owner}/${repo}/${branch}/${pathParts.join('/')}`
}
/** POST - Fetch a SKILL.md from a GitHub URL and return its raw content */
export async function POST(req: NextRequest) {
const requestId = generateRequestId()
try {
const authResult = await checkSessionOrInternalAuth(req, { requireWorkflowId: false })
if (!authResult.success || !authResult.userId) {
logger.warn(`[${requestId}] Unauthorized skill import attempt`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await req.json()
const { url } = ImportSchema.parse(body)
let rawUrl: string
try {
rawUrl = toRawGitHubUrl(url)
} catch (err) {
const message = err instanceof Error ? err.message : 'Invalid URL'
return NextResponse.json({ error: message }, { status: 400 })
}
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)
try {
const response = await fetch(rawUrl, {
signal: controller.signal,
headers: { Accept: 'text/plain' },
})
if (!response.ok) {
logger.warn(`[${requestId}] GitHub fetch failed`, {
status: response.status,
url: rawUrl,
})
return NextResponse.json(
{ error: `Failed to fetch file (HTTP ${response.status}). Is the repository public?` },
{ status: 502 }
)
}
const content = await response.text()
if (content.length > 100_000) {
return NextResponse.json({ error: 'File is too large (max 100KB)' }, { status: 400 })
}
return NextResponse.json({ content })
} finally {
clearTimeout(timeout)
}
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: 'Invalid request', details: error.errors }, { status: 400 })
}
if (error instanceof Error && error.name === 'AbortError') {
logger.warn(`[${requestId}] GitHub fetch timed out`)
return NextResponse.json({ error: 'Request timed out' }, { status: 504 })
}
logger.error(`[${requestId}] Error importing skill`, error)
return NextResponse.json({ error: 'Failed to import skill' }, { status: 500 })
}
}

View File

@@ -0,0 +1,102 @@
import { db } from '@sim/db'
import { agent } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, isNull } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { executeAgent } from '@/lib/agents/execute'
import type { AgentConfig } from '@/lib/agents/types'
import { generateRequestId } from '@/lib/core/utils/request'
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
import { checkRateLimit, createRateLimitResponse } from '@/app/api/v1/middleware'
const logger = createLogger('V1AgentAPI')
export const dynamic = 'force-dynamic'
interface RouteParams {
agentId: string
}
const ExecuteSchema = z.object({
message: z.string().min(1, 'Message is required'),
conversationId: z.string().optional(),
})
/**
* POST /api/v1/agents/{agentId}
* Execute an agent via the public API. Requires a workspace or personal API key.
*/
export async function POST(request: NextRequest, { params }: { params: Promise<RouteParams> }) {
const requestId = generateRequestId()
const { agentId } = await params
try {
const rateLimit = await checkRateLimit(request, 'agent-detail')
if (!rateLimit.allowed) {
return createRateLimitResponse(rateLimit)
}
const userId = rateLimit.userId!
const body = await request.json()
const parsed = ExecuteSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.errors[0]?.message ?? 'Invalid request' },
{ status: 400 }
)
}
const [agentRow] = await db
.select()
.from(agent)
.where(and(eq(agent.id, agentId), isNull(agent.archivedAt), eq(agent.isDeployed, true)))
.limit(1)
if (!agentRow) {
return NextResponse.json({ error: 'Agent not found or not deployed' }, { status: 404 })
}
const access = await checkWorkspaceAccess(agentRow.workspaceId, userId)
if (!access.hasAccess) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const { message, conversationId } = parsed.data
const memoryConversationId = conversationId
? `agent:${agentId}:api:${conversationId}`
: undefined
logger.info(`[${requestId}] V1 API executing agent ${agentId}`, { userId })
const result = await executeAgent({
config: agentRow.config as AgentConfig,
message,
conversationId: memoryConversationId,
agentId,
workspaceId: agentRow.workspaceId,
userId,
isDeployedContext: true,
})
const streamingResult =
result && typeof result === 'object' && 'stream' in result
? (result as { stream: unknown }).stream
: null
if (streamingResult instanceof ReadableStream) {
return new NextResponse(streamingResult, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
},
})
}
return NextResponse.json({ success: true, data: result as Record<string, unknown> })
} catch (error) {
logger.error(`[${requestId}] V1 agent execution failed for ${agentId}`, { error })
return NextResponse.json({ error: 'Execution failed' }, { status: 500 })
}
}

View File

@@ -37,7 +37,9 @@ export async function checkRateLimit(
| 'file-detail'
| 'knowledge'
| 'knowledge-detail'
| 'knowledge-search' = 'logs'
| 'knowledge-search'
| 'agents'
| 'agent-detail' = 'logs'
): Promise<RateLimitResult> {
try {
const auth = await authenticateV1Request(request)

View File

@@ -0,0 +1,226 @@
'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useRouter } from 'next/navigation'
import { Pencil, Trash } from '@/components/emcn/icons'
import { AgentIcon } from '@/components/icons'
import type { AgentConfig } from '@/lib/agents/types'
import { AgentConfigPanel } from '@/app/workspace/[workspaceId]/agents/[agentId]/components/agent-config/agent-config-panel'
import { AgentTestPanel } from '@/app/workspace/[workspaceId]/agents/[agentId]/components/agent-test-panel'
import { DeployModal } from '@/app/workspace/[workspaceId]/agents/[agentId]/components/deploy-modal'
import type { BreadcrumbItem } from '@/app/workspace/[workspaceId]/components'
import { ResourceHeader } from '@/app/workspace/[workspaceId]/components'
import { useAgent, useDeleteAgent, useUpdateAgent } from '@/hooks/queries/agents'
import { useInlineRename } from '@/hooks/use-inline-rename'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
const logger = createLogger('AgentDetail')
const AUTOSAVE_DELAY_MS = 1000
interface AgentDetailProps {
agentId: string
workspaceId: string
}
export function AgentDetail({ agentId, workspaceId }: AgentDetailProps) {
const router = useRouter()
const { data: agent, isLoading } = useAgent(agentId)
const { mutateAsync: updateAgent } = useUpdateAgent()
const { mutateAsync: deleteAgent } = useDeleteAgent()
const [localConfig, setLocalConfig] = useState<AgentConfig>({})
const [saveStatus, setSaveStatus] = useState<'saved' | 'saving' | 'unsaved'>('saved')
const [isDeleting, setIsDeleting] = useState(false)
const [isDeployModalOpen, setIsDeployModalOpen] = useState(false)
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const isInitializedRef = useRef(false)
// SubBlockStore.setValue reads activeWorkflowId from the workflow registry and no-ops when null.
// Set the agentId as the active "workflow" so tool sub-block params (credentials, etc.) persist.
useEffect(() => {
useWorkflowRegistry.setState({ activeWorkflowId: agentId })
return () => {
if (useWorkflowRegistry.getState().activeWorkflowId === agentId) {
useWorkflowRegistry.setState({ activeWorkflowId: null })
}
}
}, [agentId])
useEffect(() => {
if (agent && !isInitializedRef.current) {
setLocalConfig(agent.config ?? {})
isInitializedRef.current = true
}
}, [agent])
const agentRename = useInlineRename({
onSave: (id, name) => updateAgent({ agentId: id, name }),
})
const scheduleSave = useCallback(
(updatedConfig: AgentConfig) => {
setSaveStatus('unsaved')
if (saveTimerRef.current) clearTimeout(saveTimerRef.current)
saveTimerRef.current = setTimeout(async () => {
saveTimerRef.current = null
setSaveStatus('saving')
try {
await updateAgent({ agentId, config: updatedConfig })
setSaveStatus('saved')
} catch (error) {
logger.error('Failed to auto-save agent', { error })
setSaveStatus('unsaved')
}
}, AUTOSAVE_DELAY_MS)
},
[agentId, updateAgent]
)
useEffect(() => {
return () => {
if (saveTimerRef.current) clearTimeout(saveTimerRef.current)
}
}, [])
const handleConfigChange = useCallback(
(patch: Partial<AgentConfig>) => {
setLocalConfig((prev) => {
const next = { ...prev, ...patch }
scheduleSave(next)
return next
})
},
[scheduleSave]
)
const handleDelete = useCallback(async () => {
setIsDeleting(true)
try {
await deleteAgent({ agentId })
router.push(`/workspace/${workspaceId}/agents`)
} catch (error) {
logger.error('Failed to delete agent', { error })
setIsDeleting(false)
}
}, [agentId, deleteAgent, router, workspaceId])
const currentName = agent?.name ?? ''
const breadcrumbs = useMemo<BreadcrumbItem[]>(
() => [
{ label: 'Agents', onClick: () => router.push(`/workspace/${workspaceId}/agents`) },
{
label: currentName || '…',
editing:
agentRename.editingId === agentId
? {
isEditing: true,
value: agentRename.editValue,
onChange: agentRename.setEditValue,
onSubmit: agentRename.submitRename,
onCancel: agentRename.cancelRename,
}
: undefined,
dropdownItems: [
{
label: 'Rename',
icon: Pencil,
onClick: () => agentRename.startRename(agentId, currentName),
},
{
label: 'Delete',
icon: Trash,
onClick: handleDelete,
disabled: isDeleting,
},
],
},
],
[
agentId,
currentName,
agentRename.editingId,
agentRename.editValue,
agentRename.setEditValue,
agentRename.submitRename,
agentRename.cancelRename,
agentRename.startRename,
handleDelete,
isDeleting,
router,
workspaceId,
]
)
const saveStatusLabel =
saveStatus === 'saving' ? 'Saving…' : saveStatus === 'unsaved' ? 'Unsaved changes' : undefined
if (isLoading) {
return (
<div className='flex h-full flex-1 flex-col overflow-hidden bg-[var(--surface-1)]'>
<ResourceHeader icon={AgentIcon} breadcrumbs={[{ label: 'Agents' }, { label: '…' }]} />
<div className='flex flex-1 items-center justify-center'>
<span className='text-[14px] text-[var(--text-muted)]'>Loading</span>
</div>
</div>
)
}
if (!agent) {
return (
<div className='flex h-full flex-1 flex-col overflow-hidden bg-[var(--surface-1)]'>
<ResourceHeader
icon={AgentIcon}
breadcrumbs={[
{ label: 'Agents', onClick: () => router.push(`/workspace/${workspaceId}/agents`) },
{ label: 'Not found' },
]}
/>
<div className='flex flex-1 items-center justify-center'>
<span className='text-[14px] text-[var(--text-muted)]'>Agent not found.</span>
</div>
</div>
)
}
return (
<div className='flex h-full flex-1 flex-col overflow-hidden bg-[var(--surface-1)]'>
<ResourceHeader
icon={AgentIcon}
breadcrumbs={breadcrumbs}
actions={[
...(saveStatusLabel
? [{ label: saveStatusLabel, onClick: () => {}, disabled: true }]
: []),
{ label: 'Deploy', onClick: () => setIsDeployModalOpen(true) },
]}
/>
<div className='flex min-h-0 flex-1 overflow-hidden'>
<div className='flex w-[400px] min-w-[320px] flex-shrink-0 flex-col overflow-y-auto border-[var(--border-1)] border-r'>
<AgentConfigPanel
config={localConfig}
agentId={agentId}
workspaceId={workspaceId}
onConfigChange={handleConfigChange}
/>
</div>
<div className='flex min-w-0 flex-1 flex-col overflow-hidden'>
<AgentTestPanel agentId={agentId} />
</div>
</div>
<DeployModal
agentId={agentId}
workspaceId={workspaceId}
isDeployed={agent.isDeployed}
open={isDeployModalOpen}
onOpenChange={setIsDeployModalOpen}
/>
</div>
)
}

View File

@@ -0,0 +1,789 @@
'use client'
import { useCallback, useMemo, useRef, useState } from 'react'
import {
Button,
Combobox,
type ComboboxOption,
type ComboboxOptionGroup,
Input,
Label,
Textarea,
} from '@/components/emcn'
import { ArrowUp, ChevronDown, Plus, X } from '@/components/emcn/icons'
import { AgentSkillsIcon } from '@/components/icons'
import type { AgentConfig, SkillInput } from '@/lib/agents/types'
import { getScopesForService } from '@/lib/oauth/utils'
import { AgentToolInput } from '@/app/workspace/[workspaceId]/agents/[agentId]/components/agent-config/agent-tool-input'
import {
type AgentWandState,
useAgentWand,
} from '@/app/workspace/[workspaceId]/agents/[agentId]/hooks/use-agent-wand'
import { SkillModal } from '@/app/workspace/[workspaceId]/settings/components/skills/components/skill-modal'
import {
checkEnvVarTrigger,
EnvVarDropdown,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/env-var-dropdown'
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text'
import { ToolCredentialSelector } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/credential-selector'
import {
getModelOptions,
RESPONSE_FORMAT_WAND_CONFIG,
shouldRequireApiKeyForModel,
} from '@/blocks/utils'
import { type SkillDefinition, useSkills } from '@/hooks/queries/skills'
import {
getMaxTemperature,
getReasoningEffortValuesForModel,
getThinkingLevelsForModel,
getVerbosityValuesForModel,
MODELS_WITH_DEEP_RESEARCH,
MODELS_WITH_REASONING_EFFORT,
MODELS_WITH_THINKING,
MODELS_WITH_VERBOSITY,
MODELS_WITHOUT_MEMORY,
providers,
supportsTemperature,
} from '@/providers/utils'
import { useProvidersStore } from '@/stores/providers/store'
const MEMORY_OPTIONS: ComboboxOption[] = [
{ value: 'none', label: 'None' },
{ value: 'conversation', label: 'Conversation history' },
{ value: 'sliding_window', label: 'Sliding window (messages)' },
{ value: 'sliding_window_tokens', label: 'Sliding window (tokens)' },
]
const AZURE_MODELS = [...providers['azure-openai'].models, ...providers['azure-anthropic'].models]
const DASHED_DIVIDER_STYLE = {
backgroundImage:
'repeating-linear-gradient(to right, var(--border) 0px, var(--border) 6px, transparent 6px, transparent 12px)',
} as const
interface AgentConfigPanelProps {
config: AgentConfig
agentId: string
workspaceId: string
onConfigChange: (patch: Partial<AgentConfig>) => void
}
const SYSTEM_PROMPT_WAND_PROMPT = `You are an expert at creating professional, comprehensive LLM agent system prompts. Generate or modify a system prompt based on the user's request.
Current system prompt: {context}
RULES:
1. Generate ONLY the system prompt text — no JSON, no explanations, no markdown fences
2. Start with a clear role definition ("You are...")
3. Include specific methodology, response format requirements, and edge case handling
4. If editing, preserve structure unless asked to change it
5. Be detailed and professional`
function Divider() {
return (
<div className='px-[2px] pt-[16px] pb-[13px]'>
<div className='h-[1.25px]' style={DASHED_DIVIDER_STYLE} />
</div>
)
}
function FieldLabelRow({ label }: { label: string }) {
return (
<div className='flex items-center justify-between gap-[6px] pl-[2px]'>
<Label className='font-medium text-[13px]'>{label}</Label>
</div>
)
}
export function AgentConfigPanel({
config,
agentId,
workspaceId,
onConfigChange,
}: AgentConfigPanelProps) {
const storeProviders = useProvidersStore((s) => s.providers)
const model = config.model ?? ''
const [showAdvanced, setShowAdvanced] = useState(false)
const derived = useMemo(() => {
const reasoningValues = model ? getReasoningEffortValuesForModel(model) : null
const thinkingValues = model ? getThinkingLevelsForModel(model) : null
const verbosityValues = model ? getVerbosityValuesForModel(model) : null
const toOptions = (vals: string[]): ComboboxOption[] =>
vals.map((v) => ({ value: v, label: v }))
const isDeepResearch = MODELS_WITH_DEEP_RESEARCH.includes(model)
const showTemperature = Boolean(model) && supportsTemperature(model) && !isDeepResearch
return {
isVertexModel: providers.vertex.models.includes(model),
isAzureModel: AZURE_MODELS.includes(model),
isBedrockModel: providers.bedrock.models.includes(model),
showApiKey: shouldRequireApiKeyForModel(model),
isDeepResearch,
showMemory: !MODELS_WITHOUT_MEMORY.includes(model),
showReasoningEffort: MODELS_WITH_REASONING_EFFORT.includes(model),
showThinking: MODELS_WITH_THINKING.includes(model),
showVerbosity: MODELS_WITH_VERBOSITY.includes(model),
showTemperature,
maxTemperature: (model && getMaxTemperature(model)) ?? 1,
reasoningEffortOptions: toOptions(reasoningValues ?? ['auto', 'low', 'medium', 'high']),
thinkingLevelOptions: toOptions(
thinkingValues ? ['none', ...thinkingValues] : ['none', 'low', 'high']
),
verbosityOptions: toOptions(verbosityValues ?? ['auto', 'low', 'medium', 'high']),
}
}, [model])
const hasAdvancedFields =
derived.showReasoningEffort ||
derived.showThinking ||
derived.showVerbosity ||
derived.showTemperature ||
!derived.isDeepResearch
const modelOptions: ComboboxOption[] = useMemo(
() =>
getModelOptions().map((opt) => ({
label: opt.label,
value: opt.id,
...(opt.icon && { icon: opt.icon }),
})),
// eslint-disable-next-line react-hooks/exhaustive-deps
[storeProviders]
)
const systemPrompt = config.messages?.find((m) => m.role === 'system')?.content ?? ''
const handleSystemPromptChange = useCallback(
(value: string) => {
const filtered = (config.messages ?? []).filter((m) => m.role !== 'system')
const messages = value.trim()
? [{ role: 'system' as const, content: value }, ...filtered]
: filtered
onConfigChange({ messages })
},
[config.messages, onConfigChange]
)
const systemPromptWand = useAgentWand({
systemPrompt: SYSTEM_PROMPT_WAND_PROMPT,
maintainHistory: true,
currentValue: systemPrompt,
onGeneratedContent: handleSystemPromptChange,
})
const responseFormatWand = useAgentWand({
systemPrompt: RESPONSE_FORMAT_WAND_CONFIG.prompt,
generationType: RESPONSE_FORMAT_WAND_CONFIG.generationType,
maintainHistory: RESPONSE_FORMAT_WAND_CONFIG.maintainHistory,
currentValue: config.responseFormat ?? '',
onGeneratedContent: (content) => onConfigChange({ responseFormat: content }),
})
const [apiKeyFocused, setApiKeyFocused] = useState(false)
const [apiKeyEnvDropdown, setApiKeyEnvDropdown] = useState({ visible: false, searchTerm: '' })
const apiKeyInputRef = useRef<HTMLInputElement>(null)
return (
<div className='px-[8px] pt-[12px] pb-[8px]'>
{/* System prompt */}
<div className='flex flex-col gap-[10px]'>
<WandLabelRow label='System prompt' wand={systemPromptWand} />
<Textarea
placeholder='You are a helpful assistant…'
value={systemPrompt}
onChange={(e) => handleSystemPromptChange(e.target.value)}
rows={6}
className='resize-none text-[13px]'
/>
</div>
<Divider />
{/* Model */}
<div className='flex flex-col gap-[10px]'>
<FieldLabelRow label='Model' />
<Combobox
options={modelOptions}
value={config.model ?? ''}
onChange={(v) => onConfigChange({ model: v })}
placeholder='claude-sonnet-4-6'
editable
searchable
searchPlaceholder='Search models…'
emptyMessage='No models found'
maxHeight={240}
inputProps={{ autoComplete: 'off' }}
/>
</div>
<Divider />
{/* Vertex AI */}
{derived.isVertexModel && (
<>
<div className='flex flex-col gap-[10px]'>
<FieldLabelRow label='Google Cloud account' />
<ToolCredentialSelector
value={config.vertexCredential ?? ''}
onChange={(v) => onConfigChange({ vertexCredential: v || undefined })}
provider='vertex-ai'
serviceId='vertex-ai'
requiredScopes={getScopesForService('vertex-ai')}
/>
</div>
<Divider />
<div className='flex flex-col gap-[10px]'>
<FieldLabelRow label='Google Cloud project' />
<Input
value={config.vertexProject ?? ''}
placeholder='your-gcp-project-id'
autoComplete='off'
onChange={(e) => onConfigChange({ vertexProject: e.target.value || undefined })}
/>
</div>
<Divider />
<div className='flex flex-col gap-[10px]'>
<FieldLabelRow label='Google Cloud location' />
<Input
value={config.vertexLocation ?? ''}
placeholder='us-central1'
autoComplete='off'
onChange={(e) => onConfigChange({ vertexLocation: e.target.value || undefined })}
/>
</div>
<Divider />
</>
)}
{/* Azure */}
{derived.isAzureModel && (
<>
<div className='flex flex-col gap-[10px]'>
<FieldLabelRow label='Azure endpoint' />
<Input
value={config.azureEndpoint ?? ''}
placeholder='https://your-resource.services.ai.azure.com'
autoComplete='off'
onChange={(e) => onConfigChange({ azureEndpoint: e.target.value || undefined })}
/>
</div>
<Divider />
<div className='flex flex-col gap-[10px]'>
<FieldLabelRow label='Azure API version' />
<Input
value={config.azureApiVersion ?? ''}
placeholder='2024-12-01-preview'
autoComplete='off'
onChange={(e) => onConfigChange({ azureApiVersion: e.target.value || undefined })}
/>
</div>
<Divider />
</>
)}
{/* AWS Bedrock */}
{derived.isBedrockModel && (
<>
<div className='flex flex-col gap-[10px]'>
<FieldLabelRow label='AWS access key ID' />
<Input
value={config.bedrockAccessKeyId ?? ''}
placeholder='Enter your AWS Access Key ID'
autoComplete='off'
onChange={(e) => onConfigChange({ bedrockAccessKeyId: e.target.value || undefined })}
/>
</div>
<Divider />
<div className='flex flex-col gap-[10px]'>
<FieldLabelRow label='AWS secret access key' />
<Input
value={config.bedrockSecretKey ?? ''}
placeholder='Enter your AWS Secret Access Key'
autoComplete='off'
onChange={(e) => onConfigChange({ bedrockSecretKey: e.target.value || undefined })}
/>
</div>
<Divider />
<div className='flex flex-col gap-[10px]'>
<FieldLabelRow label='AWS region' />
<Input
value={config.bedrockRegion ?? ''}
placeholder='us-east-1'
autoComplete='off'
onChange={(e) => onConfigChange({ bedrockRegion: e.target.value || undefined })}
/>
</div>
<Divider />
</>
)}
{/* API key */}
{derived.showApiKey && (
<>
<div className='flex flex-col gap-[10px]'>
<FieldLabelRow label='API key' />
<div className='relative'>
<Input
ref={apiKeyInputRef}
type='text'
value={
apiKeyFocused ? (config.apiKey ?? '') : '•'.repeat(config.apiKey?.length ?? 0)
}
placeholder='Enter your API key'
autoComplete='off'
data-lpignore='true'
data-form-type='other'
className='allow-scroll w-full overflow-auto text-transparent caret-foreground [-ms-overflow-style:none] [scrollbar-width:none] placeholder:text-muted-foreground/50 [&::-webkit-scrollbar]:hidden'
onFocus={() => setApiKeyFocused(true)}
onBlur={() => setApiKeyFocused(false)}
onChange={(e) => {
const val = e.target.value
onConfigChange({ apiKey: val || undefined })
const cursor = e.target.selectionStart ?? val.length
const { show, searchTerm } = checkEnvVarTrigger(val, cursor)
setApiKeyEnvDropdown({ visible: show, searchTerm })
}}
/>
<div className='pointer-events-none absolute inset-0 flex items-center overflow-x-auto px-[8px] py-[6px] pr-3 font-medium font-sans text-foreground text-sm [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'>
<div className='min-w-fit whitespace-pre'>
{apiKeyFocused
? formatDisplayText(config.apiKey ?? '')
: '•'.repeat(config.apiKey?.length ?? 0)}
</div>
</div>
<EnvVarDropdown
visible={apiKeyEnvDropdown.visible}
searchTerm={apiKeyEnvDropdown.searchTerm}
inputValue={config.apiKey ?? ''}
cursorPosition={
apiKeyInputRef.current?.selectionStart ?? config.apiKey?.length ?? 0
}
workspaceId={workspaceId}
inputRef={apiKeyInputRef as React.RefObject<HTMLInputElement>}
onSelect={(newValue) => {
onConfigChange({ apiKey: newValue || undefined })
setApiKeyEnvDropdown({ visible: false, searchTerm: '' })
}}
onClose={() => setApiKeyEnvDropdown({ visible: false, searchTerm: '' })}
/>
</div>
</div>
<Divider />
</>
)}
{/* Tools */}
{!derived.isDeepResearch && (
<>
<div className='flex flex-col gap-[10px]'>
<FieldLabelRow label='Tools' />
<AgentToolInput
workspaceId={workspaceId}
selectedTools={config.tools ?? []}
model={config.model}
onChange={(tools) => onConfigChange({ tools })}
/>
</div>
<Divider />
</>
)}
{/* Skills */}
{!derived.isDeepResearch && (
<>
<div className='flex flex-col gap-[10px]'>
<FieldLabelRow label='Skills' />
<SkillsInput
workspaceId={workspaceId}
selectedSkills={config.skills ?? []}
onChange={(skills) => onConfigChange({ skills })}
/>
</div>
<Divider />
</>
)}
{/* Memory */}
{derived.showMemory && (
<>
<div className='flex flex-col gap-[10px]'>
<FieldLabelRow label='Memory' />
<Combobox
options={MEMORY_OPTIONS}
value={config.memoryType ?? 'none'}
onChange={(v) => onConfigChange({ memoryType: v as AgentConfig['memoryType'] })}
/>
</div>
{config.memoryType && config.memoryType !== 'none' && (
<>
<Divider />
<div className='flex flex-col gap-[10px]'>
<FieldLabelRow label='Conversation ID' />
<Input
value={config.conversationId ?? ''}
placeholder='e.g., user-123, session-abc'
autoComplete='off'
onChange={(e) => onConfigChange({ conversationId: e.target.value || undefined })}
/>
</div>
</>
)}
{config.memoryType === 'sliding_window' && (
<>
<Divider />
<div className='flex flex-col gap-[10px]'>
<FieldLabelRow label='Window size (messages)' />
<Input
value={config.slidingWindowSize ?? ''}
placeholder='20'
autoComplete='off'
onChange={(e) =>
onConfigChange({ slidingWindowSize: e.target.value || undefined })
}
/>
</div>
</>
)}
{config.memoryType === 'sliding_window_tokens' && (
<>
<Divider />
<div className='flex flex-col gap-[10px]'>
<FieldLabelRow label='Window size (tokens)' />
<Input
value={config.slidingWindowTokens ?? ''}
placeholder='4000'
autoComplete='off'
onChange={(e) =>
onConfigChange({ slidingWindowTokens: e.target.value || undefined })
}
/>
</div>
</>
)}
<Divider />
</>
)}
{/* Response format */}
{!derived.isDeepResearch && (
<div className='flex flex-col gap-[10px]'>
<WandLabelRow label='Response format' wand={responseFormatWand} />
<Textarea
placeholder={
'{\n "name": "my_schema",\n "strict": true,\n "schema": { "type": "object", "properties": {} }\n}'
}
value={config.responseFormat ?? ''}
onChange={(e) => onConfigChange({ responseFormat: e.target.value || undefined })}
rows={4}
className='resize-none font-mono text-[12px]'
/>
</div>
)}
{/* Previous interaction ID (deep research) */}
{derived.isDeepResearch && (
<div className='flex flex-col gap-[10px]'>
<FieldLabelRow label='Previous interaction ID' />
<Input
value={config.previousInteractionId ?? ''}
placeholder='e.g., {{agent_1.interactionId}}'
autoComplete='off'
onChange={(e) => onConfigChange({ previousInteractionId: e.target.value || undefined })}
/>
</div>
)}
{/* Advanced fields toggle */}
{hasAdvancedFields && (
<>
<div className='flex items-center gap-[10px] px-[2px] pt-[14px] pb-[12px]'>
<div className='h-[1.25px] flex-1' style={DASHED_DIVIDER_STYLE} />
<button
type='button'
onClick={() => setShowAdvanced((v) => !v)}
className='flex items-center gap-[6px] whitespace-nowrap font-medium text-[13px] text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
>
{showAdvanced ? 'Hide additional fields' : 'Show additional fields'}
<ChevronDown
className={`h-[14px] w-[14px] transition-transform duration-200 ${showAdvanced ? 'rotate-180' : ''}`}
/>
</button>
<div className='h-[1.25px] flex-1' style={DASHED_DIVIDER_STYLE} />
</div>
{showAdvanced && (
<>
{derived.showReasoningEffort && (
<>
<div className='flex flex-col gap-[10px]'>
<FieldLabelRow label='Reasoning effort' />
<Combobox
options={derived.reasoningEffortOptions}
value={config.reasoningEffort ?? 'auto'}
onChange={(v) => onConfigChange({ reasoningEffort: v })}
/>
</div>
<Divider />
</>
)}
{derived.showThinking && (
<>
<div className='flex flex-col gap-[10px]'>
<FieldLabelRow label='Thinking level' />
<Combobox
options={derived.thinkingLevelOptions}
value={config.thinkingLevel ?? 'none'}
onChange={(v) => onConfigChange({ thinkingLevel: v })}
/>
</div>
<Divider />
</>
)}
{derived.showVerbosity && (
<>
<div className='flex flex-col gap-[10px]'>
<FieldLabelRow label='Verbosity' />
<Combobox
options={derived.verbosityOptions}
value={config.verbosity ?? 'auto'}
onChange={(v) => onConfigChange({ verbosity: v })}
/>
</div>
<Divider />
</>
)}
{derived.showTemperature && (
<>
<div className='flex flex-col gap-[10px]'>
<FieldLabelRow label={`Temperature (0${derived.maxTemperature})`} />
<Input
type='number'
value={config.temperature ?? ''}
min={0}
max={derived.maxTemperature}
step={0.1}
placeholder='1.0'
onChange={(e) => {
const v = Number.parseFloat(e.target.value)
onConfigChange({ temperature: Number.isNaN(v) ? undefined : v })
}}
/>
</div>
<Divider />
</>
)}
{!derived.isDeepResearch && (
<div className='flex flex-col gap-[10px]'>
<FieldLabelRow label='Max tokens' />
<Input
type='number'
value={config.maxTokens ?? ''}
min={1}
max={200000}
placeholder='default'
onChange={(e) => {
const v = Number.parseFloat(e.target.value)
onConfigChange({ maxTokens: Number.isNaN(v) ? undefined : v })
}}
/>
</div>
)}
</>
)}
</>
)}
</div>
)
}
interface WandLabelRowProps {
label: string
wand: AgentWandState
}
function WandLabelRow({ label, wand }: WandLabelRowProps) {
return (
<div className='flex items-center justify-between gap-[6px] pl-[2px]'>
<Label className='font-medium text-[13px]'>{label}</Label>
<div className='flex min-w-0 flex-1 items-center justify-end'>
{!wand.isSearchActive ? (
<Button
variant='active'
className='-my-1 h-5 px-2 py-0 text-[11px]'
onClick={wand.onSearchClick}
>
Generate
</Button>
) : (
<div className='-my-1 flex min-w-[120px] max-w-[280px] flex-1 items-center gap-[4px]'>
<Input
ref={wand.searchInputRef}
value={wand.isStreaming ? 'Generating...' : wand.searchQuery}
onChange={(e) => wand.onSearchChange(e.target.value)}
onBlur={(e) => {
const relatedTarget = e.relatedTarget as HTMLElement | null
if (relatedTarget?.closest('button')) return
wand.onSearchBlur()
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && wand.searchQuery.trim() && !wand.isStreaming) {
wand.onSearchSubmit()
} else if (e.key === 'Escape') {
wand.onSearchCancel()
}
}}
disabled={wand.isStreaming}
className='h-5 min-w-[80px] flex-1 text-[11px]'
placeholder='Generate with AI...'
/>
<Button
variant='primary'
disabled={!wand.searchQuery.trim() || wand.isStreaming}
onMouseDown={(e) => {
e.preventDefault()
e.stopPropagation()
}}
onClick={(e) => {
e.stopPropagation()
wand.onSearchSubmit()
}}
className='h-[20px] w-[20px] flex-shrink-0 p-0'
>
<ArrowUp className='h-[12px] w-[12px]' />
</Button>
</div>
)}
</div>
</div>
)
}
interface SkillsInputProps {
workspaceId: string
selectedSkills: SkillInput[]
onChange: (skills: SkillInput[]) => void
}
function SkillsInput({ workspaceId, selectedSkills, onChange }: SkillsInputProps) {
const { data: workspaceSkills = [] } = useSkills(workspaceId)
const [showCreateModal, setShowCreateModal] = useState(false)
const [editingSkill, setEditingSkill] = useState<SkillDefinition | null>(null)
const selectedIds = useMemo(() => new Set(selectedSkills.map((s) => s.skillId)), [selectedSkills])
const skillGroups = useMemo((): ComboboxOptionGroup[] => {
const available = workspaceSkills.filter((s) => !selectedIds.has(s.id))
const groups: ComboboxOptionGroup[] = [
{
items: [
{
label: 'Create skill',
value: 'action-create-skill',
icon: Plus,
onSelect: () => setShowCreateModal(true),
keepOpen: false,
},
],
},
]
if (available.length > 0) {
groups.push({
section: 'Skills',
items: available.map((s) => ({
label: s.name,
value: `skill-${s.id}`,
icon: AgentSkillsIcon,
onSelect: () => onChange([...selectedSkills, { skillId: s.id, name: s.name }]),
})),
})
}
return groups
}, [workspaceSkills, selectedIds, selectedSkills, onChange])
const handleRemove = useCallback(
(skillId: string) => onChange(selectedSkills.filter((s) => s.skillId !== skillId)),
[selectedSkills, onChange]
)
const resolveSkillName = useCallback(
(stored: SkillInput): string => {
const found = workspaceSkills.find((s) => s.id === stored.skillId)
return found?.name ?? stored.name ?? stored.skillId
},
[workspaceSkills]
)
return (
<>
<div className='w-full space-y-[8px]'>
<Combobox
options={[]}
groups={skillGroups}
placeholder='Add skill…'
searchable
searchPlaceholder='Search skills…'
maxHeight={240}
emptyMessage='No skills found'
/>
{selectedSkills.map((stored) => {
const fullSkill = workspaceSkills.find((s) => s.id === stored.skillId)
return (
<div
key={stored.skillId}
className='group relative flex flex-col overflow-hidden rounded-[4px] border border-[var(--border-1)] transition-all duration-200 ease-in-out'
>
<div
className='flex cursor-pointer items-center justify-between gap-[8px] rounded-[4px] bg-[var(--surface-4)] px-[8px] py-[6.5px]'
onClick={() => fullSkill && setEditingSkill(fullSkill)}
>
<div className='flex min-w-0 flex-1 items-center gap-[8px]'>
<div className='flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center rounded-[4px] bg-[var(--surface-4)]'>
<AgentSkillsIcon className='h-[10px] w-[10px] text-[#333]' />
</div>
<span className='truncate font-medium text-[13px] text-[var(--text-primary)]'>
{resolveSkillName(stored)}
</span>
</div>
<button
type='button'
onClick={(e) => {
e.stopPropagation()
handleRemove(stored.skillId)
}}
className='flex items-center justify-center text-[var(--text-tertiary)] transition-colors hover:text-[var(--text-primary)]'
aria-label='Remove skill'
>
<X className='h-[13px] w-[13px]' />
</button>
</div>
</div>
)
})}
</div>
<SkillModal
open={showCreateModal || !!editingSkill}
onOpenChange={(open) => {
if (!open) {
setShowCreateModal(false)
setEditingSkill(null)
}
}}
onSave={() => {
setShowCreateModal(false)
setEditingSkill(null)
}}
initialValues={editingSkill ?? undefined}
/>
</>
)
}

View File

@@ -0,0 +1,739 @@
'use client'
import { useCallback, useMemo, useState } from 'react'
import { ChevronLeft, ChevronRight } from 'lucide-react'
import { useParams } from 'next/navigation'
import { ReactFlowProvider } from 'reactflow'
import {
Combobox,
type ComboboxOptionGroup,
Popover,
PopoverContent,
PopoverItem,
PopoverTrigger,
} from '@/components/emcn'
import { Plus, Wrench, X } from '@/components/emcn/icons'
import { McpIcon, WorkflowIcon } from '@/components/icons'
import type { ToolInput } from '@/lib/agents/types'
import { cn } from '@/lib/core/utils/cn'
import { McpServerFormModal } from '@/app/workspace/[workspaceId]/settings/components/mcp/components/mcp-server-form-modal/mcp-server-form-modal'
import {
type CustomTool,
CustomToolModal,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/custom-tool-modal/custom-tool-modal'
import { ToolSubBlockRenderer } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/sub-block-renderer'
import { getAllBlocks } from '@/blocks'
import { BUILT_IN_TOOL_TYPES } from '@/blocks/utils'
import { type McpToolForUI, useMcpTools } from '@/hooks/mcp/use-mcp-tools'
import { useCustomTools } from '@/hooks/queries/custom-tools'
import { useAllowedMcpDomains, useCreateMcpServer, useMcpServers } from '@/hooks/queries/mcp'
import { useWorkflows } from '@/hooks/queries/workflows'
import { useAvailableEnvVarKeys } from '@/hooks/use-available-env-vars'
import { getProviderFromModel, supportsToolUsageControl } from '@/providers/utils'
import { getSubBlocksForToolInput, getToolParametersConfig } from '@/tools/params'
/** Returns true if a block type has more than one tool operation. */
function hasMultipleOperations(blockType: string): boolean {
const block = getAllBlocks().find((b) => b.type === blockType)
return (block?.tools?.access?.length || 0) > 1
}
/** Returns the available operation options for a multi-operation block. */
function getOperationOptions(blockType: string): { label: string; id: string }[] {
const block = getAllBlocks().find((b) => b.type === blockType)
if (!block?.tools?.access) return []
const opSubBlock = block.subBlocks.find((sb) => sb.id === 'operation')
if (opSubBlock?.type === 'dropdown' && Array.isArray(opSubBlock.options)) {
return opSubBlock.options as { label: string; id: string }[]
}
return block.tools.access.map((toolId) => {
const params = getToolParametersConfig(toolId)
return { id: toolId, label: params?.toolConfig?.name || toolId }
})
}
/** Returns the concrete toolId for a given block type and optional operation. */
function getToolIdForOperation(blockType: string, operation?: string): string | undefined {
const block = getAllBlocks().find((b) => b.type === blockType)
if (!block?.tools?.access) return undefined
if (block.tools.access.length === 1) return block.tools.access[0]
if (operation && block.tools?.config?.tool) {
try {
return block.tools.config.tool({ operation })
} catch {}
}
if (operation && block.tools.access.includes(operation)) return operation
return block.tools.access[0]
}
interface AgentToolInputProps {
workspaceId: string
selectedTools: ToolInput[]
model?: string
onChange: (tools: ToolInput[]) => void
}
export function AgentToolInput({
workspaceId,
selectedTools,
model,
onChange,
}: AgentToolInputProps) {
const params = useParams()
const agentId = (params?.agentId as string) || 'agent'
const [activeMcpServerId, setActiveMcpServerId] = useState<string | null>(null)
const [showCustomToolModal, setShowCustomToolModal] = useState(false)
const [showMcpModal, setShowMcpModal] = useState(false)
const [usageControlIndex, setUsageControlIndex] = useState<number | null>(null)
const { data: customTools = [] } = useCustomTools(workspaceId)
const { mcpTools } = useMcpTools(workspaceId)
const { data: servers = [] } = useMcpServers(workspaceId)
const { data: workflowsList = [] } = useWorkflows(workspaceId, { syncRegistry: false })
const createMcpServer = useCreateMcpServer()
const { data: allowedMcpDomains = null } = useAllowedMcpDomains()
const availableEnvVars = useAvailableEnvVarKeys(workspaceId)
const toolBlocks = useMemo(
() =>
getAllBlocks().filter(
(block) =>
!block.hideFromToolbar &&
(block.category === 'tools' ||
block.type === 'api' ||
block.type === 'webhook_request' ||
block.type === 'knowledge' ||
block.type === 'function' ||
block.type === 'table') &&
block.type !== 'evaluator' &&
block.type !== 'mcp' &&
block.type !== 'file'
),
[]
)
const handleSelectBlock = useCallback(
(block: ReturnType<typeof getAllBlocks>[number]) => {
const hasOps = hasMultipleOperations(block.type)
const ops = hasOps ? getOperationOptions(block.type) : []
const defaultOp = ops.length > 0 ? ops[0].id : undefined
const toolId = getToolIdForOperation(block.type, defaultOp) || ''
const newTool: ToolInput = {
type: block.type,
title: block.name,
toolId,
params: {},
operation: defaultOp,
isExpanded: true,
usageControl: 'auto',
}
onChange([...selectedTools.map((t) => ({ ...t, isExpanded: false })), newTool])
},
[selectedTools, onChange]
)
const handleSelectCustomTool = useCallback(
(tool: { id: string; title: string }) => {
const newTool: ToolInput = {
type: 'custom-tool',
title: tool.title,
customToolId: tool.id,
usageControl: 'auto',
}
onChange([...selectedTools, newTool])
},
[selectedTools, onChange]
)
const handleSelectMcpTool = useCallback(
(mcpTool: McpToolForUI) => {
const newTool: ToolInput = {
type: 'mcp',
title: mcpTool.name,
toolId: mcpTool.id,
params: {
serverId: mcpTool.serverId,
toolName: mcpTool.name,
serverName: mcpTool.serverName,
},
schema: mcpTool.inputSchema,
usageControl: 'auto',
}
onChange([...selectedTools, newTool])
},
[selectedTools, onChange]
)
const handleSelectWorkflow = useCallback(
(workflow: { id: string; name: string }) => {
const alreadySelected = selectedTools.some(
(t) => t.type === 'workflow_input' && t.params?.workflowId === workflow.id
)
if (alreadySelected) return
const newTool: ToolInput = {
type: 'workflow_input',
title: workflow.name,
toolId: 'workflow_executor',
params: { workflowId: workflow.id },
isExpanded: true,
usageControl: 'auto',
}
onChange([...selectedTools.map((t) => ({ ...t, isExpanded: false })), newTool])
},
[selectedTools, onChange]
)
const handleRemove = useCallback(
(index: number) => {
const next = [...selectedTools]
next.splice(index, 1)
onChange(next)
},
[selectedTools, onChange]
)
const handleParamChange = useCallback(
(index: number, paramId: string, value: string) => {
onChange(
selectedTools.map((t, i) =>
i === index ? { ...t, params: { ...(t.params || {}), [paramId]: value } } : t
)
)
},
[selectedTools, onChange]
)
const handleOperationChange = useCallback(
(index: number, operation: string) => {
const tool = selectedTools[index]
const newToolId = getToolIdForOperation(tool.type ?? '', operation) || ''
onChange(
selectedTools.map((t, i) =>
i === index ? { ...t, operation, toolId: newToolId, params: {} } : t
)
)
},
[selectedTools, onChange]
)
const handleUsageControlChange = useCallback(
(index: number, value: 'auto' | 'force' | 'none') => {
onChange(selectedTools.map((t, i) => (i === index ? { ...t, usageControl: value } : t)))
},
[selectedTools, onChange]
)
const toggleExpansion = useCallback(
(index: number) => {
onChange(selectedTools.map((t, i) => (i === index ? { ...t, isExpanded: !t.isExpanded } : t)))
},
[selectedTools, onChange]
)
const selectedCustomIds = useMemo(
() => new Set(selectedTools.filter((t) => t.type === 'custom-tool').map((t) => t.customToolId)),
[selectedTools]
)
const selectedMcpKeys = useMemo(
() =>
new Set(
selectedTools
.filter((t) => t.type === 'mcp')
.map((t) => `${t.params?.serverId ?? ''}:${t.params?.toolName ?? ''}`)
),
[selectedTools]
)
const showUsageControl = useMemo(() => {
if (!model) return false
const provider = getProviderFromModel(model)
return Boolean(provider && supportsToolUsageControl(provider))
}, [model])
const toolGroups = useMemo((): ComboboxOptionGroup[] => {
if (activeMcpServerId) {
const server = servers.find((s) => s.id === activeMcpServerId)
const serverTools = mcpTools.filter(
(t) => t.serverId === activeMcpServerId && !selectedMcpKeys.has(`${t.serverId}:${t.name}`)
)
const groups: ComboboxOptionGroup[] = [
{
items: [
{
label: 'Back',
value: 'action-back',
icon: ChevronLeft,
onSelect: () => setActiveMcpServerId(null),
keepOpen: true,
},
...(serverTools.length > 0
? [
{
label: `Use all ${serverTools.length} tool${serverTools.length !== 1 ? 's' : ''}`,
value: 'action-use-all',
icon: McpIcon,
onSelect: () => {
const newTools: ToolInput[] = serverTools.map((t) => ({
type: 'mcp',
title: t.name,
toolId: t.id,
params: {
serverId: t.serverId,
toolName: t.name,
serverName: t.serverName,
},
schema: t.inputSchema,
usageControl: 'auto' as const,
}))
onChange([...selectedTools, ...newTools])
setActiveMcpServerId(null)
},
},
]
: []),
],
},
]
if (serverTools.length > 0) {
groups.push({
section: server?.name ?? activeMcpServerId,
items: serverTools.map((tool) => ({
label: tool.name,
value: `mcp:${tool.serverId}:${tool.name}`,
icon: McpIcon,
onSelect: () => {
handleSelectMcpTool(tool)
setActiveMcpServerId(null)
},
})),
})
}
return groups
}
const groups: ComboboxOptionGroup[] = []
groups.push({
items: [
{
label: 'Create custom tool',
value: 'action-create-tool',
icon: Plus,
onSelect: () => setShowCustomToolModal(true),
},
{
label: 'Add MCP server',
value: 'action-add-mcp',
icon: Plus,
onSelect: () => setShowMcpModal(true),
},
],
})
const availableCustom = customTools.filter((t) => !selectedCustomIds.has(t.id))
if (availableCustom.length > 0) {
groups.push({
section: 'Custom tools',
items: availableCustom.map((t) => ({
label: t.title,
value: `custom:${t.id}`,
icon: Wrench,
onSelect: () => handleSelectCustomTool(t),
})),
})
}
const enabledServers = servers.filter((s) => s.enabled)
if (enabledServers.length > 0) {
groups.push({
section: 'MCP servers',
items: enabledServers.map((server) => {
const count = mcpTools.filter((t) => t.serverId === server.id).length
return {
label: server.name,
value: `server:${server.id}`,
icon: McpIcon,
suffixElement: (
<div className='flex items-center gap-[3px] text-[11px] text-[var(--text-muted)]'>
<span>{count}</span>
<ChevronRight className='h-[9px] w-[9px]' />
</div>
),
onSelect: () => setActiveMcpServerId(server.id),
keepOpen: true,
}
}),
})
}
const builtInBlocks = toolBlocks.filter((b) => BUILT_IN_TOOL_TYPES.has(b.type))
if (builtInBlocks.length > 0) {
groups.push({
section: 'Tools',
items: builtInBlocks.map((block) => ({
label: block.name,
value: `block:${block.type}`,
iconElement: (
<div
className='flex h-[14px] w-[14px] flex-shrink-0 items-center justify-center rounded-[3px]'
style={{ backgroundColor: block.bgColor }}
>
<block.icon className='h-[9px] w-[9px] text-white' />
</div>
),
onSelect: () => handleSelectBlock(block),
})),
})
}
const integrationBlocks = toolBlocks.filter((b) => !BUILT_IN_TOOL_TYPES.has(b.type))
if (integrationBlocks.length > 0) {
groups.push({
section: 'Integrations',
items: integrationBlocks.map((block) => ({
label: block.name,
value: `integration:${block.type}`,
iconElement: (
<div
className='flex h-[14px] w-[14px] flex-shrink-0 items-center justify-center rounded-[3px]'
style={{ backgroundColor: block.bgColor }}
>
<block.icon className='h-[9px] w-[9px] text-white' />
</div>
),
onSelect: () => handleSelectBlock(block),
})),
})
}
const selectedWorkflowIds = new Set(
selectedTools
.filter((t) => t.type === 'workflow_input')
.map((t) => t.params?.workflowId as string)
)
const availableWorkflows = workflowsList.filter((w) => !selectedWorkflowIds.has(w.id))
if (availableWorkflows.length > 0) {
groups.push({
section: 'Workflows',
items: availableWorkflows.map((workflow) => ({
label: workflow.name,
value: `workflow:${workflow.id}`,
iconElement: (
<div className='flex h-[14px] w-[14px] flex-shrink-0 items-center justify-center rounded-[3px] bg-[#6366F1]'>
<WorkflowIcon className='h-[9px] w-[9px] text-white' />
</div>
),
onSelect: () => handleSelectWorkflow(workflow),
})),
})
}
return groups
}, [
activeMcpServerId,
servers,
mcpTools,
selectedMcpKeys,
customTools,
selectedCustomIds,
toolBlocks,
selectedTools,
workflowsList,
onChange,
handleSelectMcpTool,
handleSelectCustomTool,
handleSelectBlock,
handleSelectWorkflow,
])
return (
<div className='w-full space-y-[8px]'>
<Combobox
options={[]}
groups={toolGroups}
placeholder='Add tool…'
searchable
searchPlaceholder='Search tools…'
maxHeight={280}
emptyMessage='No tools available'
onOpenChange={(open) => {
if (!open) setActiveMcpServerId(null)
}}
/>
{selectedTools.map((tool, idx) => (
<ToolCard
key={`${tool.type}-${tool.customToolId ?? ''}-${tool.toolId ?? ''}-${tool.params?.serverId ?? ''}-${tool.params?.toolName ?? ''}-${tool.params?.workflowId ?? ''}-${idx}`}
tool={tool}
toolIndex={idx}
agentId={agentId}
toolBlocks={toolBlocks}
showUsageControl={showUsageControl}
usageControlOpen={usageControlIndex === idx}
onUsageControlOpenChange={(open) => setUsageControlIndex(open ? idx : null)}
onRemove={() => handleRemove(idx)}
onParamChange={(paramId, value) => handleParamChange(idx, paramId, value)}
onOperationChange={(op) => handleOperationChange(idx, op)}
onUsageControlChange={(v) => handleUsageControlChange(idx, v)}
onToggleExpansion={() => toggleExpansion(idx)}
/>
))}
<CustomToolModal
open={showCustomToolModal}
onOpenChange={setShowCustomToolModal}
onSave={(tool: CustomTool) => {
if (tool.id) {
handleSelectCustomTool({ id: tool.id, title: tool.title })
}
}}
blockId=''
/>
<McpServerFormModal
open={showMcpModal}
onOpenChange={setShowMcpModal}
mode='add'
onSubmit={async (config) => {
await createMcpServer.mutateAsync({ workspaceId, config: { ...config, enabled: true } })
}}
workspaceId={workspaceId}
availableEnvVars={availableEnvVars}
allowedMcpDomains={allowedMcpDomains}
/>
</div>
)
}
interface ToolCardProps {
tool: ToolInput
toolIndex: number
agentId: string
toolBlocks: ReturnType<typeof getAllBlocks>
showUsageControl: boolean
usageControlOpen: boolean
onUsageControlOpenChange: (open: boolean) => void
onRemove: () => void
onParamChange: (paramId: string, value: string) => void
onOperationChange: (op: string) => void
onUsageControlChange: (value: 'auto' | 'force' | 'none') => void
onToggleExpansion: () => void
}
function ToolCard({
tool,
toolIndex,
agentId,
toolBlocks,
showUsageControl,
usageControlOpen,
onUsageControlOpenChange,
onRemove,
onParamChange,
onOperationChange,
onUsageControlChange,
onToggleExpansion,
}: ToolCardProps) {
const isCustomTool = tool.type === 'custom-tool'
const isMcpTool = tool.type === 'mcp'
const isWorkflowTool = tool.type === 'workflow_input' || tool.type === 'workflow'
const block = !isCustomTool && !isMcpTool ? toolBlocks.find((b) => b.type === tool.type) : null
const currentToolId =
!isCustomTool && !isMcpTool
? (getToolIdForOperation(tool.type ?? '', tool.operation) ?? tool.toolId ?? '')
: (tool.toolId ?? '')
const subBlocksResult =
!isCustomTool && !isMcpTool && currentToolId
? getSubBlocksForToolInput(currentToolId, tool.type ?? '', {
operation: tool.operation,
...(tool.params || {}),
})
: null
// Exclude input-mapping sub-blocks — the LLM resolves these at runtime
const displaySubBlocks = (subBlocksResult?.subBlocks ?? []).filter(
(sb) => sb.type !== 'input-mapping'
)
const hasOps = !isCustomTool && !isMcpTool && hasMultipleOperations(tool.type ?? '')
const hasBody = hasOps || displaySubBlocks.length > 0
const isExpanded = hasBody ? !!tool.isExpanded : false
const iconNode = isMcpTool ? (
<div className='flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center rounded-[4px] bg-[#ede9fb]'>
<McpIcon className='h-[10px] w-[10px] text-[#6b5fc9]' />
</div>
) : isCustomTool ? (
<div className='flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center rounded-[4px] bg-[#dbeafe]'>
<Wrench className='h-[10px] w-[10px] text-[#3b82f6]' />
</div>
) : isWorkflowTool ? (
<div className='flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center rounded-[4px] bg-[#6366F1]'>
<WorkflowIcon className='h-[10px] w-[10px] text-white' />
</div>
) : block ? (
<div
className='flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center rounded-[4px]'
style={{ backgroundColor: block.bgColor }}
>
<block.icon className='h-[10px] w-[10px] text-white' />
</div>
) : null
const displayTitle = tool.title ?? tool.params?.toolName ?? tool.type
const subtitle =
tool.type === 'mcp' && tool.params?.serverName ? String(tool.params.serverName) : undefined
return (
<div
className={cn(
'group relative flex flex-col overflow-hidden rounded-[4px] border border-[var(--border-1)] transition-all duration-200 ease-in-out',
isExpanded ? 'rounded-b-[4px]' : ''
)}
>
{/* Header row */}
<div
className={cn(
'flex items-center justify-between gap-[8px] bg-[var(--surface-4)] px-[8px] py-[6.5px]',
hasBody ? 'cursor-pointer rounded-t-[4px]' : 'rounded-[4px]'
)}
onClick={hasBody ? onToggleExpansion : undefined}
>
<div className='flex min-w-0 flex-1 items-center gap-[8px]'>
{iconNode}
<div className='min-w-0 flex-1'>
<span className='block truncate font-medium text-[13px] text-[var(--text-primary)]'>
{displayTitle}
</span>
{subtitle && (
<span className='block truncate text-[11px] text-[var(--text-muted)]'>
{subtitle}
</span>
)}
</div>
</div>
<div className='flex flex-shrink-0 items-center gap-[6px]'>
{showUsageControl && (
<Popover open={usageControlOpen} onOpenChange={onUsageControlOpenChange}>
<PopoverTrigger asChild>
<button
type='button'
onClick={(e) => e.stopPropagation()}
className='flex items-center justify-center font-medium text-[12px] text-[var(--text-tertiary)] transition-colors hover:text-[var(--text-primary)]'
aria-label='Tool usage control'
>
{tool.usageControl === 'force'
? 'Force'
: tool.usageControl === 'none'
? 'None'
: 'Auto'}
</button>
</PopoverTrigger>
<PopoverContent
side='bottom'
align='end'
sideOffset={8}
onClick={(e) => e.stopPropagation()}
className='gap-[2px]'
border
>
<PopoverItem
active={(tool.usageControl || 'auto') === 'auto'}
onClick={() => {
onUsageControlChange('auto')
onUsageControlOpenChange(false)
}}
>
Auto <span className='text-[var(--text-tertiary)]'>(model decides)</span>
</PopoverItem>
<PopoverItem
active={tool.usageControl === 'force'}
onClick={() => {
onUsageControlChange('force')
onUsageControlOpenChange(false)
}}
>
Force <span className='text-[var(--text-tertiary)]'>(always use)</span>
</PopoverItem>
<PopoverItem
active={tool.usageControl === 'none'}
onClick={() => {
onUsageControlChange('none')
onUsageControlOpenChange(false)
}}
>
None
</PopoverItem>
</PopoverContent>
</Popover>
)}
<button
type='button'
onClick={(e) => {
e.stopPropagation()
onRemove()
}}
className='flex items-center justify-center text-[var(--text-tertiary)] transition-colors hover:text-[var(--text-primary)]'
aria-label='Remove tool'
>
<X className='h-[13px] w-[13px]' />
</button>
</div>
</div>
{/* Expanded body */}
{isExpanded && (
<ReactFlowProvider>
<div className='flex flex-col gap-[10px] overflow-visible rounded-b-[4px] border-[var(--border-1)] border-t bg-[var(--surface-2)] px-[8px] py-[8px]'>
{/* Operation selector */}
{hasOps &&
(() => {
const opOptions = getOperationOptions(tool.type ?? '')
return opOptions.length > 0 ? (
<div className='space-y-[6px]'>
<div className='pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
Operation
</div>
<Combobox
options={opOptions
.filter((o) => o.id !== '')
.map((o) => ({ label: o.label, value: o.id }))}
value={tool.operation || opOptions[0]?.id || ''}
onChange={(v) => onOperationChange(v)}
placeholder='Select operation'
/>
</div>
) : null
})()}
{/* Sub-block params */}
{displaySubBlocks.map((sb) => (
<ToolSubBlockRenderer
key={sb.id}
blockId={agentId}
subBlockId='agent-tools'
toolIndex={toolIndex}
subBlock={{ ...sb, title: sb.title || sb.id }}
effectiveParamId={sb.id}
toolParams={tool.params as Record<string, string> | undefined}
onParamChange={(_, paramId, value) => onParamChange(paramId, value)}
disabled={false}
/>
))}
</div>
</ReactFlowProvider>
)}
</div>
)
}

View File

@@ -0,0 +1,102 @@
'use client'
import { useState } from 'react'
import { createLogger } from '@sim/logger'
import { Button, Label } from '@/components/emcn'
import { Check, Copy } from '@/components/emcn/icons'
import { useUpdateAgent } from '@/hooks/queries/agents'
const logger = createLogger('ApiDeploy')
interface ApiDeployProps {
agentId: string
workspaceId: string
isDeployed: boolean
}
export function ApiDeploy({ agentId, workspaceId, isDeployed }: ApiDeployProps) {
const { mutateAsync: updateAgent, isPending } = useUpdateAgent()
const [copied, setCopied] = useState(false)
const endpoint =
typeof window !== 'undefined'
? `${window.location.origin}/api/v1/agents/${agentId}`
: `/api/v1/agents/${agentId}`
const curlExample = `curl -X POST "${endpoint}" \\
-H "Authorization: Bearer <your-api-key>" \\
-H "Content-Type: application/json" \\
-d '{"message": "Hello, agent!"}'`
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(curlExample)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch {
/* ignore */
}
}
const handleToggle = async () => {
try {
await updateAgent({ agentId, isDeployed: !isDeployed })
} catch (error) {
logger.error('Failed to toggle API deployment', { error })
}
}
return (
<div className='space-y-[16px]'>
<p className='text-[13px] text-[var(--text-muted)]'>
Call your agent via REST API using your workspace API key.
</p>
<div className='flex items-center justify-between'>
<Label className='text-[13px]'>API access</Label>
<Button variant='subtle' size='sm' onClick={handleToggle} disabled={isPending}>
{isDeployed ? 'Disable' : 'Enable'}
</Button>
</div>
{isDeployed && (
<>
<div>
<Label className='mb-[6px] block text-[12px] text-[var(--text-muted)]'>Endpoint</Label>
<div className='flex items-center gap-[8px] rounded-[6px] border border-[var(--border-1)] bg-[var(--surface-4)] px-[10px] py-[7px]'>
<code className='flex-1 truncate text-[12px] text-[var(--text-primary)]'>
{endpoint}
</code>
</div>
</div>
<div>
<div className='mb-[6px] flex items-center justify-between'>
<Label className='text-[12px] text-[var(--text-muted)]'>Example request</Label>
<button
type='button'
onClick={handleCopy}
className='flex items-center gap-[4px] text-[11px] text-[var(--text-tertiary)] hover:text-[var(--text-primary)]'
>
{copied ? (
<>
<Check className='h-[11px] w-[11px]' />
Copied
</>
) : (
<>
<Copy className='h-[11px] w-[11px]' />
Copy
</>
)}
</button>
</div>
<pre className='overflow-x-auto rounded-[6px] border border-[var(--border-1)] bg-[var(--surface-4)] px-[10px] py-[8px] text-[11px] text-[var(--text-primary)] leading-[1.6]'>
{curlExample}
</pre>
</div>
</>
)}
</div>
)
}

View File

@@ -0,0 +1,362 @@
'use client'
import { useCallback, useMemo, useState } from 'react'
import { createLogger } from '@sim/logger'
import { Button, Combobox, type ComboboxOption, Input, Label } from '@/components/emcn'
import { Check, Loader, Plus } from '@/components/emcn/icons'
import type { AgentDeploymentRow, SlackDeploymentConfig } from '@/lib/agents/types'
import { cn } from '@/lib/core/utils/cn'
import { getCanonicalScopesForProvider } from '@/lib/oauth'
import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal'
import {
useAgent,
useDeployAgentToSlack,
useSlackChannels,
useUndeployAgentFromSlack,
} from '@/hooks/queries/agents'
import { useWorkspaceCredentials, type WorkspaceCredential } from '@/hooks/queries/credentials'
const logger = createLogger('SlackDeploy')
interface SlackDeployProps {
agentId: string
workspaceId: string
}
const RESPOND_TO_OPTIONS: ComboboxOption[] = [
{ value: 'mentions', label: '@mentions only' },
{ value: 'all', label: 'All messages in channel' },
{ value: 'threads', label: 'Thread replies only' },
{ value: 'dm', label: 'Direct messages (DMs)' },
]
export function SlackDeploy({ agentId, workspaceId }: SlackDeployProps) {
const { data: agent } = useAgent(agentId)
const { data: credentials = [], isLoading: isLoadingCredentials } = useWorkspaceCredentials({
workspaceId,
type: 'oauth',
providerId: 'slack',
})
const existingDeployment = agent?.deployments?.find((d) => d.platform === 'slack')
return (
<SlackDeployForm
key={existingDeployment?.id ?? 'new'}
agentId={agentId}
existingDeployment={existingDeployment}
credentials={credentials}
isLoadingCredentials={isLoadingCredentials}
/>
)
}
interface SlackDeployFormProps {
agentId: string
existingDeployment: AgentDeploymentRow | undefined
credentials: WorkspaceCredential[]
isLoadingCredentials: boolean
}
function SlackDeployForm({
agentId,
existingDeployment,
credentials,
isLoadingCredentials,
}: SlackDeployFormProps) {
const [showOAuthModal, setShowOAuthModal] = useState(false)
const [deployError, setDeployError] = useState<string | null>(null)
const { mutateAsync: deploy, isPending: isDeploying } = useDeployAgentToSlack()
const { mutateAsync: undeploy, isPending: isUndeploying } = useUndeployAgentFromSlack()
const existingCfg = existingDeployment?.config as SlackDeploymentConfig | undefined
const [selectedCredentialId, setSelectedCredentialId] = useState(
existingDeployment?.credentialId ?? ''
)
const [selectedChannelIds, setSelectedChannelIds] = useState<string[]>(
existingCfg?.channelIds ?? []
)
const [respondTo, setRespondTo] = useState<SlackDeploymentConfig['respondTo']>(
existingCfg?.respondTo ?? 'mentions'
)
const [botName, setBotName] = useState(existingCfg?.botName ?? '')
const [replyInThread, setReplyInThread] = useState(existingCfg?.replyInThread !== false)
const isDeployed = Boolean(existingDeployment?.isActive)
const isDmMode = respondTo === 'dm'
const { data: channels = [], isLoading: isLoadingChannels } =
useSlackChannels(selectedCredentialId)
const handleCredentialChange = useCallback((id: string) => {
setSelectedCredentialId(id)
setSelectedChannelIds([])
}, [])
const handleDeploy = useCallback(async () => {
if (!selectedCredentialId) return
if (!isDmMode && selectedChannelIds.length === 0) return
setDeployError(null)
try {
await deploy({
agentId,
credentialId: selectedCredentialId,
channelIds: isDmMode ? [] : selectedChannelIds,
respondTo,
botName: botName.trim() || undefined,
replyInThread,
})
} catch (error) {
logger.error('Failed to deploy agent to Slack', { error })
setDeployError(error instanceof Error ? error.message : 'Deployment failed')
}
}, [
agentId,
botName,
deploy,
isDmMode,
respondTo,
replyInThread,
selectedChannelIds,
selectedCredentialId,
])
const handleUndeploy = useCallback(async () => {
try {
await undeploy({ agentId })
} catch (error) {
logger.error('Failed to undeploy agent from Slack', { error })
}
}, [agentId, undeploy])
const toggleChannel = useCallback((channelId: string) => {
setSelectedChannelIds((prev) =>
prev.includes(channelId) ? prev.filter((id) => id !== channelId) : [...prev, channelId]
)
}, [])
const credentialOptions: ComboboxOption[] = useMemo(
() => credentials.map((c) => ({ value: c.id, label: c.displayName })),
[credentials]
)
const hasValidConfig = selectedCredentialId && (isDmMode || selectedChannelIds.length > 0)
return (
<>
<OAuthRequiredModal
isOpen={showOAuthModal}
onClose={() => setShowOAuthModal(false)}
provider='slack'
toolName='Slack'
requiredScopes={getCanonicalScopesForProvider('slack')}
serviceId='slack'
/>
<div className='flex flex-col gap-[16px]'>
{/* Workspace */}
<div>
<Label className='mb-[6px] block text-[12px] text-[var(--text-muted)]'>
Slack workspace
</Label>
{isLoadingCredentials ? (
<div className='flex h-[36px] items-center gap-[6px] text-[12px] text-[var(--text-muted)]'>
<Loader className='h-[12px] w-[12px] animate-spin' />
Loading
</div>
) : credentials.length === 0 ? (
<div className='flex flex-col gap-[8px]'>
<p className='text-[12px] text-[var(--text-muted)]'>
No Slack workspace connected yet.
</p>
<Button
variant='outline'
size='sm'
onClick={() => setShowOAuthModal(true)}
className='w-fit'
>
<Plus className='mr-[4px] h-[12px] w-[12px]' />
Connect Slack
</Button>
</div>
) : (
<div className='flex items-center gap-[8px]'>
<div className='flex-1'>
<Combobox
options={credentialOptions}
value={selectedCredentialId}
onChange={handleCredentialChange}
placeholder='Select a Slack workspace…'
/>
</div>
<button
type='button'
onClick={() => setShowOAuthModal(true)}
className='flex-shrink-0 text-[var(--text-muted)] transition-colors hover:text-[var(--text-primary)]'
title='Connect another workspace'
>
<Plus className='h-[14px] w-[14px]' />
</button>
</div>
)}
</div>
{selectedCredentialId && (
<>
{/* Respond to */}
<div>
<Label className='mb-[6px] block text-[12px] text-[var(--text-muted)]'>
Respond to
</Label>
<Combobox
options={RESPOND_TO_OPTIONS}
value={respondTo}
onChange={(v) => setRespondTo(v as SlackDeploymentConfig['respondTo'])}
/>
</div>
{/* Channels — hidden in DM mode */}
{!isDmMode && (
<div>
<Label className='mb-[6px] block text-[12px] text-[var(--text-muted)]'>
Channels <span className='font-normal opacity-60'>(select one or more)</span>
</Label>
{isLoadingChannels ? (
<div className='flex h-[32px] items-center gap-[6px] text-[12px] text-[var(--text-muted)]'>
<Loader className='h-[12px] w-[12px] animate-spin' />
Loading channels
</div>
) : channels.length === 0 ? (
<p className='text-[12px] text-[var(--text-muted)]'>
No accessible channels found. Make sure the bot has been added to at least one
channel.
</p>
) : (
<div className='max-h-[160px] overflow-y-auto rounded-[4px] border border-[var(--border-1)]'>
{channels.map((channel) => (
<button
key={channel.id}
type='button'
onClick={() => toggleChannel(channel.id)}
className={cn(
'flex w-full items-center gap-[8px] px-[10px] py-[7px] text-left text-[12px] transition-colors',
'hover:bg-[var(--surface-7)]',
selectedChannelIds.includes(channel.id) && 'bg-[var(--surface-4)]'
)}
>
<span
className={cn(
'flex h-[14px] w-[14px] flex-shrink-0 items-center justify-center rounded-[3px] border',
selectedChannelIds.includes(channel.id)
? 'border-[var(--text-primary)] bg-[var(--text-primary)]'
: 'border-[var(--border-1)]'
)}
>
{selectedChannelIds.includes(channel.id) && (
<Check className='h-[9px] w-[9px] text-[var(--surface-1)]' />
)}
</span>
<span className='text-[var(--text-primary)]'>
{channel.isPrivate ? '🔒 ' : '#'}
{channel.name}
</span>
</button>
))}
</div>
)}
</div>
)}
{/* Bot display name */}
<div>
<Label className='mb-[6px] block text-[12px] text-[var(--text-muted)]'>
Bot display name <span className='font-normal opacity-60'>(optional)</span>
</Label>
<Input
value={botName}
onChange={(e) => setBotName(e.target.value)}
placeholder='e.g. Sales Assistant'
maxLength={80}
className='h-[34px] text-[12px]'
/>
</div>
{/* Reply in thread */}
<div className='flex items-center justify-between'>
<div>
<p className='text-[12px] text-[var(--text-primary)]'>Reply in thread</p>
<p className='text-[11px] text-[var(--text-muted)]'>
Keep responses inside the original thread
</p>
</div>
<button
type='button'
onClick={() => setReplyInThread((v) => !v)}
className={cn(
'relative h-[20px] w-[36px] flex-shrink-0 rounded-full transition-colors',
replyInThread ? 'bg-[var(--text-primary)]' : 'bg-[var(--border-1)]'
)}
aria-checked={replyInThread}
role='switch'
>
<span
className={cn(
'absolute top-[2px] h-[16px] w-[16px] rounded-full bg-white shadow-sm transition-transform',
replyInThread ? 'translate-x-[18px]' : 'translate-x-[2px]'
)}
/>
</button>
</div>
</>
)}
{/* Error */}
{deployError && (
<p className='rounded-[4px] bg-[var(--error-bg,#fef2f2)] px-[10px] py-[6px] text-[12px] text-[var(--error,#dc2626)]'>
{deployError}
</p>
)}
{/* Actions */}
<div className='flex items-center gap-[8px] pt-[4px]'>
{isDeployed ? (
<>
<div className='flex items-center gap-[6px] text-[12px] text-[var(--text-success,#16a34a)]'>
<span className='h-[6px] w-[6px] rounded-full bg-[var(--text-success,#16a34a)]' />
Deployed
</div>
<Button
variant='outline'
size='sm'
onClick={handleDeploy}
disabled={!hasValidConfig || isDeploying}
className='ml-auto'
>
{isDeploying ? (
<Loader className='mr-[4px] h-[12px] w-[12px] animate-spin' />
) : null}
Update
</Button>
<Button variant='outline' size='sm' onClick={handleUndeploy} disabled={isUndeploying}>
{isUndeploying ? (
<Loader className='mr-[4px] h-[12px] w-[12px] animate-spin' />
) : null}
Remove
</Button>
</>
) : (
<Button
size='sm'
onClick={handleDeploy}
disabled={!hasValidConfig || isDeploying}
className='ml-auto'
>
{isDeploying ? <Loader className='mr-[4px] h-[12px] w-[12px] animate-spin' /> : null}
Deploy to Slack
</Button>
)}
</div>
</div>
</>
)
}

View File

@@ -0,0 +1,531 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { Paperclip, Square } from 'lucide-react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { Send, X } from '@/components/emcn/icons'
import { getDocumentIcon } from '@/components/icons/document-icons'
import { cn } from '@/lib/core/utils/cn'
import { CHAT_ACCEPT_ATTRIBUTE } from '@/lib/uploads/utils/validation'
const logger = createLogger('AgentTestPanel')
const MAX_FILES = 10
const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10MB
const TEXT_EXTENSIONS = new Set([
'.txt',
'.md',
'.csv',
'.json',
'.xml',
'.html',
'.ts',
'.tsx',
'.js',
'.jsx',
'.py',
'.sh',
])
const TEXT_MEDIA_TYPES = new Set([
'text/plain',
'text/markdown',
'text/csv',
'application/json',
'text/html',
'application/xml',
'text/xml',
])
interface AttachedFile {
id: string
name: string
size: number
type: string
file: File
previewUrl?: string
}
interface ChatMessage {
role: 'user' | 'assistant'
content: string
isStreaming?: boolean
attachments?: Array<{ id: string; name: string; type: string; previewUrl?: string }>
}
interface AgentTestPanelProps {
agentId: string
}
function isTextFile(file: { type: string; name: string }) {
if (TEXT_MEDIA_TYPES.has(file.type)) return true
const ext = file.name.slice(file.name.lastIndexOf('.')).toLowerCase()
return TEXT_EXTENSIONS.has(ext)
}
function StreamingCursor() {
return (
<span className='ml-[1px] inline-block h-[13px] w-[5px] translate-y-[1px] animate-pulse bg-current opacity-60' />
)
}
function FilePill({
name,
type,
previewUrl,
onRemove,
}: {
name: string
type: string
previewUrl?: string
onRemove?: () => void
}) {
const isImage = type.startsWith('image/')
const Icon = getDocumentIcon(type, name)
if (isImage && previewUrl) {
return (
<div className='group relative h-[44px] w-[44px] flex-shrink-0 overflow-hidden rounded-[6px]'>
<img src={previewUrl} alt={name} className='h-full w-full object-cover' />
{onRemove && (
<button
type='button'
onClick={onRemove}
className='absolute inset-0 flex items-center justify-center bg-black/40 opacity-0 transition-opacity group-hover:opacity-100'
aria-label={`Remove ${name}`}
>
<X className='h-[11px] w-[11px] text-white' />
</button>
)}
</div>
)
}
return (
<div className='flex max-w-[160px] items-center gap-[4px] rounded-[6px] bg-[var(--surface-4)] px-[7px] py-[3px]'>
<Icon className='h-[11px] w-[11px] flex-shrink-0 text-[var(--text-muted)]' />
<span className='min-w-0 truncate text-[11px] text-[var(--text-primary)]'>{name}</span>
{onRemove && (
<button
type='button'
onClick={onRemove}
className='ml-[2px] flex-shrink-0 text-[var(--text-tertiary)] hover:text-[var(--text-primary)]'
aria-label={`Remove ${name}`}
>
<X className='h-[9px] w-[9px]' />
</button>
)}
</div>
)
}
function UserMessage({ message }: { message: ChatMessage }) {
const hasAttachments = (message.attachments?.length ?? 0) > 0
return (
<div className='flex w-full flex-col items-end gap-[4px]'>
{hasAttachments && (
<div className='flex flex-wrap justify-end gap-[5px]'>
{message.attachments!.map((att) => (
<FilePill key={att.id} name={att.name} type={att.type} previewUrl={att.previewUrl} />
))}
</div>
)}
{message.content && (
<div className='max-w-[85%] rounded-[8px] border border-[var(--border-1)] bg-[var(--surface-4)] px-[10px] py-[7px]'>
<p className='whitespace-pre-wrap break-words font-medium text-[13px] text-[var(--text-primary)] leading-[1.5]'>
{message.content}
</p>
</div>
)}
</div>
)
}
const REMARK_PLUGINS = [remarkGfm]
const PROSE_CLASSES = cn(
'prose prose-sm dark:prose-invert max-w-none',
'prose-p:text-[13px] prose-p:leading-[1.6] prose-p:text-[var(--text-primary)] first:prose-p:mt-0 last:prose-p:mb-0',
'prose-headings:text-[var(--text-primary)] prose-headings:font-semibold prose-headings:mt-4 prose-headings:mb-2',
'prose-li:text-[13px] prose-li:text-[var(--text-primary)] prose-li:my-0.5',
'prose-ul:my-2 prose-ol:my-2',
'prose-strong:text-[var(--text-primary)] prose-strong:font-semibold',
'prose-a:text-[var(--text-primary)] prose-a:underline prose-a:decoration-dashed prose-a:underline-offset-2',
'prose-code:rounded prose-code:bg-[var(--surface-4)] prose-code:px-1 prose-code:py-0.5 prose-code:text-[12px] prose-code:font-mono prose-code:text-[var(--text-primary)]',
'prose-code:before:content-none prose-code:after:content-none',
'prose-pre:bg-[var(--surface-4)] prose-pre:border prose-pre:border-[var(--border-1)] prose-pre:rounded-[6px] prose-pre:text-[12px]',
'prose-blockquote:border-[var(--border-1)] prose-blockquote:text-[var(--text-muted)]'
)
function AssistantMessage({ message }: { message: ChatMessage }) {
return (
<div className='w-full pl-[2px]'>
{message.isStreaming && !message.content ? (
<span className='text-[13px] text-[var(--text-tertiary)]'>
Thinking
<StreamingCursor />
</span>
) : message.content ? (
<div className={PROSE_CLASSES}>
<ReactMarkdown remarkPlugins={REMARK_PLUGINS}>{message.content}</ReactMarkdown>
{message.isStreaming && <StreamingCursor />}
</div>
) : null}
</div>
)
}
export function AgentTestPanel({ agentId }: AgentTestPanelProps) {
const [messages, setMessages] = useState<ChatMessage[]>([])
const [input, setInput] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [attachedFiles, setAttachedFiles] = useState<AttachedFile[]>([])
const conversationIdRef = useRef(`test-${Date.now()}`)
const scrollRef = useRef<HTMLDivElement>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const abortRef = useRef<AbortController | null>(null)
useEffect(() => {
return () => {
abortRef.current?.abort()
// Revoke any object URLs created for image previews
for (const f of attachedFiles) {
if (f.previewUrl?.startsWith('blob:')) URL.revokeObjectURL(f.previewUrl)
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
useEffect(() => {
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' })
}, [messages])
const syncHeight = useCallback(() => {
const el = textareaRef.current
if (!el) return
el.style.height = 'auto'
el.style.height = `${Math.min(el.scrollHeight, 180)}px`
}, [])
const addFiles = useCallback((files: File[]) => {
setAttachedFiles((current) => {
const slots = Math.max(0, MAX_FILES - current.length)
const next: AttachedFile[] = []
for (const file of files.slice(0, slots)) {
if (file.size > MAX_FILE_SIZE) continue
if ([...current, ...next].some((f) => f.name === file.name && f.size === file.size))
continue
const attached: AttachedFile = {
id: crypto.randomUUID(),
name: file.name,
size: file.size,
type: file.type,
file,
}
if (file.type.startsWith('image/')) {
attached.previewUrl = URL.createObjectURL(file)
}
next.push(attached)
}
return [...current, ...next]
})
}, [])
const handleFileChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) addFiles(Array.from(e.target.files))
e.target.value = ''
},
[addFiles]
)
const handleStop = useCallback(() => {
abortRef.current?.abort()
}, [])
const handleSend = useCallback(async () => {
const trimmed = input.trim()
if ((!trimmed && attachedFiles.length === 0) || isLoading) return
const files = [...attachedFiles]
setInput('')
setAttachedFiles([])
setIsLoading(true)
if (textareaRef.current) textareaRef.current.style.height = 'auto'
// Inject text file contents into the message
let messageText = trimmed
const textFiles = files.filter(isTextFile)
if (textFiles.length > 0) {
const contents = await Promise.all(
textFiles.map(
(f) =>
new Promise<string>((resolve) => {
const reader = new FileReader()
reader.onload = (ev) => resolve(`\n\n--- ${f.name} ---\n${ev.target?.result ?? ''}`)
reader.onerror = () => resolve('')
reader.readAsText(f.file)
})
)
)
messageText = (trimmed + contents.join('')).trim()
} else if (!trimmed && files.length > 0) {
messageText = files.map((f) => f.name).join(', ')
}
const attachments = files.map(({ id, name, type, previewUrl }) => ({
id,
name,
type,
previewUrl,
}))
setMessages((prev) => [
...prev,
{ role: 'user', content: trimmed, attachments },
{ role: 'assistant', content: '', isStreaming: true },
])
const controller = new AbortController()
abortRef.current = controller
try {
const res = await fetch(`/api/agents/${agentId}/execute`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'text/event-stream' },
body: JSON.stringify({ message: messageText, conversationId: conversationIdRef.current }),
signal: controller.signal,
})
if (!res.ok) {
const err = await res.json().catch(() => ({ error: 'Unknown error' }))
throw new Error(err.error || 'Execution failed')
}
const contentType = res.headers.get('content-type') ?? ''
if (contentType.includes('text/event-stream') && res.body) {
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buf = ''
let accumulated = ''
outer: while (true) {
const { done, value } = await reader.read()
if (done) break
buf += decoder.decode(value, { stream: true })
const lines = buf.split('\n')
buf = lines.pop() ?? ''
for (const line of lines) {
if (!line.startsWith('data: ')) continue
const data = line.slice(6).trim()
if (data === '[DONE]') break outer
try {
const parsed = JSON.parse(data)
if (parsed.content) {
accumulated += parsed.content
setMessages((prev) => {
const next = [...prev]
next[next.length - 1] = {
role: 'assistant',
content: accumulated,
isStreaming: true,
}
return next
})
}
} catch {}
}
}
setMessages((prev) => {
const next = [...prev]
next[next.length - 1] = { role: 'assistant', content: accumulated, isStreaming: false }
return next
})
} else {
const json = await res.json()
setMessages((prev) => {
const next = [...prev]
next[next.length - 1] = {
role: 'assistant',
content: json.data?.content ?? '',
isStreaming: false,
}
return next
})
}
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
setMessages((prev) => {
const next = [...prev]
const last = next[next.length - 1]
if (last?.role === 'assistant') next[next.length - 1] = { ...last, isStreaming: false }
return next
})
return
}
logger.error('Agent execution failed', { error })
const msg = error instanceof Error ? error.message : 'Something went wrong'
setMessages((prev) => {
const next = [...prev]
next[next.length - 1] = { role: 'assistant', content: `Error: ${msg}`, isStreaming: false }
return next
})
} finally {
setIsLoading(false)
abortRef.current = null
setTimeout(() => textareaRef.current?.focus(), 0)
}
}, [agentId, input, attachedFiles, isLoading])
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
}
},
[handleSend]
)
const canSend = (input.trim().length > 0 || attachedFiles.length > 0) && !isLoading
return (
<div
className='flex h-full flex-col bg-[var(--surface-1)]'
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault()
addFiles(Array.from(e.dataTransfer.files))
}}
>
<div className='flex flex-shrink-0 items-center justify-between border-[var(--border-1)] border-b px-[16px] py-[10px]'>
<span className='font-medium text-[13px] text-[var(--text-primary)]'>Test</span>
{messages.length > 0 && (
<button
type='button'
onClick={() => {
setMessages([])
setAttachedFiles([])
conversationIdRef.current = `test-${Date.now()}`
}}
className='text-[12px] text-[var(--text-muted)] transition-colors hover:text-[var(--text-primary)]'
>
Clear
</button>
)}
</div>
<div ref={scrollRef} className='flex-1 overflow-y-auto px-[20px] py-[20px]'>
{messages.length === 0 ? (
<div className='flex h-full flex-col items-center justify-center gap-[6px]'>
<p className='text-[13px] text-[var(--text-muted)]'>
Send a message to test your agent.
</p>
<p className='text-[11px] text-[var(--text-tertiary)]'>
to send · Shift+ for new line
</p>
</div>
) : (
<div className='flex flex-col gap-[20px]'>
{messages.map((msg, idx) =>
msg.role === 'user' ? (
<UserMessage key={idx} message={msg} />
) : (
<AssistantMessage key={idx} message={msg} />
)
)}
</div>
)}
</div>
<div className='flex-shrink-0 p-[12px]'>
<div className='rounded-[8px] border border-[var(--border-1)] bg-[var(--surface-5)]'>
{attachedFiles.length > 0 && (
<div className='flex flex-wrap gap-[5px] px-[10px] pt-[8px]'>
{attachedFiles.map((file) => (
<FilePill
key={file.id}
name={file.name}
type={file.type}
previewUrl={file.previewUrl}
onRemove={() => {
if (file.previewUrl?.startsWith('blob:')) URL.revokeObjectURL(file.previewUrl)
setAttachedFiles((prev) => prev.filter((f) => f.id !== file.id))
}}
/>
))}
</div>
)}
<div className='flex items-end px-[10px] py-[6px]'>
<textarea
ref={textareaRef}
value={input}
onChange={(e) => {
setInput(e.target.value)
syncHeight()
}}
onKeyDown={handleKeyDown}
placeholder='Send a message…'
rows={1}
disabled={isLoading}
className={cn(
'max-h-[180px] min-h-[24px] flex-1 resize-none bg-transparent py-[2px] text-[13px] text-[var(--text-primary)] leading-[1.5]',
'placeholder:text-[var(--text-tertiary)] focus:outline-none disabled:opacity-50',
'overflow-hidden'
)}
/>
</div>
<div className='flex items-center justify-between px-[8px] pb-[6px]'>
<button
type='button'
onClick={() => fileInputRef.current?.click()}
disabled={isLoading}
className='flex h-[26px] w-[26px] items-center justify-center rounded-[4px] text-[var(--text-tertiary)] transition-colors hover:bg-[var(--surface-4)] hover:text-[var(--text-primary)] disabled:opacity-40'
aria-label='Attach file'
>
<Paperclip className='h-[13px] w-[13px]' />
</button>
<button
type='button'
onClick={isLoading ? handleStop : handleSend}
disabled={!isLoading && !canSend}
className={cn(
'flex h-[26px] w-[26px] items-center justify-center rounded-[4px] transition-colors',
isLoading
? 'bg-[var(--surface-4)] text-[var(--text-primary)] hover:bg-[var(--border-1)]'
: canSend
? 'bg-[var(--text-primary)] text-[var(--surface-1)] hover:opacity-85'
: 'cursor-not-allowed text-[var(--text-tertiary)]'
)}
aria-label={isLoading ? 'Stop' : 'Send'}
>
{isLoading ? (
<Square className='h-[10px] w-[10px]' />
) : (
<Send className='h-[12px] w-[12px]' />
)}
</button>
</div>
</div>
</div>
<input
ref={fileInputRef}
type='file'
multiple
className='hidden'
onChange={handleFileChange}
accept={CHAT_ACCEPT_ATTRIBUTE}
/>
</div>
)
}

View File

@@ -0,0 +1,52 @@
'use client'
import {
Modal,
ModalBody,
ModalContent,
ModalHeader,
ModalTabs,
ModalTabsContent,
ModalTabsList,
ModalTabsTrigger,
} from '@/components/emcn'
import { ApiDeploy } from '@/app/workspace/[workspaceId]/agents/[agentId]/components/agent-deploy/api-deploy'
import { SlackDeploy } from '@/app/workspace/[workspaceId]/agents/[agentId]/components/agent-deploy/slack-deploy'
interface DeployModalProps {
agentId: string
workspaceId: string
isDeployed: boolean
open: boolean
onOpenChange: (open: boolean) => void
}
export function DeployModal({
agentId,
workspaceId,
isDeployed,
open,
onOpenChange,
}: DeployModalProps) {
return (
<Modal open={open} onOpenChange={onOpenChange}>
<ModalContent size='md'>
<ModalHeader>Deploy agent</ModalHeader>
<ModalTabs defaultValue='slack'>
<ModalTabsList>
<ModalTabsTrigger value='slack'>Slack</ModalTabsTrigger>
<ModalTabsTrigger value='api'>API</ModalTabsTrigger>
</ModalTabsList>
<ModalBody>
<ModalTabsContent value='slack'>
<SlackDeploy agentId={agentId} workspaceId={workspaceId} />
</ModalTabsContent>
<ModalTabsContent value='api'>
<ApiDeploy agentId={agentId} workspaceId={workspaceId} isDeployed={isDeployed} />
</ModalTabsContent>
</ModalBody>
</ModalTabs>
</ModalContent>
</Modal>
)
}

View File

@@ -0,0 +1,154 @@
'use client'
import { useCallback, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
const logger = createLogger('AgentWand')
interface ChatMessage {
role: 'user' | 'assistant'
content: string
}
interface UseAgentWandOptions {
/** System prompt passed verbatim to /api/wand. Use {context} as a placeholder for the current value. */
systemPrompt: string
generationType?: string
maintainHistory?: boolean
currentValue: string
onGeneratedContent: (content: string) => void
}
export interface AgentWandState {
isSearchActive: boolean
searchQuery: string
isStreaming: boolean
searchInputRef: React.RefObject<HTMLInputElement | null>
onSearchClick: () => void
onSearchBlur: () => void
onSearchChange: (value: string) => void
onSearchSubmit: () => void
onSearchCancel: () => void
}
export function useAgentWand({
systemPrompt,
generationType,
maintainHistory = false,
currentValue,
onGeneratedContent,
}: UseAgentWandOptions): AgentWandState {
const [isSearchActive, setIsSearchActive] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const [isStreaming, setIsStreaming] = useState(false)
const [history, setHistory] = useState<ChatMessage[]>([])
const searchInputRef = useRef<HTMLInputElement>(null)
const abortRef = useRef<AbortController | null>(null)
const searchQueryRef = useRef(searchQuery)
searchQueryRef.current = searchQuery
const onSearchClick = useCallback(() => {
setIsSearchActive(true)
setTimeout(() => searchInputRef.current?.focus(), 50)
}, [])
const onSearchBlur = useCallback(() => {
if (!searchQueryRef.current.trim() && !abortRef.current) {
setIsSearchActive(false)
}
}, [])
const onSearchChange = useCallback((value: string) => {
setSearchQuery(value)
}, [])
const onSearchCancel = useCallback(() => {
abortRef.current?.abort()
abortRef.current = null
setIsSearchActive(false)
setSearchQuery('')
setIsStreaming(false)
}, [])
const onSearchSubmit = useCallback(async () => {
const prompt = searchQueryRef.current
if (!prompt.trim()) return
const resolvedSystemPrompt = systemPrompt.replace('{context}', currentValue)
setSearchQuery('')
setIsSearchActive(false)
setIsStreaming(true)
const controller = new AbortController()
abortRef.current = controller
try {
const res = await fetch('/api/wand', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
prompt,
systemPrompt: resolvedSystemPrompt,
stream: true,
generationType,
history: maintainHistory ? history : [],
}),
signal: controller.signal,
})
if (!res.ok || !res.body) throw new Error('Wand generation failed')
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buf = ''
let accumulated = ''
outer: while (true) {
const { done, value } = await reader.read()
if (done) break
buf += decoder.decode(value, { stream: true })
const lines = buf.split('\n')
buf = lines.pop() ?? ''
for (const line of lines) {
if (!line.startsWith('data: ')) continue
try {
const parsed = JSON.parse(line.slice(6))
if (parsed.done) break outer
if (parsed.chunk) {
accumulated += parsed.chunk
onGeneratedContent(accumulated)
}
} catch {}
}
}
if (maintainHistory) {
setHistory((prev) => [
...prev,
{ role: 'user', content: prompt },
{ role: 'assistant', content: accumulated },
])
}
} catch (error) {
if (!(error instanceof Error && error.name === 'AbortError')) {
logger.error('Wand generation failed', { error })
}
} finally {
setIsStreaming(false)
abortRef.current = null
}
}, [systemPrompt, generationType, maintainHistory, currentValue, history, onGeneratedContent])
return {
isSearchActive,
searchQuery,
isStreaming,
searchInputRef,
onSearchClick,
onSearchBlur,
onSearchChange,
onSearchSubmit,
onSearchCancel,
}
}

View File

@@ -0,0 +1,3 @@
export default function AgentDetailLayout({ children }: { children: React.ReactNode }) {
return <div className='flex h-full flex-1 flex-col overflow-hidden'>{children}</div>
}

View File

@@ -0,0 +1,38 @@
import type { Metadata } from 'next'
import { redirect } from 'next/navigation'
import { getSession } from '@/lib/auth'
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
import { AgentDetail } from '@/app/workspace/[workspaceId]/agents/[agentId]/agent-detail'
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check'
export const metadata: Metadata = {
title: 'Agent',
}
interface AgentDetailPageProps {
params: Promise<{
workspaceId: string
agentId: string
}>
}
export default async function AgentDetailPage({ params }: AgentDetailPageProps) {
const { workspaceId, agentId } = await params
const session = await getSession()
if (!session?.user?.id) {
redirect('/')
}
const hasPermission = await verifyWorkspaceMembership(session.user.id, workspaceId)
if (!hasPermission) {
redirect('/')
}
const permissionConfig = await getUserPermissionConfig(session.user.id)
if (permissionConfig?.hideAgentsTab) {
redirect(`/workspace/${workspaceId}`)
}
return <AgentDetail agentId={agentId} workspaceId={workspaceId} />
}

View File

@@ -0,0 +1,224 @@
'use client'
import { useCallback, useDeferredValue, useMemo, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useParams, useRouter } from 'next/navigation'
import { AgentIcon } from '@/components/icons'
import { AgentContextMenu } from '@/app/workspace/[workspaceId]/agents/components/agent-context-menu'
import { AgentListContextMenu } from '@/app/workspace/[workspaceId]/agents/components/agent-list-context-menu'
import type { ResourceColumn, ResourceRow } from '@/app/workspace/[workspaceId]/components'
import {
InlineRenameInput,
ownerCell,
Resource,
timeCell,
} from '@/app/workspace/[workspaceId]/components'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
import {
useAgentsList,
useCreateAgent,
useDeleteAgent,
useUpdateAgent,
} from '@/hooks/queries/agents'
import { useWorkspaceMembersQuery } from '@/hooks/queries/workspace'
import { useInlineRename } from '@/hooks/use-inline-rename'
const logger = createLogger('Agents')
const COLUMNS: ResourceColumn[] = [
{ id: 'name', header: 'Name' },
{ id: 'model', header: 'Model' },
{ id: 'status', header: 'Status' },
{ id: 'created', header: 'Created' },
{ id: 'owner', header: 'Owner' },
{ id: 'updated', header: 'Last Updated' },
]
export function Agents() {
const params = useParams()
const router = useRouter()
const workspaceId = params.workspaceId as string
const { data: agents = [], isLoading } = useAgentsList(workspaceId)
const { data: members } = useWorkspaceMembersQuery(workspaceId)
const { mutateAsync: createAgent, isPending: isCreating } = useCreateAgent()
const { mutateAsync: deleteAgent } = useDeleteAgent()
const { mutateAsync: updateAgent } = useUpdateAgent()
const userPermissions = useUserPermissionsContext()
const [activeAgentId, setActiveAgentId] = useState<string | null>(null)
const [isDeleting, setIsDeleting] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const deferredSearch = useDeferredValue(searchQuery)
const listRename = useInlineRename({
onSave: (agentId, name) => updateAgent({ agentId, name }),
})
const {
isOpen: isListContextMenuOpen,
position: listContextMenuPosition,
handleContextMenu: handleListContextMenu,
closeMenu: closeListContextMenu,
} = useContextMenu()
const {
isOpen: isRowContextMenuOpen,
position: rowContextMenuPosition,
handleContextMenu: handleRowCtxMenu,
closeMenu: closeRowContextMenu,
} = useContextMenu()
const handleContentContextMenu = useCallback(
(e: React.MouseEvent) => {
const target = e.target as HTMLElement
if (
target.closest('[data-resource-row]') ||
target.closest('button, input, a, [role="button"]')
)
return
handleListContextMenu(e)
},
[handleListContextMenu]
)
const handleRowClick = useCallback(
(rowId: string) => {
if (isRowContextMenuOpen || listRename.editingId === rowId) return
router.push(`/workspace/${workspaceId}/agents/${rowId}`)
},
[isRowContextMenuOpen, listRename.editingId, router, workspaceId]
)
const handleRowContextMenu = useCallback(
(e: React.MouseEvent, rowId: string) => {
setActiveAgentId(rowId)
handleRowCtxMenu(e)
},
[handleRowCtxMenu]
)
const handleDelete = useCallback(async () => {
if (!activeAgentId) return
setIsDeleting(true)
try {
await deleteAgent({ agentId: activeAgentId })
closeRowContextMenu()
setActiveAgentId(null)
} catch (error) {
logger.error('Failed to delete agent', { error })
} finally {
setIsDeleting(false)
}
}, [activeAgentId, deleteAgent, closeRowContextMenu])
const handleCreateAgent = useCallback(async () => {
const existingNames = new Set(agents.map((a) => a.name))
let counter = 1
while (existingNames.has(`Agent ${counter}`)) counter++
const name = `Agent ${counter}`
try {
const agent = await createAgent({ workspaceId, name })
router.push(`/workspace/${workspaceId}/agents/${agent.id}`)
} catch (error) {
logger.error('Failed to create agent', { error })
}
}, [agents, createAgent, router, workspaceId])
const handleRename = useCallback(() => {
if (!activeAgentId) return
const agent = agents.find((a) => a.id === activeAgentId)
if (agent) listRename.startRename(activeAgentId, agent.name)
closeRowContextMenu()
}, [activeAgentId, agents, listRename, closeRowContextMenu])
const rows: ResourceRow[] = useMemo(() => {
const filtered = deferredSearch
? agents.filter((a) => a.name.toLowerCase().includes(deferredSearch.toLowerCase()))
: agents
return filtered.map((agent) => ({
id: agent.id,
cells: {
name: {
icon: <AgentIcon className='h-[14px] w-[14px]' />,
label: agent.name,
content:
listRename.editingId === agent.id ? (
<span className='flex min-w-0 items-center gap-[12px] font-medium text-[14px] text-[var(--text-body)]'>
<span className='flex-shrink-0 text-[var(--text-icon)]'>
<AgentIcon className='h-[14px] w-[14px]' />
</span>
<InlineRenameInput
value={listRename.editValue}
onChange={listRename.setEditValue}
onSubmit={listRename.submitRename}
onCancel={listRename.cancelRename}
/>
</span>
) : undefined,
},
model: { label: agent.config.model ?? '—' },
status: { label: agent.isDeployed ? 'Deployed' : 'Draft' },
created: timeCell(agent.createdAt),
owner: ownerCell(agent.createdBy, members),
updated: timeCell(agent.updatedAt),
},
sortValues: {
created: -new Date(agent.createdAt).getTime(),
updated: -new Date(agent.updatedAt).getTime(),
},
}))
}, [agents, deferredSearch, members, listRename.editingId, listRename.editValue])
return (
<>
<Resource
icon={AgentIcon}
title='Agents'
create={{
label: 'New agent',
onClick: handleCreateAgent,
disabled: userPermissions.canEdit !== true || isCreating,
}}
search={{
value: searchQuery,
onChange: setSearchQuery,
placeholder: 'Search agents…',
}}
defaultSort='created'
columns={COLUMNS}
rows={rows}
onRowClick={handleRowClick}
onRowContextMenu={handleRowContextMenu}
isLoading={isLoading}
onContextMenu={handleContentContextMenu}
/>
<AgentListContextMenu
isOpen={isListContextMenuOpen}
position={listContextMenuPosition}
onClose={closeListContextMenu}
onAddAgent={handleCreateAgent}
disableAdd={userPermissions.canEdit !== true || isCreating}
/>
{activeAgentId && (
<AgentContextMenu
isOpen={isRowContextMenuOpen}
position={rowContextMenuPosition}
onClose={closeRowContextMenu}
onOpen={() => {
router.push(`/workspace/${workspaceId}/agents/${activeAgentId}`)
closeRowContextMenu()
}}
onRename={handleRename}
onDelete={handleDelete}
isDeleting={isDeleting}
disableEdit={userPermissions.canEdit !== true}
/>
)}
</>
)
}

View File

@@ -0,0 +1,77 @@
'use client'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/emcn'
import { Loader, Pencil, SquareArrowUpRight, Trash } from '@/components/emcn/icons'
interface AgentContextMenuProps {
isOpen: boolean
position: { x: number; y: number }
onClose: () => void
onOpen?: () => void
onRename?: () => void
onDelete?: () => void
isDeleting?: boolean
disableEdit?: boolean
}
export function AgentContextMenu({
isOpen,
position,
onClose,
onOpen,
onRename,
onDelete,
isDeleting = false,
disableEdit = false,
}: AgentContextMenuProps) {
return (
<DropdownMenu open={isOpen} onOpenChange={(open) => !open && onClose()} modal={false}>
<DropdownMenuTrigger asChild>
<div
style={{
position: 'fixed',
left: `${position.x}px`,
top: `${position.y}px`,
width: '1px',
height: '1px',
pointerEvents: 'none',
}}
tabIndex={-1}
aria-hidden
/>
</DropdownMenuTrigger>
<DropdownMenuContent
align='start'
side='bottom'
sideOffset={4}
onCloseAutoFocus={(e) => e.preventDefault()}
>
{onOpen && (
<DropdownMenuItem onSelect={onOpen}>
<SquareArrowUpRight />
Open
</DropdownMenuItem>
)}
{onRename && (
<DropdownMenuItem disabled={disableEdit} onSelect={onRename}>
<Pencil />
Rename
</DropdownMenuItem>
)}
{onDelete && (onOpen || onRename) && <DropdownMenuSeparator />}
{onDelete && (
<DropdownMenuItem disabled={disableEdit || isDeleting} onSelect={onDelete}>
{isDeleting ? <Loader className='animate-spin' /> : <Trash />}
Delete
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -0,0 +1,57 @@
'use client'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/emcn'
import { Plus } from '@/components/emcn/icons'
interface AgentListContextMenuProps {
isOpen: boolean
position: { x: number; y: number }
onClose: () => void
onAddAgent?: () => void
disableAdd?: boolean
}
export function AgentListContextMenu({
isOpen,
position,
onClose,
onAddAgent,
disableAdd = false,
}: AgentListContextMenuProps) {
return (
<DropdownMenu open={isOpen} onOpenChange={(open) => !open && onClose()} modal={false}>
<DropdownMenuTrigger asChild>
<div
style={{
position: 'fixed',
left: `${position.x}px`,
top: `${position.y}px`,
width: '1px',
height: '1px',
pointerEvents: 'none',
}}
tabIndex={-1}
aria-hidden
/>
</DropdownMenuTrigger>
<DropdownMenuContent
align='start'
side='bottom'
sideOffset={4}
onCloseAutoFocus={(e) => e.preventDefault()}
>
{onAddAgent && (
<DropdownMenuItem disabled={disableAdd} onSelect={onAddAgent}>
<Plus />
New agent
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -0,0 +1,3 @@
export default function AgentsLayout({ children }: { children: React.ReactNode }) {
return <div className='flex h-full flex-1 flex-col overflow-hidden'>{children}</div>
}

View File

@@ -0,0 +1,63 @@
import { Skeleton } from '@/components/emcn'
const SKELETON_ROW_COUNT = 5
const COLUMN_COUNT = 6
export default function AgentsLoading() {
return (
<div className='flex h-full flex-1 flex-col overflow-hidden bg-[var(--bg)]'>
<div className='border-[var(--border)] border-b px-[24px] py-[10px]'>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-[12px]'>
<Skeleton className='h-[14px] w-[14px] rounded-[2px]' />
<Skeleton className='h-[14px] w-[96px] rounded-[4px]' />
</div>
<div className='flex items-center gap-[6px]'>
<Skeleton className='h-[28px] w-[80px] rounded-[6px]' />
</div>
</div>
</div>
<div className='border-[var(--border)] border-b px-[24px] py-[10px]'>
<div className='flex items-center'>
<Skeleton className='h-[14px] w-[14px] rounded-[2px]' />
<Skeleton className='ml-[10px] h-[14px] w-[160px] rounded-[4px]' />
</div>
</div>
<div className='min-h-0 flex-1 overflow-auto'>
<table className='w-full'>
<thead>
<tr className='border-[var(--border)] border-b'>
<th className='w-[40px] px-[12px] py-[8px]'>
<Skeleton className='h-[14px] w-[14px] rounded-[2px]' />
</th>
{Array.from({ length: COLUMN_COUNT }).map((_, i) => (
<th key={i} className='px-[12px] py-[8px] text-left'>
<Skeleton className='h-[12px] w-[56px] rounded-[4px]' />
</th>
))}
</tr>
</thead>
<tbody>
{Array.from({ length: SKELETON_ROW_COUNT }).map((_, rowIndex) => (
<tr key={rowIndex} className='border-[var(--border)] border-b'>
<td className='w-[40px] px-[12px] py-[10px]'>
<Skeleton className='h-[14px] w-[14px] rounded-[2px]' />
</td>
{Array.from({ length: COLUMN_COUNT }).map((_, colIndex) => (
<td key={colIndex} className='px-[12px] py-[10px]'>
<Skeleton
className='h-[14px] rounded-[4px]'
style={{ width: colIndex === 0 ? '128px' : '80px' }}
/>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}

View File

@@ -0,0 +1,37 @@
import type { Metadata } from 'next'
import { redirect } from 'next/navigation'
import { getSession } from '@/lib/auth'
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
import { Agents } from '@/app/workspace/[workspaceId]/agents/agents'
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check'
export const metadata: Metadata = {
title: 'Agents',
}
interface AgentsPageProps {
params: Promise<{
workspaceId: string
}>
}
export default async function AgentsPage({ params }: AgentsPageProps) {
const { workspaceId } = await params
const session = await getSession()
if (!session?.user?.id) {
redirect('/')
}
const hasPermission = await verifyWorkspaceMembership(session.user.id, workspaceId)
if (!hasPermission) {
redirect('/')
}
const permissionConfig = await getUserPermissionConfig(session.user.id)
if (permissionConfig?.hideAgentsTab) {
redirect(`/workspace/${workspaceId}`)
}
return <Agents />
}

View File

@@ -0,0 +1,287 @@
'use client'
import type { ChangeEvent } from 'react'
import { useCallback, useRef, useState } from 'react'
import { Loader2 } from 'lucide-react'
import { Button, Input, Label, Textarea } from '@/components/emcn'
import { Upload } from '@/components/emcn/icons'
import { cn } from '@/lib/core/utils/cn'
import { extractSkillFromZip, parseSkillMarkdown } from './utils'
interface ImportedSkill {
name: string
description: string
content: string
}
interface SkillImportProps {
onImport: (data: ImportedSkill) => void
}
type ImportState = 'idle' | 'loading' | 'error'
const ACCEPTED_EXTENSIONS = ['.md', '.zip']
function isAcceptedFile(file: File): boolean {
const name = file.name.toLowerCase()
return ACCEPTED_EXTENSIONS.some((ext) => name.endsWith(ext))
}
export function SkillImport({ onImport }: SkillImportProps) {
const fileInputRef = useRef<HTMLInputElement>(null)
const [isDragging, setIsDragging] = useState(false)
const [dragCounter, setDragCounter] = useState(0)
const [fileState, setFileState] = useState<ImportState>('idle')
const [fileError, setFileError] = useState('')
const [githubUrl, setGithubUrl] = useState('')
const [githubState, setGithubState] = useState<ImportState>('idle')
const [githubError, setGithubError] = useState('')
const [pasteContent, setPasteContent] = useState('')
const [pasteError, setPasteError] = useState('')
const processFile = useCallback(
async (file: File) => {
if (!isAcceptedFile(file)) {
setFileError('Unsupported file type. Use .md or .zip files.')
setFileState('error')
return
}
setFileState('loading')
setFileError('')
try {
let rawContent: string
if (file.name.toLowerCase().endsWith('.zip')) {
rawContent = await extractSkillFromZip(file)
} else {
rawContent = await file.text()
}
const parsed = parseSkillMarkdown(rawContent)
setFileState('idle')
onImport(parsed)
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to process file'
setFileError(message)
setFileState('error')
}
},
[onImport]
)
const handleFileChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) processFile(file)
if (fileInputRef.current) fileInputRef.current.value = ''
},
[processFile]
)
const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setDragCounter((prev) => {
const next = prev + 1
if (next === 1) setIsDragging(true)
return next
})
}, [])
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setDragCounter((prev) => {
const next = prev - 1
if (next === 0) setIsDragging(false)
return next
})
}, [])
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
e.dataTransfer.dropEffect = 'copy'
}, [])
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
setDragCounter(0)
const file = e.dataTransfer.files?.[0]
if (file) processFile(file)
},
[processFile]
)
const handleGithubImport = useCallback(async () => {
const trimmed = githubUrl.trim()
if (!trimmed) {
setGithubError('Please enter a GitHub URL')
setGithubState('error')
return
}
setGithubState('loading')
setGithubError('')
try {
const res = await fetch('/api/skills/import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: trimmed }),
})
const data = await res.json()
if (!res.ok) {
throw new Error(data.error || `Import failed (HTTP ${res.status})`)
}
const parsed = parseSkillMarkdown(data.content)
setGithubState('idle')
onImport(parsed)
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to import from GitHub'
setGithubError(message)
setGithubState('error')
}
}, [githubUrl, onImport])
const handlePasteImport = useCallback(() => {
const trimmed = pasteContent.trim()
if (!trimmed) {
setPasteError('Please paste some content first')
return
}
setPasteError('')
const parsed = parseSkillMarkdown(trimmed)
onImport(parsed)
}, [pasteContent, onImport])
return (
<div className='flex flex-col gap-[18px]'>
{/* File drop zone */}
<div className='flex flex-col gap-[4px]'>
<Label className='font-medium text-[14px]'>Upload File</Label>
<button
type='button'
onClick={() => fileInputRef.current?.click()}
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
disabled={fileState === 'loading'}
className={cn(
'flex w-full cursor-pointer flex-col items-center justify-center gap-[8px] rounded-[8px] border border-dashed px-[16px] py-[32px] transition-colors',
'border-[var(--border-1)] bg-[var(--surface-1)] hover:bg-[var(--surface-4)]',
isDragging && 'border-[var(--surface-7)] bg-[var(--surface-4)]',
fileState === 'loading' && 'pointer-events-none opacity-60'
)}
>
<input
ref={fileInputRef}
type='file'
accept='.md,.zip'
onChange={handleFileChange}
className='hidden'
/>
{fileState === 'loading' ? (
<Loader2 className='h-[20px] w-[20px] animate-spin text-[var(--text-tertiary)]' />
) : (
<Upload className='h-[20px] w-[20px] text-[var(--text-tertiary)]' />
)}
<div className='flex flex-col gap-[2px] text-center'>
<span className='text-[14px] text-[var(--text-primary)]'>
{isDragging ? 'Drop file here' : 'Drop file here or click to browse'}
</span>
<span className='text-[11px] text-[var(--text-tertiary)]'>
.md file with YAML frontmatter, or .zip containing a SKILL.md
</span>
</div>
</button>
{fileError && <p className='text-[13px] text-[var(--text-error)]'>{fileError}</p>}
</div>
<Divider />
{/* GitHub URL */}
<div className='flex flex-col gap-[4px]'>
<Label htmlFor='skill-github-url' className='font-medium text-[14px]'>
Import from GitHub
</Label>
<div className='flex gap-[8px]'>
<Input
id='skill-github-url'
placeholder='https://github.com/owner/repo/blob/main/SKILL.md'
value={githubUrl}
onChange={(e) => {
setGithubUrl(e.target.value)
if (githubError) setGithubError('')
}}
className='flex-1'
disabled={githubState === 'loading'}
/>
<Button
variant='default'
onClick={handleGithubImport}
disabled={githubState === 'loading' || !githubUrl.trim()}
>
{githubState === 'loading' ? (
<Loader2 className='h-[14px] w-[14px] animate-spin' />
) : (
'Fetch'
)}
</Button>
</div>
{githubError && <p className='text-[13px] text-[var(--text-error)]'>{githubError}</p>}
</div>
<Divider />
{/* Paste content */}
<div className='flex flex-col gap-[4px]'>
<Label htmlFor='skill-paste' className='font-medium text-[14px]'>
Paste SKILL.md Content
</Label>
<Textarea
id='skill-paste'
placeholder={
'---\nname: my-skill\ndescription: What this skill does\n---\n\n# Instructions...'
}
value={pasteContent}
onChange={(e: ChangeEvent<HTMLTextAreaElement>) => {
setPasteContent(e.target.value)
if (pasteError) setPasteError('')
}}
className='min-h-[120px] resize-y font-mono text-[14px]'
/>
{pasteError && <p className='text-[13px] text-[var(--text-error)]'>{pasteError}</p>}
<div className='flex justify-end'>
<Button variant='default' onClick={handlePasteImport} disabled={!pasteContent.trim()}>
Import
</Button>
</div>
</div>
</div>
)
}
function Divider() {
return (
<div className='flex items-center gap-[12px]'>
<div className='h-px flex-1 bg-[var(--border-1)]' />
<span className='text-[12px] text-[var(--text-tertiary)]'>or</span>
<div className='h-px flex-1 bg-[var(--border-1)]' />
</div>
)
}

View File

@@ -1,7 +1,7 @@
'use client'
import type { ChangeEvent } from 'react'
import { useMemo, useState } from 'react'
import { useCallback, useMemo, useState } from 'react'
import { useParams } from 'next/navigation'
import {
Button,
@@ -12,10 +12,15 @@ import {
ModalContent,
ModalFooter,
ModalHeader,
ModalTabs,
ModalTabsContent,
ModalTabsList,
ModalTabsTrigger,
Textarea,
} from '@/components/emcn'
import type { SkillDefinition } from '@/hooks/queries/skills'
import { useCreateSkill, useUpdateSkill } from '@/hooks/queries/skills'
import { SkillImport } from './skill-import'
interface SkillModalProps {
open: boolean
@@ -34,6 +39,8 @@ interface FieldErrors {
general?: string
}
type TabValue = 'create' | 'import'
export function SkillModal({
open,
onOpenChange,
@@ -52,6 +59,7 @@ export function SkillModal({
const [content, setContent] = useState('')
const [errors, setErrors] = useState<FieldErrors>({})
const [saving, setSaving] = useState(false)
const [activeTab, setActiveTab] = useState<TabValue>('create')
const [prevOpen, setPrevOpen] = useState(false)
const [prevInitialValues, setPrevInitialValues] = useState(initialValues)
@@ -60,6 +68,7 @@ export function SkillModal({
setDescription(initialValues?.description ?? '')
setContent(initialValues?.content ?? '')
setErrors({})
setActiveTab('create')
}
if (open !== prevOpen) setPrevOpen(open)
if (initialValues !== prevInitialValues) setPrevInitialValues(initialValues)
@@ -124,97 +133,137 @@ export function SkillModal({
}
}
const handleImport = useCallback(
(data: { name: string; description: string; content: string }) => {
setName(data.name)
setDescription(data.description)
setContent(data.content)
setErrors({})
setActiveTab('create')
},
[]
)
const isEditing = !!initialValues
const createForm = (
<div className='flex flex-col gap-[18px]'>
<div className='flex flex-col gap-[4px]'>
<Label htmlFor='skill-name' className='font-medium text-[14px]'>
Name
</Label>
<Input
id='skill-name'
placeholder='my-skill-name'
value={name}
onChange={(e) => {
setName(e.target.value)
if (errors.name || errors.general)
setErrors((prev) => ({ ...prev, name: undefined, general: undefined }))
}}
/>
{errors.name ? (
<p className='text-[13px] text-[var(--text-error)]'>{errors.name}</p>
) : (
<span className='text-[11px] text-[var(--text-muted)]'>
Lowercase letters, numbers, and hyphens (e.g. my-skill)
</span>
)}
</div>
<div className='flex flex-col gap-[4px]'>
<Label htmlFor='skill-description' className='font-medium text-[14px]'>
Description
</Label>
<Input
id='skill-description'
placeholder='What this skill does and when to use it...'
value={description}
onChange={(e) => {
setDescription(e.target.value)
if (errors.description || errors.general)
setErrors((prev) => ({ ...prev, description: undefined, general: undefined }))
}}
maxLength={1024}
/>
{errors.description && (
<p className='text-[13px] text-[var(--text-error)]'>{errors.description}</p>
)}
</div>
<div className='flex flex-col gap-[4px]'>
<Label htmlFor='skill-content' className='font-medium text-[14px]'>
Content
</Label>
<Textarea
id='skill-content'
placeholder='Skill instructions in markdown...'
value={content}
onChange={(e: ChangeEvent<HTMLTextAreaElement>) => {
setContent(e.target.value)
if (errors.content || errors.general)
setErrors((prev) => ({ ...prev, content: undefined, general: undefined }))
}}
className='min-h-[200px] resize-y font-mono text-[14px]'
/>
{errors.content && <p className='text-[13px] text-[var(--text-error)]'>{errors.content}</p>}
</div>
{errors.general && <p className='text-[13px] text-[var(--text-error)]'>{errors.general}</p>}
</div>
)
const footer = (
<ModalFooter className='items-center justify-between'>
{isEditing && onDelete ? (
<Button variant='destructive' onClick={() => onDelete(initialValues.id)}>
Delete
</Button>
) : (
<div />
)}
<div className='flex gap-2'>
<Button variant='default' onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button variant='primary' onClick={handleSave} disabled={saving || !hasChanges}>
{saving ? 'Saving...' : isEditing ? 'Update' : 'Create'}
</Button>
</div>
</ModalFooter>
)
return (
<Modal open={open} onOpenChange={onOpenChange}>
<ModalContent size='lg'>
<ModalHeader>{initialValues ? 'Edit Skill' : 'Create Skill'}</ModalHeader>
<ModalBody>
<div className='flex flex-col gap-[18px]'>
<div className='flex flex-col gap-[4px]'>
<Label htmlFor='skill-name' className='font-medium text-[14px]'>
Name
</Label>
<Input
id='skill-name'
placeholder='my-skill-name'
value={name}
onChange={(e) => {
setName(e.target.value)
if (errors.name || errors.general)
setErrors((prev) => ({ ...prev, name: undefined, general: undefined }))
}}
/>
{errors.name ? (
<p className='text-[13px] text-[var(--text-error)]'>{errors.name}</p>
) : (
<span className='text-[11px] text-[var(--text-muted)]'>
Lowercase letters, numbers, and hyphens (e.g. my-skill)
</span>
)}
</div>
<div className='flex flex-col gap-[4px]'>
<Label htmlFor='skill-description' className='font-medium text-[14px]'>
Description
</Label>
<Input
id='skill-description'
placeholder='What this skill does and when to use it...'
value={description}
onChange={(e) => {
setDescription(e.target.value)
if (errors.description || errors.general)
setErrors((prev) => ({ ...prev, description: undefined, general: undefined }))
}}
maxLength={1024}
/>
{errors.description && (
<p className='text-[13px] text-[var(--text-error)]'>{errors.description}</p>
)}
</div>
<div className='flex flex-col gap-[4px]'>
<Label htmlFor='skill-content' className='font-medium text-[14px]'>
Content
</Label>
<Textarea
id='skill-content'
placeholder='Skill instructions in markdown...'
value={content}
onChange={(e: ChangeEvent<HTMLTextAreaElement>) => {
setContent(e.target.value)
if (errors.content || errors.general)
setErrors((prev) => ({ ...prev, content: undefined, general: undefined }))
}}
className='min-h-[200px] resize-y font-mono text-[14px]'
/>
{errors.content && (
<p className='text-[13px] text-[var(--text-error)]'>{errors.content}</p>
)}
</div>
{errors.general && (
<p className='text-[13px] text-[var(--text-error)]'>{errors.general}</p>
)}
</div>
</ModalBody>
<ModalFooter className='items-center justify-between'>
{initialValues && onDelete ? (
<Button variant='destructive' onClick={() => onDelete(initialValues.id)}>
Delete
</Button>
) : (
<div />
)}
<div className='flex gap-2'>
<Button variant='default' onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button variant='primary' onClick={handleSave} disabled={saving || !hasChanges}>
{saving ? 'Saving...' : initialValues ? 'Update' : 'Create'}
</Button>
</div>
</ModalFooter>
{isEditing ? (
<>
<ModalHeader>Edit Skill</ModalHeader>
<ModalBody>{createForm}</ModalBody>
{footer}
</>
) : (
<>
<ModalHeader>Add Skill</ModalHeader>
<ModalTabs
value={activeTab}
onValueChange={(v) => setActiveTab(v as TabValue)}
className='flex min-h-0 flex-1 flex-col'
>
<ModalTabsList activeValue={activeTab}>
<ModalTabsTrigger value='create'>Create</ModalTabsTrigger>
<ModalTabsTrigger value='import'>Import</ModalTabsTrigger>
</ModalTabsList>
<ModalBody>
<ModalTabsContent value='create'>{createForm}</ModalTabsContent>
<ModalTabsContent value='import'>
<SkillImport onImport={handleImport} />
</ModalTabsContent>
</ModalBody>
</ModalTabs>
{activeTab === 'create' && footer}
</>
)}
</ModalContent>
</Modal>
)

View File

@@ -0,0 +1,191 @@
/**
* @vitest-environment node
*/
import JSZip from 'jszip'
import { describe, expect, it } from 'vitest'
import { extractSkillFromZip, parseSkillMarkdown } from './utils'
describe('parseSkillMarkdown', () => {
it('parses standard SKILL.md with name, description, and body', () => {
const input = [
'---',
'name: my-skill',
'description: Does something useful',
'---',
'',
'# Instructions',
'Use this skill to do things.',
].join('\n')
expect(parseSkillMarkdown(input)).toEqual({
name: 'my-skill',
description: 'Does something useful',
content: '# Instructions\nUse this skill to do things.',
})
})
it('strips single and double quotes from frontmatter values', () => {
const input = '---\nname: \'my-skill\'\ndescription: "A quoted description"\n---\nBody'
expect(parseSkillMarkdown(input)).toEqual({
name: 'my-skill',
description: 'A quoted description',
content: 'Body',
})
})
it('preserves colons inside description values', () => {
const input = '---\nname: api-tool\ndescription: API key: required for auth\n---\nBody'
expect(parseSkillMarkdown(input)).toEqual({
name: 'api-tool',
description: 'API key: required for auth',
content: 'Body',
})
})
it('ignores unknown frontmatter fields', () => {
const input = '---\nname: x\ndescription: y\nauthor: someone\nversion: 2\n---\nBody'
const result = parseSkillMarkdown(input)
expect(result.name).toBe('x')
expect(result.description).toBe('y')
expect(result.content).toBe('Body')
})
it('infers name from heading when frontmatter has no name field', () => {
const input =
'---\ndescription: A tool for blocks\nargument-hint: <name>\n---\n\n# Add Block Skill\n\nContent here.'
expect(parseSkillMarkdown(input)).toEqual({
name: 'add-block-skill',
description: 'A tool for blocks',
content: '# Add Block Skill\n\nContent here.',
})
})
it('infers name from heading when there is no frontmatter at all', () => {
const input = '# My Cool Tool\n\nSome instructions.'
expect(parseSkillMarkdown(input)).toEqual({
name: 'my-cool-tool',
description: '',
content: '# My Cool Tool\n\nSome instructions.',
})
})
it('returns empty name when there is no frontmatter and no heading', () => {
const input = 'Just some plain text without any structure.'
expect(parseSkillMarkdown(input)).toEqual({
name: '',
description: '',
content: 'Just some plain text without any structure.',
})
})
it('handles empty input', () => {
expect(parseSkillMarkdown('')).toEqual({
name: '',
description: '',
content: '',
})
})
it('handles frontmatter with empty name value', () => {
const input = '---\nname:\ndescription: Has a description\n---\n\n# Fallback Heading\nBody'
const result = parseSkillMarkdown(input)
expect(result.name).toBe('fallback-heading')
expect(result.description).toBe('Has a description')
})
it('handles frontmatter with no body', () => {
const input = '---\nname: solo\ndescription: Just frontmatter\n---'
expect(parseSkillMarkdown(input)).toEqual({
name: 'solo',
description: 'Just frontmatter',
content: '',
})
})
it('handles unclosed frontmatter as plain content', () => {
const input = '---\nname: broken\nno closing delimiter'
const result = parseSkillMarkdown(input)
expect(result.name).toBe('')
expect(result.content).toBe(input)
})
it('trims whitespace from input', () => {
const input = '\n\n ---\nname: trimmed\ndescription: yes\n---\nBody \n\n'
const result = parseSkillMarkdown(input)
expect(result.name).toBe('trimmed')
expect(result.content).toBe('Body')
})
it('truncates inferred heading names to 64 characters', () => {
const longHeading = `# ${'A'.repeat(100)}`
const result = parseSkillMarkdown(longHeading)
expect(result.name.length).toBeLessThanOrEqual(64)
})
it('sanitizes special characters in inferred heading names', () => {
const input = '# Hello, World! (v2) — Updated'
const result = parseSkillMarkdown(input)
expect(result.name).toBe('hello-world-v2-updated')
})
it('handles h2 and h3 headings for name inference', () => {
expect(parseSkillMarkdown('## Sub Heading').name).toBe('sub-heading')
expect(parseSkillMarkdown('### Third Level').name).toBe('third-level')
})
it('does not match h4+ headings for name inference', () => {
expect(parseSkillMarkdown('#### Too Deep').name).toBe('')
})
it('uses first heading even when multiple exist', () => {
const input = '# First\n\n## Second\n\n### Third'
expect(parseSkillMarkdown(input).name).toBe('first')
})
})
describe('extractSkillFromZip', () => {
async function makeZipBuffer(files: Record<string, string>): Promise<Uint8Array> {
const zip = new JSZip()
for (const [path, content] of Object.entries(files)) {
zip.file(path, content)
}
return zip.generateAsync({ type: 'uint8array' })
}
it('extracts SKILL.md at root level', async () => {
const data = await makeZipBuffer({ 'SKILL.md': '---\nname: root\n---\nContent' })
const content = await extractSkillFromZip(data)
expect(content).toBe('---\nname: root\n---\nContent')
})
it('extracts SKILL.md from a nested directory', async () => {
const data = await makeZipBuffer({ 'my-skill/SKILL.md': '---\nname: nested\n---\nBody' })
const content = await extractSkillFromZip(data)
expect(content).toBe('---\nname: nested\n---\nBody')
})
it('prefers the shallowest SKILL.md when multiple exist', async () => {
const data = await makeZipBuffer({
'deep/nested/SKILL.md': 'deep',
'SKILL.md': 'root',
'other/SKILL.md': 'other',
})
const content = await extractSkillFromZip(data)
expect(content).toBe('root')
})
it('throws when no SKILL.md is found', async () => {
const data = await makeZipBuffer({ 'README.md': 'No skill here' })
await expect(extractSkillFromZip(data)).rejects.toThrow('No SKILL.md file found')
})
})

View File

@@ -0,0 +1,112 @@
import JSZip from 'jszip'
interface ParsedSkill {
name: string
description: string
content: string
}
const FRONTMATTER_REGEX = /^---\s*\n([\s\S]*?)\n---\s*\n?([\s\S]*)$/
/**
* Parses a SKILL.md string with optional YAML frontmatter into structured fields.
*
* Expected format:
* ```
* ---
* name: my-skill
* description: What this skill does
* ---
* # Markdown content here...
* ```
*
* If no frontmatter is present, the entire text becomes the content field.
*/
export function parseSkillMarkdown(raw: string): ParsedSkill {
const trimmed = raw.trim()
const match = trimmed.match(FRONTMATTER_REGEX)
if (!match) {
return {
name: inferNameFromHeading(trimmed),
description: '',
content: trimmed,
}
}
const frontmatter = match[1]
const body = (match[2] ?? '').trim()
let name = ''
let description = ''
for (const line of frontmatter.split('\n')) {
const colonIdx = line.indexOf(':')
if (colonIdx === -1) continue
const key = line.slice(0, colonIdx).trim().toLowerCase()
const value = line
.slice(colonIdx + 1)
.trim()
.replace(/^['"]|['"]$/g, '')
if (key === 'name') {
name = value
} else if (key === 'description') {
description = value
}
}
if (!name) {
name = inferNameFromHeading(body)
}
return { name, description, content: body }
}
/**
* Derives a kebab-case name from the first markdown heading (e.g. `# Add Block Skill` -> `add-block-skill`).
*/
function inferNameFromHeading(markdown: string): string {
const headingMatch = markdown.match(/^#{1,3}\s+(.+)$/m)
if (!headingMatch) return ''
return headingMatch[1]
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
.slice(0, 64)
}
/**
* Extracts the SKILL.md content from a ZIP archive.
* Searches for a file named SKILL.md at any depth within the archive.
* Accepts File, Blob, ArrayBuffer, or Uint8Array (anything JSZip supports).
*/
export async function extractSkillFromZip(
data: File | Blob | ArrayBuffer | Uint8Array
): Promise<string> {
const zip = await JSZip.loadAsync(data)
const candidates: string[] = []
zip.forEach((relativePath, entry) => {
if (!entry.dir && relativePath.endsWith('SKILL.md')) {
candidates.push(relativePath)
}
})
if (candidates.length === 0) {
throw new Error('No SKILL.md file found in the ZIP archive')
}
// Prefer the shallowest path (fewest slashes)
candidates.sort((a, b) => {
const depthA = a.split('/').length
const depthB = b.split('/').length
return depthA - depthB
})
const content = await zip.file(candidates[0])!.async('string')
return content
}

View File

@@ -17,6 +17,8 @@ import { generateToolInputSchema, sanitizeToolName } from '@/lib/mcp/workflow-to
import { normalizeInputFormatValue } from '@/lib/workflows/input-format'
import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers'
import type { InputFormatField } from '@/lib/workflows/types'
import { McpServerFormModal } from '@/app/workspace/[workspaceId]/settings/components/mcp/components/mcp-server-form-modal/mcp-server-form-modal'
import { useAllowedMcpDomains, useCreateMcpServer } from '@/hooks/queries/mcp'
import {
useAddWorkflowMcpTool,
useDeleteWorkflowMcpTool,
@@ -26,7 +28,7 @@ import {
type WorkflowMcpServer,
type WorkflowMcpTool,
} from '@/hooks/queries/workflow-mcp-servers'
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
import { useAvailableEnvVarKeys } from '@/hooks/use-available-env-vars'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
@@ -100,7 +102,11 @@ export function McpDeploy({
}: McpDeployProps) {
const params = useParams()
const workspaceId = params.workspaceId as string
const { navigateToSettings } = useSettingsNavigation()
const [showMcpModal, setShowMcpModal] = useState(false)
const createMcpServer = useCreateMcpServer()
const { data: allowedMcpDomains = null } = useAllowedMcpDomains()
const availableEnvVars = useAvailableEnvVarKeys(workspaceId)
const { data: servers = [], isLoading: isLoadingServers } = useWorkflowMcpServers(workspaceId)
const addToolMutation = useAddWorkflowMcpTool()
@@ -464,17 +470,27 @@ export function McpDeploy({
if (servers.length === 0) {
return (
<div className='flex h-full flex-col items-center justify-center gap-3'>
<p className='text-[13px] text-[var(--text-muted)]'>
Create an MCP Server in Settings MCP Servers first.
</p>
<Button
variant='tertiary'
onClick={() => navigateToSettings({ section: 'workflow-mcp-servers' })}
>
Create MCP Server
</Button>
</div>
<>
<div className='flex h-full flex-col items-center justify-center gap-3'>
<p className='text-[13px] text-[var(--text-muted)]'>
Create an MCP Server in Settings MCP Servers first.
</p>
<Button variant='tertiary' onClick={() => setShowMcpModal(true)}>
Create MCP Server
</Button>
</div>
<McpServerFormModal
open={showMcpModal}
onOpenChange={setShowMcpModal}
mode='add'
onSubmit={async (config) => {
await createMcpServer.mutateAsync({ workspaceId, config: { ...config, enabled: true } })
}}
workspaceId={workspaceId}
availableEnvVars={availableEnvVars}
allowedMcpDomains={allowedMcpDomains}
/>
</>
)
}

View File

@@ -7,7 +7,6 @@ import { Button, Combobox } from '@/components/emcn/components'
import { getSubscriptionStatus } from '@/lib/billing/client'
import { getEnv, isTruthy } from '@/lib/core/config/env'
import { getPollingProviderFromOAuth } from '@/lib/credential-sets/providers'
import { writePendingCredentialCreateRequest } from '@/lib/credentials/client-state'
import {
getCanonicalScopesForProvider,
getProviderIdFromServiceId,
@@ -26,7 +25,6 @@ import { useOAuthCredentials } from '@/hooks/queries/oauth/oauth-credentials'
import { useOrganizations } from '@/hooks/queries/organization'
import { useSubscriptionData } from '@/hooks/queries/subscription'
import { useCredentialRefreshTriggers } from '@/hooks/use-credential-refresh-triggers'
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
const isBillingEnabled = isTruthy(getEnv('NEXT_PUBLIC_BILLING_ENABLED'))
@@ -53,7 +51,10 @@ export function CredentialSelector({
const [showOAuthModal, setShowOAuthModal] = useState(false)
const [editingValue, setEditingValue] = useState('')
const [isEditing, setIsEditing] = useState(false)
const { activeWorkflowId } = useWorkflowRegistry()
const { activeWorkflowId, workflows } = useWorkflowRegistry()
// Only pass workflowId when it's a real registered workflow (not an agent ID set as the active context)
const effectiveWorkflowId =
activeWorkflowId && workflows[activeWorkflowId] ? activeWorkflowId : undefined
const [storeValue, setStoreValue] = useSubBlockValue<string | null>(blockId, subBlock.id)
const requiredScopes = subBlock.requiredScopes || []
@@ -103,7 +104,7 @@ export function CredentialSelector({
} = useOAuthCredentials(effectiveProviderId, {
enabled: Boolean(effectiveProviderId),
workspaceId,
workflowId: activeWorkflowId || undefined,
workflowId: effectiveWorkflowId,
})
const selectedCredential = useMemo(
@@ -157,7 +158,6 @@ export function CredentialSelector({
const displayValue = isEditing ? editingValue : resolvedLabel
useCredentialRefreshTriggers(refetchCredentials, effectiveProviderId, workspaceId)
const { navigateToSettings } = useSettingsNavigation()
const handleOpenChange = useCallback(
(isOpen: boolean) => {
@@ -199,21 +199,8 @@ export function CredentialSelector({
)
const handleAddCredential = useCallback(() => {
writePendingCredentialCreateRequest({
workspaceId,
type: 'oauth',
providerId: effectiveProviderId,
displayName: '',
serviceId,
requiredScopes: getCanonicalScopesForProvider(effectiveProviderId),
requestedAt: Date.now(),
returnOrigin: activeWorkflowId
? { type: 'workflow', workflowId: activeWorkflowId }
: undefined,
})
navigateToSettings({ section: 'integrations' })
}, [workspaceId, effectiveProviderId, serviceId, activeWorkflowId])
setShowOAuthModal(true)
}, [])
const getProviderIcon = useCallback((providerName: OAuthProvider) => {
const { baseProvider } = parseProvider(providerName)

View File

@@ -2,7 +2,6 @@ import { createElement, useCallback, useEffect, useMemo, useRef, useState } from
import { ExternalLink } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Button, Combobox } from '@/components/emcn/components'
import { writePendingCredentialCreateRequest } from '@/lib/credentials/client-state'
import {
getCanonicalScopesForProvider,
getProviderIdFromServiceId,
@@ -16,7 +15,6 @@ import { getMissingRequiredScopes } from '@/lib/oauth/utils'
import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal'
import { useOAuthCredentials } from '@/hooks/queries/oauth/oauth-credentials'
import { useCredentialRefreshTriggers } from '@/hooks/use-credential-refresh-triggers'
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
const getProviderIcon = (providerName: OAuthProvider) => {
@@ -74,8 +72,10 @@ export function ToolCredentialSelector({
const [showOAuthModal, setShowOAuthModal] = useState(false)
const [editingInputValue, setEditingInputValue] = useState('')
const [isEditing, setIsEditing] = useState(false)
const { activeWorkflowId } = useWorkflowRegistry()
const { navigateToSettings } = useSettingsNavigation()
const { activeWorkflowId, workflows } = useWorkflowRegistry()
// Only pass workflowId when it's a real registered workflow (not an agent ID set as the active context)
const effectiveWorkflowId =
activeWorkflowId && workflows[activeWorkflowId] ? activeWorkflowId : undefined
const selectedId = value || ''
const effectiveLabel = label || `Select ${getProviderName(provider)} account`
@@ -89,7 +89,7 @@ export function ToolCredentialSelector({
} = useOAuthCredentials(effectiveProviderId, {
enabled: Boolean(effectiveProviderId),
workspaceId,
workflowId: activeWorkflowId || undefined,
workflowId: effectiveWorkflowId,
})
const selectedCredential = useMemo(
@@ -164,18 +164,8 @@ export function ToolCredentialSelector({
)
const handleAddCredential = useCallback(() => {
writePendingCredentialCreateRequest({
workspaceId,
type: 'oauth',
providerId: effectiveProviderId,
displayName: '',
serviceId,
requiredScopes: getCanonicalScopesForProvider(effectiveProviderId),
requestedAt: Date.now(),
})
navigateToSettings({ section: 'integrations' })
}, [workspaceId, effectiveProviderId, serviceId])
setShowOAuthModal(true)
}, [])
const comboboxOptions = useMemo(() => {
const options = credentials.map((cred) => ({

View File

@@ -27,6 +27,7 @@ import type { McpToolSchema } from '@/lib/mcp/types'
import { getProviderIdFromServiceId, type OAuthProvider, type OAuthService } from '@/lib/oauth'
import { extractInputFieldsFromBlocks } from '@/lib/workflows/input-format'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { McpServerFormModal } from '@/app/workspace/[workspaceId]/settings/components/mcp/components/mcp-server-form-modal/mcp-server-form-modal'
import {
LongInput,
ShortInput,
@@ -48,6 +49,7 @@ import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/c
import type { WandControlHandlers } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block'
import { getAllBlocks } from '@/blocks'
import type { SubBlockConfig as BlockSubBlockConfig } from '@/blocks/types'
import { BUILT_IN_TOOL_TYPES } from '@/blocks/utils'
import { useMcpTools } from '@/hooks/mcp/use-mcp-tools'
import {
type CustomTool as CustomToolDefinition,
@@ -55,12 +57,15 @@ import {
} from '@/hooks/queries/custom-tools'
import { useDeploymentInfo, useDeployWorkflow } from '@/hooks/queries/deployments'
import {
useAllowedMcpDomains,
useCreateMcpServer,
useForceRefreshMcpTools,
useMcpServers,
useMcpToolsEvents,
useStoredMcpTools,
} from '@/hooks/queries/mcp'
import { useWorkflowState, useWorkflows } from '@/hooks/queries/workflows'
import { useAvailableEnvVarKeys } from '@/hooks/use-available-env-vars'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { usePermissionConfig } from '@/hooks/use-permission-config'
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
@@ -330,24 +335,6 @@ function resolveCustomToolFromReference(
* These are distinguished from third-party integrations for categorization
* in the tool selection dropdown.
*/
const BUILT_IN_TOOL_TYPES = new Set([
'api',
'file',
'function',
'knowledge',
'search',
'thinking',
'image_generator',
'video_generator',
'vision',
'translate',
'tts',
'stt',
'memory',
'table',
'webhook_request',
'workflow',
])
/**
* Checks if a block supports multiple operations.
@@ -469,6 +456,7 @@ export const ToolInput = memo(function ToolInput({
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlockId)
const [open, setOpen] = useState(false)
const [customToolModalOpen, setCustomToolModalOpen] = useState(false)
const [mcpModalOpen, setMcpModalOpen] = useState(false)
const [editingToolIndex, setEditingToolIndex] = useState<number | null>(null)
const [draggedIndex, setDraggedIndex] = useState<number | null>(null)
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null)
@@ -507,6 +495,9 @@ export const ToolInput = memo(function ToolInput({
const forceRefreshMcpTools = useForceRefreshMcpTools()
useMcpToolsEvents(workspaceId)
const { navigateToSettings } = useSettingsNavigation()
const createMcpServer = useCreateMcpServer()
const { data: allowedMcpDomains = null } = useAllowedMcpDomains()
const availableEnvVars = useAvailableEnvVarKeys(workspaceId)
const mcpDataLoading = mcpLoading || mcpServersLoading
const { data: workflowsList = [] } = useWorkflows(workspaceId, { syncRegistry: false })
@@ -1379,7 +1370,7 @@ export const ToolInput = memo(function ToolInput({
icon: McpIcon,
onSelect: () => {
setOpen(false)
navigateToSettings({ section: 'mcp' })
setMcpModalOpen(true)
},
disabled: isPreview,
})
@@ -2095,6 +2086,18 @@ export const ToolInput = memo(function ToolInput({
: undefined
}
/>
<McpServerFormModal
open={mcpModalOpen}
onOpenChange={setMcpModalOpen}
mode='add'
onSubmit={async (config) => {
await createMcpServer.mutateAsync({ workspaceId, config: { ...config, enabled: true } })
}}
workspaceId={workspaceId}
availableEnvVars={availableEnvVars}
allowedMcpDomains={allowedMcpDomains}
/>
</div>
)
})

View File

@@ -32,7 +32,9 @@ import {
Settings,
Sim,
Table,
Wordmark,
} from '@/components/emcn/icons'
import { AgentIcon } from '@/components/icons'
import { useSession } from '@/lib/auth/auth-client'
import { cn } from '@/lib/core/utils/cn'
import { ConversationListItem } from '@/app/workspace/[workspaceId]/components'
@@ -197,7 +199,6 @@ const SidebarNavItem = memo(function SidebarNavItem({
const Icon = item.icon
const baseClasses =
'group flex h-[30px] items-center gap-[8px] rounded-[8px] mx-[2px] px-[8px] text-[14px] hover:bg-[var(--surface-active)]'
const activeClasses = active ? 'bg-[var(--surface-active)]' : ''
const content = (
<>
@@ -210,7 +211,7 @@ const SidebarNavItem = memo(function SidebarNavItem({
<Link
href={item.href}
data-item-id={item.id}
className={`${baseClasses} ${activeClasses}`}
className={cn(baseClasses, active && 'bg-[var(--surface-active)]')}
onClick={
item.onClick
? (e) => {
@@ -228,7 +229,7 @@ const SidebarNavItem = memo(function SidebarNavItem({
<button
type='button'
data-item-id={item.id}
className={`${baseClasses} ${activeClasses}`}
className={cn(baseClasses, active && 'bg-[var(--surface-active)]')}
onClick={item.onClick}
>
{content}
@@ -274,7 +275,7 @@ export const Sidebar = memo(function Sidebar() {
const fileInputRef = useRef<HTMLInputElement>(null)
const scrollContainerRef = useRef<HTMLDivElement>(null)
const { data: sessionData, isPending: sessionLoading } = useSession()
const { data: sessionData } = useSession()
const { canEdit } = useUserPermissionsContext()
const { config: permissionConfig, filterBlocks } = usePermissionConfig()
const { navigateToSettings, getSettingsHref } = useSettingsNavigation()
@@ -552,6 +553,13 @@ export const Sidebar = memo(function Sidebar() {
const workspaceNavItems = useMemo(
() =>
[
{
id: 'agents',
label: 'Agents',
icon: AgentIcon,
href: `/workspace/${workspaceId}/agents`,
hidden: permissionConfig.hideAgentsTab,
},
{
id: 'tables',
label: 'Tables',
@@ -588,6 +596,7 @@ export const Sidebar = memo(function Sidebar() {
].filter((item) => !item.hidden),
[
workspaceId,
permissionConfig.hideAgentsTab,
permissionConfig.hideKnowledgeBaseTab,
permissionConfig.hideTablesTab,
permissionConfig.hideFilesTab,
@@ -615,7 +624,7 @@ export const Sidebar = memo(function Sidebar() {
},
},
],
[workspaceId, navigateToSettings, getSettingsHref, isCollapsed, setSidebarWidth]
[navigateToSettings, getSettingsHref, isCollapsed, setSidebarWidth]
)
const { data: fetchedTasks = [], isLoading: tasksLoading } = useTasks(workspaceId)
@@ -785,7 +794,6 @@ export const Sidebar = memo(function Sidebar() {
const isOnSettingsPage = pathname?.startsWith(`/workspace/${workspaceId}/settings`) ?? false
const isLoading = workflowsLoading || sessionLoading
const initialScrollDoneRef = useRef(false)
useEffect(() => {
@@ -900,25 +908,11 @@ export const Sidebar = memo(function Sidebar() {
const zipFile = files[0]
await importWorkspace(zipFile)
if (event.target) {
event.target.value = ''
}
event.target.value = ''
},
[importWorkspace]
)
const resolveWorkspaceIdFromPath = useCallback((): string | undefined => {
if (workspaceId) return workspaceId
if (typeof window === 'undefined') return undefined
const parts = window.location.pathname.split('/')
const idx = parts.indexOf('workspace')
if (idx === -1) return undefined
return parts[idx + 1]
}, [workspaceId])
useRegisterGlobalCommands(() =>
createCommands([
{
@@ -935,30 +929,13 @@ export const Sidebar = memo(function Sidebar() {
}
},
},
// {
// id: 'goto-templates',
// handler: () => {
// try {
// const pathWorkspaceId = resolveWorkspaceIdFromPath()
// if (pathWorkspaceId) {
// navigateToPage(`/workspace/${pathWorkspaceId}/templates`)
// logger.info('Navigated to templates', { workspaceId: pathWorkspaceId })
// } else {
// logger.warn('No workspace ID found, cannot navigate to templates')
// }
// } catch (err) {
// logger.error('Failed to navigate to templates', { err })
// }
// },
// },
{
id: 'goto-logs',
handler: () => {
try {
const pathWorkspaceId = resolveWorkspaceIdFromPath()
if (pathWorkspaceId) {
navigateToPage(`/workspace/${pathWorkspaceId}/logs`)
logger.info('Navigated to logs', { workspaceId: pathWorkspaceId })
if (workspaceId) {
navigateToPage(`/workspace/${workspaceId}/logs`)
logger.info('Navigated to logs', { workspaceId })
} else {
logger.warn('No workspace ID found, cannot navigate to logs')
}
@@ -1017,7 +994,7 @@ export const Sidebar = memo(function Sidebar() {
) : (
<Link
href={`/workspace/${workspaceId}/home`}
className='flex h-[30px] w-[30px] items-center justify-center rounded-[8px] hover:bg-[var(--surface-active)]'
className='flex h-[30px] items-center rounded-[8px] px-[6px] hover:bg-[var(--surface-active)]'
>
{brand.logoUrl ? (
<Image
@@ -1029,7 +1006,7 @@ export const Sidebar = memo(function Sidebar() {
unoptimized
/>
) : (
<Sim className='h-[16px] w-[16px] text-[var(--text-icon)]' />
<Wordmark className='h-[16px] w-auto text-[var(--text-body)]' />
)}
</Link>
)}
@@ -1369,7 +1346,7 @@ export const Sidebar = memo(function Sidebar() {
workspaceId={workspaceId}
workflowId={workflowId}
regularWorkflows={regularWorkflows}
isLoading={isLoading}
isLoading={workflowsLoading}
canReorder={canEdit}
handleFileChange={handleImportFileChange}
fileInputRef={fileInputRef}

View File

@@ -47,6 +47,30 @@ export function getDependsOnFields(dependsOn: SubBlockConfig['dependsOn']): stri
return [...(dependsOn.all || []), ...(dependsOn.any || [])]
}
/**
* Block types that are surfaced as "built-in tools" in the agent tool picker,
* as opposed to third-party integration blocks.
* Shared between the workflow tool-input and the standalone agent tool picker.
*/
export const BUILT_IN_TOOL_TYPES = new Set([
'api',
'file',
'function',
'knowledge',
'search',
'thinking',
'image_generator',
'video_generator',
'vision',
'translate',
'tts',
'stt',
'memory',
'table',
'webhook_request',
'workflow',
])
export function resolveOutputType(
outputs: Record<string, OutputFieldDefinition>
): Record<string, BlockOutput> {
@@ -80,7 +104,7 @@ function buildModelVisibilityCondition(model: string, shouldShow: boolean) {
return shouldShow ? { field: 'model', value: model } : { field: 'model', value: model, not: true }
}
function shouldRequireApiKeyForModel(model: string): boolean {
export function shouldRequireApiKeyForModel(model: string): boolean {
const normalizedModel = model.trim().toLowerCase()
if (!normalizedModel) return false

View File

@@ -0,0 +1,29 @@
import type { SVGProps } from 'react'
/**
* Bot icon component - robot head with antenna and circuit details
* @param props - SVG properties including className, fill, etc.
*/
export function Bot(props: SVGProps<SVGSVGElement>) {
return (
<svg
width='24'
height='24'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='1.75'
strokeLinecap='round'
strokeLinejoin='round'
xmlns='http://www.w3.org/2000/svg'
{...props}
>
<path d='M12 8V4h0' />
<rect width='16' height='12' x='4' y='8' rx='2' />
<path d='M2 14h2' />
<path d='M20 14h2' />
<path d='M15 13v2' />
<path d='M9 13v2' />
</svg>
)
}

View File

@@ -7,6 +7,7 @@ export { Asterisk } from './asterisk'
export { Bell } from './bell'
export { Blimp } from './blimp'
export { BookOpen } from './book-open'
export { Bot } from './bot'
export { BubbleChatClose } from './bubble-chat-close'
export { BubbleChatPreview } from './bubble-chat-preview'
export { Bug } from './bug'
@@ -84,6 +85,7 @@ export { Upload } from './upload'
export { User } from './user'
export { UserPlus } from './user-plus'
export { Users } from './users'
export { Wordmark } from './wordmark'
export { WorkflowX } from './workflow-x'
export { Wrap } from './wrap'
export { Wrench } from './wrench'

View File

@@ -0,0 +1,55 @@
import type { SVGProps } from 'react'
/**
* Sim brand wordmark — icon (green) + "Sim" text as a single SVG.
* Use when expanded; use the plain `Sim` icon when collapsed.
* @param props - SVG properties including className, style, etc.
*/
export function Wordmark(props: SVGProps<SVGSVGElement>) {
return (
<svg
fill='none'
height='22'
viewBox='0 0 71 22'
width='71'
xmlns='http://www.w3.org/2000/svg'
{...props}
>
<g transform='scale(.07483)'>
<path
clipRule='evenodd'
d='m142.793 124.175c0 4.75-1.88 9.312-5.216 12.671l-.478.481c-3.334 3.369-7.863 5.252-12.58 5.252h-106.7127c-9.82776 0-17.8063 8.026-17.8063 17.924v115.407c0 9.898 7.97854 17.924 17.8063 17.924h114.5767c9.828 0 17.796-8.026 17.796-17.924v-108.052c0-4.405 1.735-8.632 4.83-11.749 3.086-3.108 7.283-4.856 11.657-4.856h108.5c9.828 0 17.796-8.024 17.796-17.923v-115.4069c0-9.89798-7.968-17.9231-17.796-17.9231h-114.578c-9.827 0-17.795 8.02512-17.795 17.9231zm34.771-99.6079h80.617c5.744 0 10.389 4.6874 10.389 10.463v81.1939c0 5.774-4.645 10.463-10.389 10.463h-80.617c-5.734 0-10.389-4.689-10.389-10.463v-81.1939c0-5.7756 4.655-10.463 10.389-10.463z'
fill='#33c482'
fillRule='evenodd'
/>
<path
d='m275.293 171.578h-85.187c-10.327 0-18.7 8.432-18.7 18.834v84.75c0 10.402 8.373 18.834 18.7 18.834h85.187c10.328 0 18.701-8.432 18.701-18.834v-84.75c0-10.402-8.373-18.834-18.701-18.834z'
fill='#33c482'
/>
<path
d='m275.293 171.18h-85.187c-10.327 0-18.7 8.432-18.7 18.834v84.749c0 10.402 8.373 18.833 18.7 18.833h85.187c10.328 0 18.701-8.431 18.701-18.833v-84.749c0-10.402-8.373-18.834-18.701-18.834z'
fill='url(#sim-wordmark-gradient)'
fillOpacity='.2'
/>
<defs>
<linearGradient
id='sim-wordmark-gradient'
gradientUnits='userSpaceOnUse'
x1='171.406'
x2='245.831'
y1='171.18'
y2='245.428'
>
<stop offset='0' />
<stop offset='1' stopOpacity='0' />
</linearGradient>
</defs>
</g>
<g fill='currentColor'>
<path d='m31.5718 15.845h2.5865c0 .7141.2586 1.2835.7759 1.7081.5173.4053 1.2166.608 2.0979.608.958 0 1.6956-.1834 2.2129-.5501.5173-.386.776-.8975.776-1.5344 0-.4632-.1437-.8492-.4311-1.158-.2682-.3088-.7664-.5597-1.4944-.7527l-2.4716-.579c-1.2453-.3088-2.1745-.7817-2.7876-1.4186-.594-.6369-.8909-1.4765-.8909-2.51873 0-.86852.2203-1.62124.661-2.25815.4598-.63692 1.0825-1.12908 1.868-1.47648.8047-.34741 1.7243-.52112 2.7589-.52112s1.9255.18336 2.6727.55007c.7664.3667 1.3603.87817 1.7818 1.53438.4407.65622.6706 1.43788.6898 2.345h-2.5865c-.0192-.73341-.2587-1.30278-.7185-1.70809-.4598-.4053-1.1017-.60796-1.9255-.60796-.843 0-1.4944.18336-1.9542.55006-.4599.36671-.6898.86852-.6898 1.50544 0 .94568.6898 1.59228 2.0692 1.93968l2.4716.608c1.1878.2702 2.0787.7141 2.6727 1.3317.5939.5983.8909 1.4186.8909 2.4608 0 .8878-.2395 1.6695-.7185 2.345-.479.6562-1.14 1.1677-1.983 1.5344-.8238.3474-1.8009.5211-2.9313.5211-1.6477 0-2.9601-.4053-3.9372-1.2159-.9772-.8106-1.4657-1.8915-1.4657-3.2425z' />
<path d='m44.5096 19.956v-14.15687c1.0772.39383 1.5521.39383 2.7014 0v14.15687zm1.322-15.09268c-.479 0-.9005-.1737-1.2645-.52111-.3449-.36671-.5173-.79132-.5173-1.27383 0-.50181.1724-.92642.5173-1.27383.364-.34741.7855-.52111 1.2645-.52111.4981 0 .9196.1737 1.2645.52111s.5173.77202.5173 1.27383c0 .48251-.1724.90712-.5173 1.27383-.3449.34741-.7664.52111-1.2645.52111z' />
<path d='m51.976 19.956h-2.7014v-14.15687h2.4141v2.38865c.2873-.79131.843-1.46223 1.6093-1.98334.7855-.54041 1.7339-.81062 2.8452-.81062 1.2453 0 2.2799.33776 3.1038 1.01328.8238.67551 1.3603 1.57298 1.6093 2.69241h-.4885c.1916-1.11943.7184-2.0169 1.5806-2.69241.8622-.67552 1.9255-1.01328 3.19-1.01328 1.6094 0 2.8739.47286 3.7935 1.41858.9197.94573 1.3795 2.23886 1.3795 3.8794v9.2642h-2.644v-8.5983c0-1.1195-.2874-1.97834-.8621-2.57665-.5557-.61761-1.3125-.92642-2.2704-.92642-.6706 0-1.2645.1544-1.7818.46321-.4982.28951-.8909.71412-1.1783 1.27383-.2874.55973-.4311 1.21593-.4311 1.96863v8.3957h-2.6727v-8.6273c0-1.1194-.2778-1.96864-.8334-2.54765-.5556-.59831-1.3124-.89747-2.2704-.89747-.6706 0-1.2645.1544-1.7818.46321-.4981.28951-.8909.71412-1.1783 1.27383-.2874.54038-.4311 1.18698-.4311 1.93968z' />
</g>
</svg>
)
}

View File

@@ -61,6 +61,10 @@ export interface ToolInput {
operation?: string
/** Database ID for custom tools (new reference format) */
customToolId?: string
/** Direct tool ID for execution */
toolId?: string
/** Whether the tool details are expanded in the UI */
isExpanded?: boolean
}
export interface Message {

View File

@@ -0,0 +1,249 @@
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import type {
AgentConfig,
AgentDeploymentRow,
AgentRow,
SlackDeploymentConfig,
} from '@/lib/agents/types'
export type AgentQueryScope = 'active' | 'archived' | 'all'
export interface Agent extends AgentRow {
deployments?: AgentDeploymentRow[]
}
export interface CreateAgentParams {
workspaceId: string
name: string
description?: string
config?: Partial<AgentConfig>
}
export interface UpdateAgentParams {
agentId: string
name?: string
description?: string
config?: Partial<AgentConfig>
isDeployed?: boolean
}
export interface DeployAgentToSlackParams {
agentId: string
credentialId: string
channelIds: string[]
respondTo: SlackDeploymentConfig['respondTo']
botName?: string
replyInThread?: boolean
}
export const agentKeys = {
all: ['agents'] as const,
lists: () => [...agentKeys.all, 'list'] as const,
list: (workspaceId: string, scope: AgentQueryScope = 'active') =>
[...agentKeys.lists(), workspaceId, scope] as const,
details: () => [...agentKeys.all, 'detail'] as const,
detail: (agentId: string) => [...agentKeys.details(), agentId] as const,
slackChannels: (credentialId: string) =>
[...agentKeys.all, 'slack-channels', credentialId] as const,
}
async function fetchAgents(
workspaceId: string,
scope: AgentQueryScope,
signal?: AbortSignal
): Promise<Agent[]> {
const res = await fetch(
`/api/agents?workspaceId=${encodeURIComponent(workspaceId)}&scope=${scope}`,
{ signal }
)
if (!res.ok) {
const error = await res.json().catch(() => ({}))
throw new Error(error.error || 'Failed to fetch agents')
}
const json = await res.json()
return json.data
}
async function fetchAgent(agentId: string, signal?: AbortSignal): Promise<Agent> {
const res = await fetch(`/api/agents/${agentId}`, { signal })
if (!res.ok) {
const error = await res.json().catch(() => ({}))
throw new Error(error.error || 'Failed to fetch agent')
}
const json = await res.json()
return json.data
}
/**
* List all agents for a workspace.
*/
export function useAgentsList(workspaceId: string, scope: AgentQueryScope = 'active') {
return useQuery({
queryKey: agentKeys.list(workspaceId, scope),
queryFn: ({ signal }) => fetchAgents(workspaceId, scope, signal),
enabled: Boolean(workspaceId),
staleTime: 60 * 1000,
placeholderData: keepPreviousData,
})
}
/**
* Fetch a single agent by ID (includes deployments).
*/
export function useAgent(agentId: string) {
return useQuery({
queryKey: agentKeys.detail(agentId),
queryFn: ({ signal }) => fetchAgent(agentId, signal),
enabled: Boolean(agentId),
staleTime: 30 * 1000,
})
}
/**
* Create a new agent.
*/
export function useCreateAgent() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (params: CreateAgentParams): Promise<Agent> => {
const res = await fetch('/api/agents', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(params),
})
if (!res.ok) {
const error = await res.json().catch(() => ({}))
throw new Error(error.error || 'Failed to create agent')
}
const json = await res.json()
return json.data
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: agentKeys.lists() })
},
})
}
/**
* Update an agent's name, description, or config.
*/
export function useUpdateAgent() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ agentId, ...body }: UpdateAgentParams): Promise<Agent> => {
const res = await fetch(`/api/agents/${agentId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (!res.ok) {
const error = await res.json().catch(() => ({}))
throw new Error(error.error || 'Failed to update agent')
}
const json = await res.json()
return json.data
},
onSettled: (_data, _error, variables) => {
queryClient.invalidateQueries({ queryKey: agentKeys.lists() })
queryClient.invalidateQueries({ queryKey: agentKeys.detail(variables.agentId) })
},
})
}
/**
* Soft-delete an agent.
*/
export function useDeleteAgent() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ agentId }: { agentId: string }): Promise<void> => {
const res = await fetch(`/api/agents/${agentId}`, { method: 'DELETE' })
if (!res.ok) {
const error = await res.json().catch(() => ({}))
throw new Error(error.error || 'Failed to delete agent')
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: agentKeys.lists() })
},
})
}
/**
* Configure a Slack deployment for an agent.
*/
export function useDeployAgentToSlack() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({
agentId,
...body
}: DeployAgentToSlackParams): Promise<AgentDeploymentRow> => {
const res = await fetch(`/api/agents/${agentId}/deployments/slack`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (!res.ok) {
const error = await res.json().catch(() => ({}))
throw new Error(error.error || 'Failed to deploy agent to Slack')
}
const json = await res.json()
return json.data
},
onSettled: (_data, _error, variables) => {
queryClient.invalidateQueries({ queryKey: agentKeys.detail(variables.agentId) })
queryClient.invalidateQueries({ queryKey: agentKeys.lists() })
},
})
}
export interface SlackChannel {
id: string
name: string
isPrivate: boolean
}
/**
* Fetch accessible Slack channels for a given Slack OAuth credential.
*/
export function useSlackChannels(credentialId: string) {
return useQuery({
queryKey: agentKeys.slackChannels(credentialId),
queryFn: async ({ signal }) => {
const res = await fetch('/api/tools/slack/channels', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ credential: credentialId }),
signal,
})
if (!res.ok) return [] as SlackChannel[]
const data = (await res.json()) as { channels?: SlackChannel[] }
return data.channels ?? []
},
enabled: Boolean(credentialId),
staleTime: 60 * 1000,
})
}
/**
* Remove the Slack deployment from an agent.
*/
export function useUndeployAgentFromSlack() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ agentId }: { agentId: string }): Promise<void> => {
const res = await fetch(`/api/agents/${agentId}/deployments/slack`, {
method: 'DELETE',
})
if (!res.ok) {
const error = await res.json().catch(() => ({}))
throw new Error(error.error || 'Failed to undeploy agent from Slack')
}
},
onSettled: (_data, _error, variables) => {
queryClient.invalidateQueries({ queryKey: agentKeys.detail(variables.agentId) })
queryClient.invalidateQueries({ queryKey: agentKeys.lists() })
},
})
}

View File

@@ -0,0 +1,114 @@
import { createLogger } from '@sim/logger'
import { v4 as uuidv4 } from 'uuid'
import type { AgentConfig } from '@/lib/agents/types'
import type { BlockOutput } from '@/blocks/types'
import { BlockType } from '@/executor/constants'
import { AgentBlockHandler } from '@/executor/handlers/agent/agent-handler'
import type { AgentInputs } from '@/executor/handlers/agent/types'
import type { ExecutionContext, StreamingExecution } from '@/executor/types'
import type { SerializedBlock } from '@/serializer/types'
const logger = createLogger('AgentExecutor')
const handler = new AgentBlockHandler()
/**
* Minimal synthetic block that satisfies AgentBlockHandler.canHandle().
*/
const SYNTHETIC_AGENT_BLOCK: SerializedBlock = {
id: 'agent',
position: { x: 0, y: 0 },
metadata: { id: BlockType.AGENT },
config: { tool: BlockType.AGENT, params: {} },
inputs: {},
outputs: {},
enabled: true,
}
export interface ExecuteAgentOptions {
/** The stored agent configuration */
config: AgentConfig
/** User message to inject */
message: string
/** Memory conversation ID (namespaced by caller) */
conversationId?: string
/** IDs for execution context */
agentId: string
workspaceId: string
userId?: string
/** Whether this is an authenticated deployment (API/Slack) vs. UI test */
isDeployedContext?: boolean
/** AbortSignal for cancellation */
abortSignal?: AbortSignal
}
/**
* Executes a standalone agent by constructing an ExecutionContext and
* AgentInputs from the stored config, then calling AgentBlockHandler directly.
*/
export async function executeAgent(
options: ExecuteAgentOptions
): Promise<BlockOutput | StreamingExecution> {
const {
config,
message,
conversationId,
agentId,
workspaceId,
userId,
isDeployedContext = false,
abortSignal,
} = options
const executionId = uuidv4()
const ctx: ExecutionContext = {
workflowId: agentId,
workspaceId,
executionId,
userId,
isDeployedContext,
blockStates: new Map(),
executedBlocks: new Set(),
blockLogs: [],
metadata: { duration: 0, startTime: new Date().toISOString() },
environmentVariables: {},
decisions: { router: new Map(), condition: new Map() },
completedLoops: new Set(),
activeExecutionPath: new Set(),
abortSignal,
}
const existingMessages = config.messages ?? []
const messages = [...existingMessages, { role: 'user' as const, content: message }]
const inputs: AgentInputs = {
messages,
model: config.model,
tools: config.tools,
skills: config.skills,
memoryType: config.memoryType,
conversationId: conversationId ?? config.conversationId,
slidingWindowSize: config.slidingWindowSize,
slidingWindowTokens: config.slidingWindowTokens,
temperature: config.temperature?.toString(),
maxTokens: config.maxTokens?.toString(),
responseFormat: config.responseFormat,
apiKey: config.apiKey,
reasoningEffort: config.reasoningEffort,
verbosity: config.verbosity,
thinkingLevel: config.thinkingLevel,
azureEndpoint: config.azureEndpoint,
azureApiVersion: config.azureApiVersion,
vertexProject: config.vertexProject,
vertexLocation: config.vertexLocation,
vertexCredential: config.vertexCredential,
bedrockAccessKeyId: config.bedrockAccessKeyId,
bedrockSecretKey: config.bedrockSecretKey,
bedrockRegion: config.bedrockRegion,
}
logger.info(`Executing agent ${agentId}`, { executionId, workspaceId, isDeployedContext })
return handler.execute(ctx, SYNTHETIC_AGENT_BLOCK, inputs)
}

View File

@@ -0,0 +1,100 @@
import type { Message, SkillInput, ToolInput } from '@/executor/handlers/agent/types'
export type { Message, SkillInput, ToolInput }
/**
* Core agent configuration, stored as JSONB in the `agent` table.
* Mirrors the subBlock fields of the AgentBlock definition.
*/
export interface AgentConfig {
/** System and user prompt messages */
messages?: Message[]
/** LLM model identifier */
model?: string
/** Tools available to the agent */
tools?: ToolInput[]
/** Skills available to the agent */
skills?: SkillInput[]
/** Memory strategy */
memoryType?: 'none' | 'conversation' | 'sliding_window' | 'sliding_window_tokens'
/** Conversation ID for grouping memory records (required for non-none memory types) */
conversationId?: string
/** Number of messages to retain for sliding_window memory */
slidingWindowSize?: string
/** Max tokens to retain for sliding_window_tokens memory */
slidingWindowTokens?: string
/** LLM temperature (02) */
temperature?: number
/** Maximum output tokens */
maxTokens?: number
/** JSON schema string for structured output */
responseFormat?: string
/** Per-agent provider API key override (encrypted at rest) */
apiKey?: string
/** Reasoning effort for supported models */
reasoningEffort?: string
/** Verbosity for supported models */
verbosity?: string
/** Thinking level for supported models */
thinkingLevel?: string
azureEndpoint?: string
azureApiVersion?: string
vertexProject?: string
vertexLocation?: string
vertexCredential?: string
bedrockAccessKeyId?: string
bedrockSecretKey?: string
bedrockRegion?: string
/** Previous interaction ID for multi-turn deep research follow-ups */
previousInteractionId?: string
}
/**
* Slack-specific deployment configuration, stored as JSONB in
* the `agent_deployment.config` field when platform = 'slack'.
*/
export interface SlackDeploymentConfig {
/** Slack workspace (team) ID from OAuth */
teamId: string
/** The bot's Slack user ID (U...) from oauth.v2.access, used for mention stripping */
botUserId: string
/** Channel IDs the agent listens in (unused when respondTo is 'dm') */
channelIds: string[]
/** Which events trigger the agent */
respondTo: 'mentions' | 'all' | 'threads' | 'dm'
/** Optional display name override for the bot (requires chat:write.customize scope) */
botName?: string
/** Whether to reply inside the thread (true) or in the channel (false). Default true. */
replyInThread: boolean
}
/**
* Agent row as returned from the API.
*/
export interface AgentRow {
id: string
workspaceId: string
createdBy: string
name: string
description?: string | null
config: AgentConfig
isDeployed: boolean
deployedAt?: string | null
archivedAt?: string | null
createdAt: string
updatedAt: string
}
/**
* Agent deployment row as returned from the API.
*/
export interface AgentDeploymentRow {
id: string
agentId: string
platform: 'slack'
credentialId?: string | null
config: SlackDeploymentConfig
isActive: boolean
createdAt: string
updatedAt: string
}

View File

@@ -285,6 +285,7 @@ export const env = createEnv({
DROPBOX_CLIENT_SECRET: z.string().optional(), // Dropbox OAuth client secret
SLACK_CLIENT_ID: z.string().optional(), // Slack OAuth client ID
SLACK_CLIENT_SECRET: z.string().optional(), // Slack OAuth client secret
SLACK_SIGNING_SECRET: z.string().optional(), // Slack signing secret for webhook verification
REDDIT_CLIENT_ID: z.string().optional(), // Reddit OAuth client ID
REDDIT_CLIENT_SECRET: z.string().optional(), // Reddit OAuth client secret
WEBFLOW_CLIENT_ID: z.string().optional(), // Webflow OAuth client ID

View File

@@ -669,6 +669,7 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
'groups:history',
'chat:write',
'chat:write.public',
'chat:write.customize',
'im:write',
'im:history',
'im:read',
@@ -678,6 +679,10 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
'files:read',
'canvases:write',
'reactions:write',
// Required for agent event subscriptions (app_mention events, DMs)
'app_mentions:read',
// Required for conversations.list to return group DMs
'mpim:read',
],
},
},

View File

@@ -3,6 +3,7 @@ export interface PermissionGroupConfig {
allowedModelProviders: string[] | null
// Platform Configuration
hideTraceSpans: boolean
hideAgentsTab: boolean
hideKnowledgeBaseTab: boolean
hideTablesTab: boolean
hideCopilot: boolean
@@ -27,6 +28,7 @@ export const DEFAULT_PERMISSION_GROUP_CONFIG: PermissionGroupConfig = {
allowedIntegrations: null,
allowedModelProviders: null,
hideTraceSpans: false,
hideAgentsTab: false,
hideKnowledgeBaseTab: false,
hideTablesTab: false,
hideCopilot: false,
@@ -57,6 +59,7 @@ export function parsePermissionGroupConfig(config: unknown): PermissionGroupConf
allowedIntegrations: Array.isArray(c.allowedIntegrations) ? c.allowedIntegrations : null,
allowedModelProviders: Array.isArray(c.allowedModelProviders) ? c.allowedModelProviders : null,
hideTraceSpans: typeof c.hideTraceSpans === 'boolean' ? c.hideTraceSpans : false,
hideAgentsTab: typeof c.hideAgentsTab === 'boolean' ? c.hideAgentsTab : false,
hideKnowledgeBaseTab:
typeof c.hideKnowledgeBaseTab === 'boolean' ? c.hideKnowledgeBaseTab : false,
hideTablesTab: typeof c.hideTablesTab === 'boolean' ? c.hideTablesTab : false,

View File

@@ -0,0 +1,37 @@
<svg fill="none" height="22" viewBox="0 0 71 22" width="71" xmlns="http://www.w3.org/2000/svg">
<g transform="scale(.07483)">
<path
clip-rule="evenodd"
d="m142.793 124.175c0 4.75-1.88 9.312-5.216 12.671l-.478.481c-3.334 3.369-7.863 5.252-12.58 5.252h-106.7127c-9.82776 0-17.8063 8.026-17.8063 17.924v115.407c0 9.898 7.97854 17.924 17.8063 17.924h114.5767c9.828 0 17.796-8.026 17.796-17.924v-108.052c0-4.405 1.735-8.632 4.83-11.749 3.086-3.108 7.283-4.856 11.657-4.856h108.5c9.828 0 17.796-8.024 17.796-17.923v-115.4069c0-9.89798-7.968-17.9231-17.796-17.9231h-114.578c-9.827 0-17.795 8.02512-17.795 17.9231zm34.771-99.6079h80.617c5.744 0 10.389 4.6874 10.389 10.463v81.1939c0 5.774-4.645 10.463-10.389 10.463h-80.617c-5.734 0-10.389-4.689-10.389-10.463v-81.1939c0-5.7756 4.655-10.463 10.389-10.463z"
fill="#33c482"
fill-rule="evenodd"
/>
<path
d="m275.293 171.578h-85.187c-10.327 0-18.7 8.432-18.7 18.834v84.75c0 10.402 8.373 18.834 18.7 18.834h85.187c10.328 0 18.701-8.432 18.701-18.834v-84.75c0-10.402-8.373-18.834-18.701-18.834z"
fill="#33c482"
/>
<path
d="m275.293 171.18h-85.187c-10.327 0-18.7 8.432-18.7 18.834v84.749c0 10.402 8.373 18.833 18.7 18.833h85.187c10.328 0 18.701-8.431 18.701-18.833v-84.749c0-10.402-8.373-18.834-18.701-18.834z"
fill="url(#sim-wordmark-gradient)"
fill-opacity=".2"
/>
<defs>
<linearGradient
id="sim-wordmark-gradient"
gradientUnits="userSpaceOnUse"
x1="171.406"
x2="245.831"
y1="171.18"
y2="245.428"
>
<stop offset="0" />
<stop offset="1" stop-opacity="0" />
</linearGradient>
</defs>
</g>
<g fill="#111827">
<path d="m31.5718 15.845h2.5865c0 .7141.2586 1.2835.7759 1.7081.5173.4053 1.2166.608 2.0979.608.958 0 1.6956-.1834 2.2129-.5501.5173-.386.776-.8975.776-1.5344 0-.4632-.1437-.8492-.4311-1.158-.2682-.3088-.7664-.5597-1.4944-.7527l-2.4716-.579c-1.2453-.3088-2.1745-.7817-2.7876-1.4186-.594-.6369-.8909-1.4765-.8909-2.51873 0-.86852.2203-1.62124.661-2.25815.4598-.63692 1.0825-1.12908 1.868-1.47648.8047-.34741 1.7243-.52112 2.7589-.52112s1.9255.18336 2.6727.55007c.7664.3667 1.3603.87817 1.7818 1.53438.4407.65622.6706 1.43788.6898 2.345h-2.5865c-.0192-.73341-.2587-1.30278-.7185-1.70809-.4598-.4053-1.1017-.60796-1.9255-.60796-.843 0-1.4944.18336-1.9542.55006-.4599.36671-.6898.86852-.6898 1.50544 0 .94568.6898 1.59228 2.0692 1.93968l2.4716.608c1.1878.2702 2.0787.7141 2.6727 1.3317.5939.5983.8909 1.4186.8909 2.4608 0 .8878-.2395 1.6695-.7185 2.345-.479.6562-1.14 1.1677-1.983 1.5344-.8238.3474-1.8009.5211-2.9313.5211-1.6477 0-2.9601-.4053-3.9372-1.2159-.9772-.8106-1.4657-1.8915-1.4657-3.2425z" />
<path d="m44.5096 19.956v-14.15687c1.0772.39383 1.5521.39383 2.7014 0v14.15687zm1.322-15.09268c-.479 0-.9005-.1737-1.2645-.52111-.3449-.36671-.5173-.79132-.5173-1.27383 0-.50181.1724-.92642.5173-1.27383.364-.34741.7855-.52111 1.2645-.52111.4981 0 .9196.1737 1.2645.52111s.5173.77202.5173 1.27383c0 .48251-.1724.90712-.5173 1.27383-.3449.34741-.7664.52111-1.2645.52111z" />
<path d="m51.976 19.956h-2.7014v-14.15687h2.4141v2.38865c.2873-.79131.843-1.46223 1.6093-1.98334.7855-.54041 1.7339-.81062 2.8452-.81062 1.2453 0 2.2799.33776 3.1038 1.01328.8238.67551 1.3603 1.57298 1.6093 2.69241h-.4885c.1916-1.11943.7184-2.0169 1.5806-2.69241.8622-.67552 1.9255-1.01328 3.19-1.01328 1.6094 0 2.8739.47286 3.7935 1.41858.9197.94573 1.3795 2.23886 1.3795 3.8794v9.2642h-2.644v-8.5983c0-1.1195-.2874-1.97834-.8621-2.57665-.5557-.61761-1.3125-.92642-2.2704-.92642-.6706 0-1.2645.1544-1.7818.46321-.4982.28951-.8909.71412-1.1783 1.27383-.2874.55973-.4311 1.21593-.4311 1.96863v8.3957h-2.6727v-8.6273c0-1.1194-.2778-1.96864-.8334-2.54765-.5556-.59831-1.3124-.89747-2.2704-.89747-.6706 0-1.2645.1544-1.7818.46321-.4981.28951-.8909.71412-1.1783 1.27383-.2874.54038-.4311 1.18698-.4311 1.93968z" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@@ -0,0 +1,37 @@
<svg fill="none" height="22" viewBox="0 0 71 22" width="71" xmlns="http://www.w3.org/2000/svg">
<g transform="scale(.07483)">
<path
clip-rule="evenodd"
d="m142.793 124.175c0 4.75-1.88 9.312-5.216 12.671l-.478.481c-3.334 3.369-7.863 5.252-12.58 5.252h-106.7127c-9.82776 0-17.8063 8.026-17.8063 17.924v115.407c0 9.898 7.97854 17.924 17.8063 17.924h114.5767c9.828 0 17.796-8.026 17.796-17.924v-108.052c0-4.405 1.735-8.632 4.83-11.749 3.086-3.108 7.283-4.856 11.657-4.856h108.5c9.828 0 17.796-8.024 17.796-17.923v-115.4069c0-9.89798-7.968-17.9231-17.796-17.9231h-114.578c-9.827 0-17.795 8.02512-17.795 17.9231zm34.771-99.6079h80.617c5.744 0 10.389 4.6874 10.389 10.463v81.1939c0 5.774-4.645 10.463-10.389 10.463h-80.617c-5.734 0-10.389-4.689-10.389-10.463v-81.1939c0-5.7756 4.655-10.463 10.389-10.463z"
fill="#33c482"
fill-rule="evenodd"
/>
<path
d="m275.293 171.578h-85.187c-10.327 0-18.7 8.432-18.7 18.834v84.75c0 10.402 8.373 18.834 18.7 18.834h85.187c10.328 0 18.701-8.432 18.701-18.834v-84.75c0-10.402-8.373-18.834-18.701-18.834z"
fill="#33c482"
/>
<path
d="m275.293 171.18h-85.187c-10.327 0-18.7 8.432-18.7 18.834v84.749c0 10.402 8.373 18.833 18.7 18.833h85.187c10.328 0 18.701-8.431 18.701-18.833v-84.749c0-10.402-8.373-18.834-18.701-18.834z"
fill="url(#sim-wordmark-gradient)"
fill-opacity=".2"
/>
<defs>
<linearGradient
id="sim-wordmark-gradient"
gradientUnits="userSpaceOnUse"
x1="171.406"
x2="245.831"
y1="171.18"
y2="245.428"
>
<stop offset="0" />
<stop offset="1" stop-opacity="0" />
</linearGradient>
</defs>
</g>
<g fill="#ffffff">
<path d="m31.5718 15.845h2.5865c0 .7141.2586 1.2835.7759 1.7081.5173.4053 1.2166.608 2.0979.608.958 0 1.6956-.1834 2.2129-.5501.5173-.386.776-.8975.776-1.5344 0-.4632-.1437-.8492-.4311-1.158-.2682-.3088-.7664-.5597-1.4944-.7527l-2.4716-.579c-1.2453-.3088-2.1745-.7817-2.7876-1.4186-.594-.6369-.8909-1.4765-.8909-2.51873 0-.86852.2203-1.62124.661-2.25815.4598-.63692 1.0825-1.12908 1.868-1.47648.8047-.34741 1.7243-.52112 2.7589-.52112s1.9255.18336 2.6727.55007c.7664.3667 1.3603.87817 1.7818 1.53438.4407.65622.6706 1.43788.6898 2.345h-2.5865c-.0192-.73341-.2587-1.30278-.7185-1.70809-.4598-.4053-1.1017-.60796-1.9255-.60796-.843 0-1.4944.18336-1.9542.55006-.4599.36671-.6898.86852-.6898 1.50544 0 .94568.6898 1.59228 2.0692 1.93968l2.4716.608c1.1878.2702 2.0787.7141 2.6727 1.3317.5939.5983.8909 1.4186.8909 2.4608 0 .8878-.2395 1.6695-.7185 2.345-.479.6562-1.14 1.1677-1.983 1.5344-.8238.3474-1.8009.5211-2.9313.5211-1.6477 0-2.9601-.4053-3.9372-1.2159-.9772-.8106-1.4657-1.8915-1.4657-3.2425z" />
<path d="m44.5096 19.956v-14.15687c1.0772.39383 1.5521.39383 2.7014 0v14.15687zm1.322-15.09268c-.479 0-.9005-.1737-1.2645-.52111-.3449-.36671-.5173-.79132-.5173-1.27383 0-.50181.1724-.92642.5173-1.27383.364-.34741.7855-.52111 1.2645-.52111.4981 0 .9196.1737 1.2645.52111s.5173.77202.5173 1.27383c0 .48251-.1724.90712-.5173 1.27383-.3449.34741-.7664.52111-1.2645.52111z" />
<path d="m51.976 19.956h-2.7014v-14.15687h2.4141v2.38865c.2873-.79131.843-1.46223 1.6093-1.98334.7855-.54041 1.7339-.81062 2.8452-.81062 1.2453 0 2.2799.33776 3.1038 1.01328.8238.67551 1.3603 1.57298 1.6093 2.69241h-.4885c.1916-1.11943.7184-2.0169 1.5806-2.69241.8622-.67552 1.9255-1.01328 3.19-1.01328 1.6094 0 2.8739.47286 3.7935 1.41858.9197.94573 1.3795 2.23886 1.3795 3.8794v9.2642h-2.644v-8.5983c0-1.1195-.2874-1.97834-.8621-2.57665-.5557-.61761-1.3125-.92642-2.2704-.92642-.6706 0-1.2645.1544-1.7818.46321-.4982.28951-.8909.71412-1.1783 1.27383-.2874.55973-.4311 1.21593-.4311 1.96863v8.3957h-2.6727v-8.6273c0-1.1194-.2778-1.96864-.8334-2.54765-.5556-.59831-1.3124-.89747-2.2704-.89747-.6706 0-1.2645.1544-1.7818.46321-.4981.28951-.8909.71412-1.1783 1.27383-.2874.54038-.4311 1.18698-.4311 1.93968z" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@@ -0,0 +1,51 @@
CREATE TYPE "public"."agent_platform" AS ENUM('slack');--> statement-breakpoint
CREATE TABLE "agent" (
"id" text PRIMARY KEY NOT NULL,
"workspace_id" text NOT NULL,
"created_by" text NOT NULL,
"name" text NOT NULL,
"description" text,
"config" jsonb DEFAULT '{}' NOT NULL,
"is_deployed" boolean DEFAULT false NOT NULL,
"deployed_at" timestamp,
"archived_at" timestamp,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "agent_conversation" (
"id" text PRIMARY KEY NOT NULL,
"agent_id" text NOT NULL,
"platform" "agent_platform" NOT NULL,
"external_id" text NOT NULL,
"conversation_id" text NOT NULL,
"metadata" jsonb DEFAULT '{}' NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "agent_deployment" (
"id" text PRIMARY KEY NOT NULL,
"agent_id" text NOT NULL,
"platform" "agent_platform" NOT NULL,
"credential_id" text,
"config" jsonb DEFAULT '{}' NOT NULL,
"is_active" boolean DEFAULT false NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "agent" ADD CONSTRAINT "agent_workspace_id_workspace_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspace"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "agent" ADD CONSTRAINT "agent_created_by_user_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "agent_conversation" ADD CONSTRAINT "agent_conversation_agent_id_agent_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."agent"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "agent_deployment" ADD CONSTRAINT "agent_deployment_agent_id_agent_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."agent"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "agent_deployment" ADD CONSTRAINT "agent_deployment_credential_id_credential_id_fk" FOREIGN KEY ("credential_id") REFERENCES "public"."credential"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "agent_workspace_id_idx" ON "agent" USING btree ("workspace_id");--> statement-breakpoint
CREATE INDEX "agent_created_by_idx" ON "agent" USING btree ("created_by");--> statement-breakpoint
CREATE UNIQUE INDEX "agent_workspace_name_unique" ON "agent" USING btree ("workspace_id","name") WHERE "agent"."archived_at" IS NULL;--> statement-breakpoint
CREATE INDEX "agent_conversation_agent_id_idx" ON "agent_conversation" USING btree ("agent_id");--> statement-breakpoint
CREATE UNIQUE INDEX "agent_conversation_unique" ON "agent_conversation" USING btree ("agent_id","platform","external_id");--> statement-breakpoint
CREATE INDEX "agent_deployment_agent_id_idx" ON "agent_deployment" USING btree ("agent_id");--> statement-breakpoint
CREATE INDEX "agent_deployment_platform_idx" ON "agent_deployment" USING btree ("platform");--> statement-breakpoint
CREATE INDEX "agent_deployment_credential_id_idx" ON "agent_deployment" USING btree ("credential_id");--> statement-breakpoint
CREATE UNIQUE INDEX "agent_deployment_agent_platform_unique" ON "agent_deployment" USING btree ("agent_id","platform");

File diff suppressed because it is too large Load Diff

View File

@@ -1261,6 +1261,13 @@
"when": 1774305252055,
"tag": "0180_amused_marvel_boy",
"breakpoints": true
},
{
"idx": 181,
"version": "7",
"when": 1774336258575,
"tag": "0181_wide_morlocks",
"breakpoints": true
}
]
}

View File

@@ -2863,3 +2863,107 @@ export const mothershipInboxWebhook = pgTable('mothership_inbox_webhook', {
secret: text('secret').notNull(),
createdAt: timestamp('created_at').notNull().defaultNow(),
})
// ============================================================
// Agents - Standalone agent registry
// ============================================================
export const agentPlatformEnum = pgEnum('agent_platform', ['slack'])
/**
* Agents - Standalone AI agents with their own configuration,
* separate from workflow blocks. Can be deployed to Slack and
* exposed as REST APIs.
*/
export const agent = pgTable(
'agent',
{
id: text('id').primaryKey(),
workspaceId: text('workspace_id')
.notNull()
.references(() => workspace.id, { onDelete: 'cascade' }),
createdBy: text('created_by')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
description: text('description'),
/** Agent configuration: model, tools, skills, memory, prompts, etc. */
config: jsonb('config').notNull().default('{}'),
/** Whether the agent is deployed to at least one platform or API */
isDeployed: boolean('is_deployed').notNull().default(false),
deployedAt: timestamp('deployed_at'),
archivedAt: timestamp('archived_at'),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
workspaceIdIdx: index('agent_workspace_id_idx').on(table.workspaceId),
createdByIdx: index('agent_created_by_idx').on(table.createdBy),
uniqueNamePerWorkspace: uniqueIndex('agent_workspace_name_unique')
.on(table.workspaceId, table.name)
.where(sql`${table.archivedAt} IS NULL`),
})
)
/**
* Agent Deployments - Per-platform deployment configuration for an agent.
* One row per (agent, platform) pair.
*/
export const agentDeployment = pgTable(
'agent_deployment',
{
id: text('id').primaryKey(),
agentId: text('agent_id')
.notNull()
.references(() => agent.id, { onDelete: 'cascade' }),
platform: agentPlatformEnum('platform').notNull(),
/** OAuth credential used for this deployment (e.g. Slack bot token) */
credentialId: text('credential_id').references(() => credential.id, {
onDelete: 'set null',
}),
/** Platform-specific config (teamId, channelIds, respondTo, etc.) */
config: jsonb('config').notNull().default('{}'),
isActive: boolean('is_active').notNull().default(false),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
agentIdIdx: index('agent_deployment_agent_id_idx').on(table.agentId),
platformIdx: index('agent_deployment_platform_idx').on(table.platform),
credentialIdx: index('agent_deployment_credential_id_idx').on(table.credentialId),
uniqueAgentPlatform: uniqueIndex('agent_deployment_agent_platform_unique').on(
table.agentId,
table.platform
),
})
)
/**
* Agent Conversations - Maps platform-specific conversation identifiers
* (Slack thread, DM, channel) to memory keys in the memory table.
*/
export const agentConversation = pgTable(
'agent_conversation',
{
id: text('id').primaryKey(),
agentId: text('agent_id')
.notNull()
.references(() => agent.id, { onDelete: 'cascade' }),
platform: agentPlatformEnum('platform').notNull(),
/** External identifier: channel_id, thread_ts, DM id, or API conversation ID */
externalId: text('external_id').notNull(),
/** Corresponds to memory.key in the memory table */
conversationId: text('conversation_id').notNull(),
metadata: jsonb('metadata').notNull().default('{}'),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
agentIdIdx: index('agent_conversation_agent_id_idx').on(table.agentId),
uniqueConversation: uniqueIndex('agent_conversation_unique').on(
table.agentId,
table.platform,
table.externalId
),
})
)

View File

@@ -0,0 +1,97 @@
/**
* Script to find and delete spam accounts matching a pattern.
*
* Usage:
* DATABASE_URL="postgres://..." bun run scripts/ban-spam-accounts.ts [--dry-run] [--pattern @sharebot.net]
*
* Options:
* --dry-run List matching accounts without deleting (default behavior)
* --execute Actually delete the accounts
* --pattern Email domain/pattern to match (default: @sharebot.net)
*/
import postgres from 'postgres'
const args = process.argv.slice(2)
const dryRun = !args.includes('--execute')
const patternFlag = args.indexOf('--pattern')
const pattern = patternFlag !== -1 ? args[patternFlag + 1] : '@vapu.xyz'
const DATABASE_URL = process.env.DATABASE_URL
if (!DATABASE_URL) {
console.error('ERROR: DATABASE_URL environment variable is required')
process.exit(1)
}
const sql = postgres(DATABASE_URL, { ssl: 'require' })
async function main() {
console.log(`\n🔍 Searching for spam accounts matching: *${pattern}`)
console.log(
` Mode: ${dryRun ? 'DRY RUN (use --execute to delete)' : '⚠️ EXECUTE MODE - accounts will be deleted'}\n`
)
// Find all matching users
const spamUsers = await sql`
SELECT u.id, u.name, u.email, u."created_at"
FROM "user" u
WHERE u.email LIKE ${'%' + pattern}
ORDER BY u."created_at" DESC
`
if (spamUsers.length === 0) {
console.log('No matching accounts found.')
await sql.end()
return
}
console.log(`Found ${spamUsers.length} matching accounts:\n`)
// Show account details with their workflow/execution counts
for (const user of spamUsers) {
const [stats] = await sql`
SELECT
(SELECT COUNT(*) FROM workflow WHERE user_id = ${user.id}) as workflow_count,
(SELECT COUNT(*) FROM workspace WHERE owner_id = ${user.id}) as workspace_count
`
console.log(` ${user.email}`)
console.log(` ID: ${user.id} | Created: ${user.created_at}`)
console.log(` Workspaces: ${stats.workspace_count} | Workflows: ${stats.workflow_count}`)
}
if (dryRun) {
console.log(`\n📋 Dry run complete. ${spamUsers.length} accounts would be deleted.`)
console.log(' Run with --execute to delete these accounts.')
await sql.end()
return
}
// Execute deletion
console.log(`\n⚠ Deleting ${spamUsers.length} accounts...`)
const userIds = spamUsers.map((u: { id: string }) => u.id)
// Delete workspaces first to handle the billedAccountUserId no-action FK
const deletedWorkspaces = await sql`
DELETE FROM workspace WHERE owner_id = ANY(${userIds}::text[])
`
console.log(
` Deleted ${deletedWorkspaces.count} workspaces (cascades: workflows, execution logs, etc.)`
)
// Now delete the users (cascades: sessions, accounts, credentials, etc.)
const deletedUsers = await sql`
DELETE FROM "user" WHERE id = ANY(${userIds}::text[])
`
console.log(` Deleted ${deletedUsers.count} user accounts`)
console.log(`\n✅ Done. ${deletedUsers.count} spam accounts removed.`)
await sql.end()
}
main().catch(async (err) => {
console.error('Error:', err)
await sql.end()
process.exit(1)
})