mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
Compare commits
2 Commits
feat/fix-l
...
feat/agent
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ef91e56b8 | ||
|
|
1b25460a62 |
63
README.md
63
README.md
@@ -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
|
||||
|
||||
|
||||
236
apps/sim/app/api/agents/[agentId]/deployments/slack/route.ts
Normal file
236
apps/sim/app/api/agents/[agentId]/deployments/slack/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
99
apps/sim/app/api/agents/[agentId]/execute/route.ts
Normal file
99
apps/sim/app/api/agents/[agentId]/execute/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
171
apps/sim/app/api/agents/[agentId]/route.ts
Normal file
171
apps/sim/app/api/agents/[agentId]/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
128
apps/sim/app/api/agents/route.ts
Normal file
128
apps/sim/app/api/agents/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
489
apps/sim/app/api/agents/slack/webhook/route.ts
Normal file
489
apps/sim/app/api/agents/slack/webhook/route.ts
Normal 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
|
||||
}
|
||||
110
apps/sim/app/api/skills/import/route.ts
Normal file
110
apps/sim/app/api/skills/import/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
102
apps/sim/app/api/v1/agents/[agentId]/route.ts
Normal file
102
apps/sim/app/api/v1/agents/[agentId]/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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} />
|
||||
}
|
||||
224
apps/sim/app/workspace/[workspaceId]/agents/agents.tsx
Normal file
224
apps/sim/app/workspace/[workspaceId]/agents/agents.tsx
Normal 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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
3
apps/sim/app/workspace/[workspaceId]/agents/layout.tsx
Normal file
3
apps/sim/app/workspace/[workspaceId]/agents/layout.tsx
Normal 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>
|
||||
}
|
||||
63
apps/sim/app/workspace/[workspaceId]/agents/loading.tsx
Normal file
63
apps/sim/app/workspace/[workspaceId]/agents/loading.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
37
apps/sim/app/workspace/[workspaceId]/agents/page.tsx
Normal file
37
apps/sim/app/workspace/[workspaceId]/agents/page.tsx
Normal 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 />
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
29
apps/sim/components/emcn/icons/bot.tsx
Normal file
29
apps/sim/components/emcn/icons/bot.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
55
apps/sim/components/emcn/icons/wordmark.tsx
Normal file
55
apps/sim/components/emcn/icons/wordmark.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
249
apps/sim/hooks/queries/agents.ts
Normal file
249
apps/sim/hooks/queries/agents.ts
Normal 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() })
|
||||
},
|
||||
})
|
||||
}
|
||||
114
apps/sim/lib/agents/execute.ts
Normal file
114
apps/sim/lib/agents/execute.ts
Normal 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)
|
||||
}
|
||||
100
apps/sim/lib/agents/types.ts
Normal file
100
apps/sim/lib/agents/types.ts
Normal 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 (0–2) */
|
||||
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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
37
apps/sim/public/logo/wordmark-dark.svg
Normal file
37
apps/sim/public/logo/wordmark-dark.svg
Normal 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 |
37
apps/sim/public/logo/wordmark.svg
Normal file
37
apps/sim/public/logo/wordmark.svg
Normal 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 |
51
packages/db/migrations/0181_wide_morlocks.sql
Normal file
51
packages/db/migrations/0181_wide_morlocks.sql
Normal 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");
|
||||
14860
packages/db/migrations/meta/0181_snapshot.json
Normal file
14860
packages/db/migrations/meta/0181_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
),
|
||||
})
|
||||
)
|
||||
|
||||
97
scripts/ban-spam-accounts.ts
Normal file
97
scripts/ban-spam-accounts.ts
Normal 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)
|
||||
})
|
||||
Reference in New Issue
Block a user