Compare commits

..

2 Commits

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,11 +1,6 @@
import { NextResponse } from 'next/server'
import { getLatestRunForStream } from '@/lib/copilot/async-runs/repository'
import { abortActiveStream, waitForPendingChatStream } from '@/lib/copilot/chat-streaming'
import { SIM_AGENT_API_URL } from '@/lib/copilot/constants'
import { abortActiveStream } from '@/lib/copilot/chat-streaming'
import { authenticateCopilotRequestSessionOnly } from '@/lib/copilot/request-helpers'
import { env } from '@/lib/core/config/env'
const GO_EXPLICIT_ABORT_TIMEOUT_MS = 3000
export async function POST(request: Request) {
const { userId: authenticatedUserId, isAuthenticated } =
@@ -17,48 +12,11 @@ export async function POST(request: Request) {
const body = await request.json().catch(() => ({}))
const streamId = typeof body.streamId === 'string' ? body.streamId : ''
let chatId = typeof body.chatId === 'string' ? body.chatId : ''
if (!streamId) {
return NextResponse.json({ error: 'streamId is required' }, { status: 400 })
}
if (!chatId) {
const run = await getLatestRunForStream(streamId, authenticatedUserId).catch(() => null)
if (run?.chatId) {
chatId = run.chatId
}
}
try {
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
if (env.COPILOT_API_KEY) {
headers['x-api-key'] = env.COPILOT_API_KEY
}
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), GO_EXPLICIT_ABORT_TIMEOUT_MS)
const response = await fetch(`${SIM_AGENT_API_URL}/api/streams/explicit-abort`, {
method: 'POST',
headers,
signal: controller.signal,
body: JSON.stringify({
messageId: streamId,
userId: authenticatedUserId,
...(chatId ? { chatId } : {}),
}),
}).finally(() => clearTimeout(timeout))
if (!response.ok) {
throw new Error(`Explicit abort marker request failed: ${response.status}`)
}
} catch {
// best effort: local abort should still proceed even if Go marker fails
}
const aborted = await abortActiveStream(streamId)
if (chatId) {
await waitForPendingChatStream(chatId, GO_EXPLICIT_ABORT_TIMEOUT_MS + 1000, streamId).catch(
() => false
)
}
const aborted = abortActiveStream(streamId)
return NextResponse.json({ aborted })
}

View File

@@ -8,9 +8,7 @@ import { getSession } from '@/lib/auth'
import { getAccessibleCopilotChat, resolveOrCreateChat } from '@/lib/copilot/chat-lifecycle'
import { buildCopilotRequestPayload } from '@/lib/copilot/chat-payload'
import {
acquirePendingChatStream,
createSSEStream,
releasePendingChatStream,
requestChatTitle,
SSE_RESPONSE_HEADERS,
} from '@/lib/copilot/chat-streaming'
@@ -18,7 +16,6 @@ import { COPILOT_REQUEST_MODES } from '@/lib/copilot/models'
import { orchestrateCopilotStream } from '@/lib/copilot/orchestrator'
import { getStreamMeta, readStreamEvents } from '@/lib/copilot/orchestrator/stream/buffer'
import type { OrchestratorResult } from '@/lib/copilot/orchestrator/types'
import { resolveActiveResourceContext } from '@/lib/copilot/process-contents'
import {
authenticateCopilotRequestSessionOnly,
createBadRequestResponse,
@@ -47,13 +44,6 @@ const FileAttachmentSchema = z.object({
size: z.number(),
})
const ResourceAttachmentSchema = z.object({
type: z.enum(['workflow', 'table', 'file', 'knowledgebase']),
id: z.string().min(1),
title: z.string().optional(),
active: z.boolean().optional(),
})
const ChatMessageSchema = z.object({
message: z.string().min(1, 'Message is required'),
userMessageId: z.string().optional(),
@@ -68,7 +58,6 @@ const ChatMessageSchema = z.object({
stream: z.boolean().optional().default(true),
implicitFeedback: z.string().optional(),
fileAttachments: z.array(FileAttachmentSchema).optional(),
resourceAttachments: z.array(ResourceAttachmentSchema).optional(),
provider: z.string().optional(),
contexts: z
.array(
@@ -109,10 +98,6 @@ const ChatMessageSchema = z.object({
*/
export async function POST(req: NextRequest) {
const tracker = createRequestTracker()
let actualChatId: string | undefined
let pendingChatStreamAcquired = false
let pendingChatStreamHandedOff = false
let pendingChatStreamID: string | undefined
try {
// Get session to access user information including name
@@ -139,7 +124,6 @@ export async function POST(req: NextRequest) {
stream,
implicitFeedback,
fileAttachments,
resourceAttachments,
provider,
contexts,
commands,
@@ -205,7 +189,7 @@ export async function POST(req: NextRequest) {
let currentChat: any = null
let conversationHistory: any[] = []
actualChatId = chatId
let actualChatId = chatId
const selectedModel = model || 'claude-opus-4-6'
if (chatId || createNewChat) {
@@ -257,39 +241,6 @@ export async function POST(req: NextRequest) {
}
}
if (
Array.isArray(resourceAttachments) &&
resourceAttachments.length > 0 &&
resolvedWorkspaceId
) {
const results = await Promise.allSettled(
resourceAttachments.map(async (r) => {
const ctx = await resolveActiveResourceContext(
r.type,
r.id,
resolvedWorkspaceId!,
authenticatedUserId,
actualChatId
)
if (!ctx) return null
return {
...ctx,
tag: r.active ? '@active_tab' : '@open_tab',
}
})
)
for (const result of results) {
if (result.status === 'fulfilled' && result.value) {
agentContexts.push(result.value)
} else if (result.status === 'rejected') {
logger.error(
`[${tracker.requestId}] Failed to resolve resource attachment`,
result.reason
)
}
}
}
const effectiveMode = mode === 'agent' ? 'build' : mode
const userPermission = resolvedWorkspaceId
@@ -340,21 +291,6 @@ export async function POST(req: NextRequest) {
})
} catch {}
if (stream && actualChatId) {
const acquired = await acquirePendingChatStream(actualChatId, userMessageIdToUse)
if (!acquired) {
return NextResponse.json(
{
error:
'A response is already in progress for this chat. Wait for it to finish or use Stop.',
},
{ status: 409 }
)
}
pendingChatStreamAcquired = true
pendingChatStreamID = userMessageIdToUse
}
if (actualChatId) {
const userMsg = {
id: userMessageIdToUse,
@@ -401,7 +337,6 @@ export async function POST(req: NextRequest) {
titleProvider: provider,
requestId: tracker.requestId,
workspaceId: resolvedWorkspaceId,
pendingChatStreamAlreadyRegistered: Boolean(actualChatId && stream),
orchestrateOptions: {
userId: authenticatedUserId,
workflowId,
@@ -413,7 +348,6 @@ export async function POST(req: NextRequest) {
interactive: true,
onComplete: async (result: OrchestratorResult) => {
if (!actualChatId) return
if (!result.success) return
const assistantMessage: Record<string, unknown> = {
id: crypto.randomUUID(),
@@ -489,7 +423,6 @@ export async function POST(req: NextRequest) {
},
},
})
pendingChatStreamHandedOff = true
return new Response(sseStream, { headers: SSE_RESPONSE_HEADERS })
}
@@ -595,14 +528,6 @@ export async function POST(req: NextRequest) {
},
})
} catch (error) {
if (
actualChatId &&
pendingChatStreamAcquired &&
!pendingChatStreamHandedOff &&
pendingChatStreamID
) {
await releasePendingChatStream(actualChatId, pendingChatStreamID).catch(() => {})
}
const duration = tracker.getDuration()
if (error instanceof z.ZodError) {

View File

@@ -8,9 +8,9 @@ import { getSession } from '@/lib/auth'
import { resolveOrCreateChat } from '@/lib/copilot/chat-lifecycle'
import { buildCopilotRequestPayload } from '@/lib/copilot/chat-payload'
import {
acquirePendingChatStream,
createSSEStream,
SSE_RESPONSE_HEADERS,
waitForPendingChatStream,
} from '@/lib/copilot/chat-streaming'
import type { OrchestratorResult } from '@/lib/copilot/orchestrator/types'
import { processContextsServer, resolveActiveResourceContext } from '@/lib/copilot/process-contents'
@@ -253,16 +253,7 @@ export async function POST(req: NextRequest) {
)
if (actualChatId) {
const acquired = await acquirePendingChatStream(actualChatId, userMessageId)
if (!acquired) {
return NextResponse.json(
{
error:
'A response is already in progress for this chat. Wait for it to finish or use Stop.',
},
{ status: 409 }
)
}
await waitForPendingChatStream(actualChatId)
}
const executionId = crypto.randomUUID()
@@ -280,7 +271,6 @@ export async function POST(req: NextRequest) {
titleModel: 'claude-opus-4-6',
requestId: tracker.requestId,
workspaceId,
pendingChatStreamAlreadyRegistered: Boolean(actualChatId),
orchestrateOptions: {
userId: authenticatedUserId,
workspaceId,
@@ -292,7 +282,6 @@ export async function POST(req: NextRequest) {
interactive: true,
onComplete: async (result: OrchestratorResult) => {
if (!actualChatId) return
if (!result.success) return
const assistantMessage: Record<string, unknown> = {
id: crypto.randomUUID(),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -323,8 +323,8 @@ export function useChat(
reader: ReadableStreamDefaultReader<Uint8Array>,
assistantId: string,
expectedGen?: number
) => Promise<boolean>
>(async () => false)
) => Promise<void>
>(async () => {})
const finalizeRef = useRef<(options?: { error?: boolean }) => void>(() => {})
const abortControllerRef = useRef<AbortController | null>(null)
@@ -415,8 +415,6 @@ export function useChat(
setIsReconnecting(false)
setResources([])
setActiveResourceId(null)
setStreamingFile(null)
streamingFileRef.current = null
setMessageQueue([])
}, [initialChatId, queryClient])
@@ -435,8 +433,6 @@ export function useChat(
setIsReconnecting(false)
setResources([])
setActiveResourceId(null)
setStreamingFile(null)
streamingFileRef.current = null
setMessageQueue([])
}, [isHomePage])
@@ -445,6 +441,12 @@ export function useChat(
const activeStreamId = chatHistory.activeStreamId
const snapshot = chatHistory.streamSnapshot
if (activeStreamId && !snapshot && !sendingRef.current) {
queryClient.invalidateQueries({ queryKey: taskKeys.detail(chatHistory.id) })
return
}
appliedChatIdRef.current = chatHistory.id
const mappedMessages = chatHistory.messages.map(mapStoredMessage)
const shouldPreserveActiveStreamingMessage =
@@ -495,6 +497,7 @@ export function useChat(
}
if (activeStreamId && !sendingRef.current) {
abortControllerRef.current?.abort()
const gen = ++streamGenRef.current
const abortController = new AbortController()
abortControllerRef.current = abortController
@@ -505,7 +508,6 @@ export function useChat(
const assistantId = crypto.randomUUID()
const reconnect = async () => {
let reconnectFailed = false
try {
const encoder = new TextEncoder()
@@ -513,8 +515,14 @@ export function useChat(
const streamStatus = snapshot?.status ?? ''
if (batchEvents.length === 0 && streamStatus === 'unknown') {
reconnectFailed = true
setError(RECONNECT_TAIL_ERROR)
const cid = chatIdRef.current
if (cid) {
fetch(stopPathRef.current, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ chatId: cid, streamId: activeStreamId, content: '' }),
}).catch(() => {})
}
return
}
@@ -542,7 +550,6 @@ export function useChat(
{ signal: abortController.signal }
)
if (!sseRes.ok || !sseRes.body) {
reconnectFailed = true
logger.warn('SSE tail reconnect returned no readable body', {
status: sseRes.status,
streamId: activeStreamId,
@@ -558,7 +565,6 @@ export function useChat(
}
} catch (err) {
if (!(err instanceof Error && err.name === 'AbortError')) {
reconnectFailed = true
logger.warn('SSE tail failed during reconnect', err)
setError(RECONNECT_TAIL_ERROR)
}
@@ -569,21 +575,13 @@ export function useChat(
},
})
const hadStreamError = await processSSEStreamRef.current(
combinedStream.getReader(),
assistantId,
gen
)
if (hadStreamError) {
reconnectFailed = true
}
await processSSEStreamRef.current(combinedStream.getReader(), assistantId, gen)
} catch (err) {
if (err instanceof Error && err.name === 'AbortError') return
reconnectFailed = true
} finally {
setIsReconnecting(false)
if (streamGenRef.current === gen) {
finalizeRef.current(reconnectFailed ? { error: true } : undefined)
finalizeRef.current()
}
}
}
@@ -621,34 +619,7 @@ export function useChat(
return b
}
const appendInlineErrorTag = (tag: string) => {
if (runningText.includes(tag)) return
const tb = ensureTextBlock()
const prefix = runningText.length > 0 && !runningText.endsWith('\n') ? '\n' : ''
tb.content = `${tb.content ?? ''}${prefix}${tag}`
if (activeSubagent) tb.subagent = activeSubagent
runningText += `${prefix}${tag}`
streamingContentRef.current = runningText
flush()
}
const buildInlineErrorTag = (payload: SSEPayload) => {
const data = getPayloadData(payload) as Record<string, unknown> | undefined
const message =
(data?.displayMessage as string | undefined) ||
payload.error ||
'An unexpected error occurred'
const provider = (data?.provider as string | undefined) || undefined
const code = (data?.code as string | undefined) || undefined
return `<mothership-error>${JSON.stringify({
message,
...(code ? { code } : {}),
...(provider ? { provider } : {}),
})}</mothership-error>`
}
const isStale = () => expectedGen !== undefined && streamGenRef.current !== expectedGen
let sawStreamError = false
const flush = () => {
if (isStale()) return
@@ -673,9 +644,12 @@ export function useChat(
try {
while (true) {
if (isStale()) {
reader.cancel().catch(() => {})
break
}
const { done, value } = await reader.read()
if (done) break
if (isStale()) continue
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
@@ -1139,20 +1113,21 @@ export function useChat(
break
}
case 'error': {
sawStreamError = true
setError(parsed.error || 'An error occurred')
appendInlineErrorTag(buildInlineErrorTag(parsed))
break
}
}
}
if (isStale()) {
reader.cancel().catch(() => {})
break
}
}
} finally {
if (streamReaderRef.current === reader) {
streamReaderRef.current = null
}
}
return sawStreamError
},
[workspaceId, queryClient, addResource, removeResource]
)
@@ -1379,10 +1354,7 @@ export function useChat(
if (!response.body) throw new Error('No response body')
const hadStreamError = await processSSEStream(response.body.getReader(), assistantId, gen)
if (streamGenRef.current === gen) {
finalize(hadStreamError ? { error: true } : undefined)
}
await processSSEStream(response.body.getReader(), assistantId, gen)
} catch (err) {
if (err instanceof Error && err.name === 'AbortError') return
setError(err instanceof Error ? err.message : 'Failed to send message')
@@ -1391,6 +1363,9 @@ export function useChat(
}
return
}
if (streamGenRef.current === gen) {
finalize()
}
},
[workspaceId, queryClient, processSSEStream, finalize]
)
@@ -1412,25 +1387,6 @@ export function useChat(
sendingRef.current = false
setIsSending(false)
setMessages((prev) =>
prev.map((msg) => {
if (!msg.contentBlocks?.some((b) => b.toolCall?.status === 'executing')) return msg
const updated = msg.contentBlocks!.map((block) => {
if (block.toolCall?.status !== 'executing') return block
return {
...block,
toolCall: {
...block.toolCall,
status: 'cancelled' as const,
displayTitle: 'Stopped by user',
},
}
})
updated.push({ type: 'stopped' as const })
return { ...msg, contentBlocks: updated }
})
)
if (sid) {
fetch('/api/copilot/chat/abort', {
method: 'POST',
@@ -1454,6 +1410,25 @@ export function useChat(
streamingFileRef.current = null
setResources((rs) => rs.filter((resource) => resource.id !== 'streaming-file'))
setMessages((prev) =>
prev.map((msg) => {
if (!msg.contentBlocks?.some((b) => b.toolCall?.status === 'executing')) return msg
const updated = msg.contentBlocks!.map((block) => {
if (block.toolCall?.status !== 'executing') return block
return {
...block,
toolCall: {
...block.toolCall,
status: 'cancelled' as const,
displayTitle: 'Stopped by user',
},
}
})
updated.push({ type: 'stopped' as const })
return { ...msg, contentBlocks: updated }
})
)
const execState = useExecutionStore.getState()
const consoleStore = useTerminalConsoleStore.getState()
for (const [workflowId, wfExec] of execState.workflowExecutions) {
@@ -1525,6 +1500,7 @@ export function useChat(
useEffect(() => {
return () => {
streamReaderRef.current?.cancel().catch(() => {})
streamReaderRef.current = null
abortControllerRef.current = null
streamGenRef.current++

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -86,20 +86,6 @@ export async function getLatestRunForExecution(executionId: string) {
return run ?? null
}
export async function getLatestRunForStream(streamId: string, userId?: string) {
const conditions = userId
? and(eq(copilotRuns.streamId, streamId), eq(copilotRuns.userId, userId))
: eq(copilotRuns.streamId, streamId)
const [run] = await db
.select()
.from(copilotRuns)
.where(conditions)
.orderBy(desc(copilotRuns.startedAt))
.limit(1)
return run ?? null
}
export async function getRunSegment(runId: string) {
const [run] = await db.select().from(copilotRuns).where(eq(copilotRuns.id, runId)).limit(1)
return run ?? null
@@ -135,20 +121,6 @@ export async function upsertAsyncToolCall(input: {
status?: CopilotAsyncToolStatus
}) {
const existing = await getAsyncToolCall(input.toolCallId)
const incomingStatus = input.status ?? 'pending'
if (
existing &&
(isTerminalAsyncStatus(existing.status) || isDeliveredAsyncStatus(existing.status)) &&
!isTerminalAsyncStatus(incomingStatus) &&
!isDeliveredAsyncStatus(incomingStatus)
) {
logger.info('Ignoring async tool upsert that would downgrade terminal state', {
toolCallId: input.toolCallId,
existingStatus: existing.status,
incomingStatus,
})
return existing
}
const effectiveRunId = input.runId ?? existing?.runId ?? null
if (!effectiveRunId) {
logger.warn('upsertAsyncToolCall missing runId and no existing row', {
@@ -168,7 +140,7 @@ export async function upsertAsyncToolCall(input: {
toolCallId: input.toolCallId,
toolName: input.toolName,
args: input.args ?? {},
status: incomingStatus,
status: input.status ?? 'pending',
updatedAt: now,
})
.onConflictDoUpdate({
@@ -178,7 +150,7 @@ export async function upsertAsyncToolCall(input: {
checkpointId: input.checkpointId ?? null,
toolName: input.toolName,
args: input.args ?? {},
status: incomingStatus,
status: input.status ?? 'pending',
updatedAt: now,
},
})

View File

@@ -8,19 +8,14 @@ import type { OrchestrateStreamOptions } from '@/lib/copilot/orchestrator'
import { orchestrateCopilotStream } from '@/lib/copilot/orchestrator'
import {
createStreamEventWriter,
getStreamMeta,
resetStreamBuffer,
setStreamMeta,
} from '@/lib/copilot/orchestrator/stream/buffer'
import { taskPubSub } from '@/lib/copilot/task-events'
import { env } from '@/lib/core/config/env'
import { acquireLock, getRedisClient, releaseLock } from '@/lib/core/config/redis'
import { SSE_HEADERS } from '@/lib/core/utils/sse'
const logger = createLogger('CopilotChatStreaming')
const CHAT_STREAM_LOCK_TTL_SECONDS = 2 * 60 * 60
const STREAM_ABORT_TTL_SECONDS = 10 * 60
const STREAM_ABORT_POLL_MS = 1000
// Registry of in-flight Sim→Go streams so the explicit abort endpoint can
// reach them. Keyed by streamId, cleaned up when the stream completes.
@@ -53,138 +48,25 @@ function resolvePendingChatStream(chatId: string, streamId: string): void {
}
}
function getChatStreamLockKey(chatId: string): string {
return `copilot:chat-stream-lock:${chatId}`
}
function getStreamAbortKey(streamId: string): string {
return `copilot:stream-abort:${streamId}`
}
/**
* Wait for any in-flight stream on `chatId` to settle without force-aborting it.
* Returns true when no stream is active (or it settles in time), false on timeout.
* Abort any in-flight stream on `chatId` and wait for it to fully settle
* (including onComplete and Go-side persistence). Returns immediately if
* no stream is active. Gives up after `timeoutMs`.
*/
export async function waitForPendingChatStream(
chatId: string,
timeoutMs = 5_000,
expectedStreamId?: string
): Promise<boolean> {
const redis = getRedisClient()
const deadline = Date.now() + timeoutMs
export async function waitForPendingChatStream(chatId: string, timeoutMs = 5_000): Promise<void> {
const entry = pendingChatStreams.get(chatId)
if (!entry) return
for (;;) {
const entry = pendingChatStreams.get(chatId)
const localPending = !!entry && (!expectedStreamId || entry.streamId === expectedStreamId)
// Force-abort the previous stream so we don't passively wait for it to
// finish naturally (which could take tens of seconds for a subagent).
abortActiveStream(entry.streamId)
if (redis) {
try {
const ownerStreamId = await redis.get(getChatStreamLockKey(chatId))
const lockReleased =
!ownerStreamId || (expectedStreamId !== undefined && ownerStreamId !== expectedStreamId)
if (!localPending && lockReleased) {
return true
}
} catch (error) {
logger.warn('Failed to check distributed chat stream lock while waiting', {
chatId,
expectedStreamId,
error: error instanceof Error ? error.message : String(error),
})
}
} else if (!localPending) {
return true
}
if (Date.now() >= deadline) return false
await new Promise((resolve) => setTimeout(resolve, 200))
}
await Promise.race([entry.promise, new Promise<void>((r) => setTimeout(r, timeoutMs))])
}
export async function releasePendingChatStream(chatId: string, streamId: string): Promise<void> {
const redis = getRedisClient()
if (redis) {
await releaseLock(getChatStreamLockKey(chatId), streamId).catch(() => false)
}
resolvePendingChatStream(chatId, streamId)
}
export async function acquirePendingChatStream(
chatId: string,
streamId: string,
timeoutMs = 5_000
): Promise<boolean> {
const redis = getRedisClient()
if (redis) {
const deadline = Date.now() + timeoutMs
for (;;) {
try {
const acquired = await acquireLock(
getChatStreamLockKey(chatId),
streamId,
CHAT_STREAM_LOCK_TTL_SECONDS
)
if (acquired) {
registerPendingChatStream(chatId, streamId)
return true
}
if (!pendingChatStreams.has(chatId)) {
const ownerStreamId = await redis.get(getChatStreamLockKey(chatId))
if (ownerStreamId) {
const ownerMeta = await getStreamMeta(ownerStreamId)
const ownerTerminal =
ownerMeta?.status === 'complete' ||
ownerMeta?.status === 'error' ||
ownerMeta?.status === 'cancelled'
if (ownerTerminal) {
await releaseLock(getChatStreamLockKey(chatId), ownerStreamId).catch(() => false)
continue
}
}
}
} catch (error) {
logger.warn('Distributed chat stream lock failed; retrying distributed coordination', {
chatId,
streamId,
error: error instanceof Error ? error.message : String(error),
})
}
if (Date.now() >= deadline) return false
await new Promise((resolve) => setTimeout(resolve, 200))
}
}
for (;;) {
const existing = pendingChatStreams.get(chatId)
if (!existing) {
registerPendingChatStream(chatId, streamId)
return true
}
const settled = await Promise.race([
existing.promise.then(() => true),
new Promise<boolean>((r) => setTimeout(() => r(false), timeoutMs)),
])
if (!settled) return false
}
}
export async function abortActiveStream(streamId: string): Promise<boolean> {
const redis = getRedisClient()
let published = false
if (redis) {
try {
await redis.set(getStreamAbortKey(streamId), '1', 'EX', STREAM_ABORT_TTL_SECONDS)
published = true
} catch (error) {
logger.warn('Failed to publish distributed stream abort', {
streamId,
error: error instanceof Error ? error.message : String(error),
})
}
}
export function abortActiveStream(streamId: string): boolean {
const controller = activeStreams.get(streamId)
if (!controller) return published
if (!controller) return false
controller.abort()
activeStreams.delete(streamId)
return true
@@ -253,7 +135,6 @@ export interface StreamingOrchestrationParams {
requestId: string
workspaceId?: string
orchestrateOptions: Omit<OrchestrateStreamOptions, 'onEvent'>
pendingChatStreamAlreadyRegistered?: boolean
}
export function createSSEStream(params: StreamingOrchestrationParams): ReadableStream {
@@ -272,7 +153,6 @@ export function createSSEStream(params: StreamingOrchestrationParams): ReadableS
requestId,
workspaceId,
orchestrateOptions,
pendingChatStreamAlreadyRegistered = false,
} = params
let eventWriter: ReturnType<typeof createStreamEventWriter> | null = null
@@ -280,7 +160,7 @@ export function createSSEStream(params: StreamingOrchestrationParams): ReadableS
const abortController = new AbortController()
activeStreams.set(streamId, abortController)
if (chatId && !pendingChatStreamAlreadyRegistered) {
if (chatId) {
registerPendingChatStream(chatId, streamId)
}
@@ -311,47 +191,14 @@ export function createSSEStream(params: StreamingOrchestrationParams): ReadableS
eventWriter = createStreamEventWriter(streamId)
let localSeq = 0
let abortPoller: ReturnType<typeof setInterval> | null = null
const redis = getRedisClient()
if (redis) {
abortPoller = setInterval(() => {
void (async () => {
try {
const shouldAbort = await redis.get(getStreamAbortKey(streamId))
if (shouldAbort && !abortController.signal.aborted) {
abortController.abort()
await redis.del(getStreamAbortKey(streamId))
}
} catch (error) {
logger.warn(`[${requestId}] Failed to poll distributed stream abort`, {
streamId,
error: error instanceof Error ? error.message : String(error),
})
}
})()
}, STREAM_ABORT_POLL_MS)
}
const pushEvent = async (event: Record<string, any>) => {
if (!eventWriter) return
const eventId = ++localSeq
try {
await eventWriter.write(event)
if (FLUSH_EVENT_TYPES.has(event.type)) {
await eventWriter.flush()
}
} catch (error) {
logger.error(`[${requestId}] Failed to persist stream event`, {
eventType: event.type,
eventId,
error: error instanceof Error ? error.message : String(error),
})
// Keep the live SSE stream going even if durable buffering hiccups.
}
// Enqueue to client stream FIRST for minimal latency.
// Redis persistence happens after so the client never waits on I/O.
try {
if (!clientDisconnected) {
controller.enqueue(
@@ -361,16 +208,16 @@ export function createSSEStream(params: StreamingOrchestrationParams): ReadableS
} catch {
clientDisconnected = true
}
}
const pushEventBestEffort = async (event: Record<string, any>) => {
try {
await pushEvent(event)
} catch (error) {
logger.error(`[${requestId}] Failed to push event`, {
eventType: event.type,
error: error instanceof Error ? error.message : String(error),
})
await eventWriter.write(event)
if (FLUSH_EVENT_TYPES.has(event.type)) {
await eventWriter.flush()
}
} catch {
if (clientDisconnected) {
await eventWriter.flush().catch(() => {})
}
}
}
@@ -437,7 +284,7 @@ export function createSSEStream(params: StreamingOrchestrationParams): ReadableS
logger.error(`[${requestId}] Orchestration returned failure`, {
error: errorMessage,
})
await pushEventBestEffort({
await pushEvent({
type: 'error',
error: errorMessage,
data: {
@@ -477,7 +324,7 @@ export function createSSEStream(params: StreamingOrchestrationParams): ReadableS
}
logger.error(`[${requestId}] Orchestration error:`, error)
const errorMessage = error instanceof Error ? error.message : 'Stream error'
await pushEventBestEffort({
await pushEvent({
type: 'error',
error: errorMessage,
data: {
@@ -498,19 +345,10 @@ export function createSSEStream(params: StreamingOrchestrationParams): ReadableS
}).catch(() => {})
} finally {
clearInterval(keepaliveInterval)
if (abortPoller) {
clearInterval(abortPoller)
}
activeStreams.delete(streamId)
if (chatId) {
if (redis) {
await releaseLock(getChatStreamLockKey(chatId), streamId).catch(() => false)
}
resolvePendingChatStream(chatId, streamId)
}
if (redis) {
await redis.del(getStreamAbortKey(streamId)).catch(() => {})
}
try {
controller.close()
} catch {

View File

@@ -0,0 +1,281 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { OrchestratorOptions } from './types'
const {
prepareExecutionContext,
getEffectiveDecryptedEnv,
runStreamLoop,
claimCompletedAsyncToolCall,
getAsyncToolCall,
getAsyncToolCalls,
markAsyncToolDelivered,
releaseCompletedAsyncToolClaim,
updateRunStatus,
} = vi.hoisted(() => ({
prepareExecutionContext: vi.fn(),
getEffectiveDecryptedEnv: vi.fn(),
runStreamLoop: vi.fn(),
claimCompletedAsyncToolCall: vi.fn(),
getAsyncToolCall: vi.fn(),
getAsyncToolCalls: vi.fn(),
markAsyncToolDelivered: vi.fn(),
releaseCompletedAsyncToolClaim: vi.fn(),
updateRunStatus: vi.fn(),
}))
vi.mock('@/lib/copilot/orchestrator/tool-executor', () => ({
prepareExecutionContext,
}))
vi.mock('@/lib/environment/utils', () => ({
getEffectiveDecryptedEnv,
}))
vi.mock('@/lib/copilot/async-runs/repository', () => ({
claimCompletedAsyncToolCall,
getAsyncToolCall,
getAsyncToolCalls,
markAsyncToolDelivered,
releaseCompletedAsyncToolClaim,
updateRunStatus,
}))
vi.mock('@/lib/copilot/orchestrator/stream/core', async () => {
const actual = await vi.importActual<typeof import('./stream/core')>('./stream/core')
return {
...actual,
buildToolCallSummaries: vi.fn(() => []),
runStreamLoop,
}
})
import { orchestrateCopilotStream } from './index'
describe('orchestrateCopilotStream async continuation', () => {
beforeEach(() => {
vi.clearAllMocks()
prepareExecutionContext.mockResolvedValue({
userId: 'user-1',
workflowId: 'workflow-1',
chatId: 'chat-1',
})
getEffectiveDecryptedEnv.mockResolvedValue({})
claimCompletedAsyncToolCall.mockResolvedValue({ toolCallId: 'tool-1' })
getAsyncToolCall.mockResolvedValue({
toolCallId: 'tool-1',
toolName: 'read',
status: 'completed',
result: { ok: true },
error: null,
})
getAsyncToolCalls.mockResolvedValue([
{
toolCallId: 'tool-1',
toolName: 'read',
status: 'completed',
result: { ok: true },
error: null,
},
])
markAsyncToolDelivered.mockResolvedValue(null)
releaseCompletedAsyncToolClaim.mockResolvedValue(null)
updateRunStatus.mockResolvedValue(null)
})
it('builds resume payloads with success=true for claimed completed rows', async () => {
runStreamLoop
.mockImplementationOnce(async (_url: string, _opts: RequestInit, context: any) => {
context.awaitingAsyncContinuation = {
checkpointId: 'checkpoint-1',
runId: 'run-1',
pendingToolCallIds: ['tool-1'],
}
})
.mockImplementationOnce(async (url: string, opts: RequestInit) => {
expect(url).toContain('/api/tools/resume')
const body = JSON.parse(String(opts.body))
expect(body).toEqual({
checkpointId: 'checkpoint-1',
results: [
{
callId: 'tool-1',
name: 'read',
data: { ok: true },
success: true,
},
],
})
})
const result = await orchestrateCopilotStream(
{ message: 'hello' },
{
userId: 'user-1',
workflowId: 'workflow-1',
chatId: 'chat-1',
executionId: 'exec-1',
runId: 'run-1',
}
)
expect(result.success).toBe(true)
expect(markAsyncToolDelivered).toHaveBeenCalledWith('tool-1')
})
it('marks claimed tool calls delivered even when the resumed stream later records errors', async () => {
runStreamLoop
.mockImplementationOnce(async (_url: string, _opts: RequestInit, context: any) => {
context.awaitingAsyncContinuation = {
checkpointId: 'checkpoint-1',
runId: 'run-1',
pendingToolCallIds: ['tool-1'],
}
})
.mockImplementationOnce(async (_url: string, _opts: RequestInit, context: any) => {
context.errors.push('resume stream failed after handoff')
})
const result = await orchestrateCopilotStream(
{ message: 'hello' },
{
userId: 'user-1',
workflowId: 'workflow-1',
chatId: 'chat-1',
executionId: 'exec-1',
runId: 'run-1',
}
)
expect(result.success).toBe(false)
expect(markAsyncToolDelivered).toHaveBeenCalledWith('tool-1')
})
it('forwards done events while still marking async pauses on the run', async () => {
const onEvent = vi.fn()
const streamOptions: OrchestratorOptions = { onEvent }
runStreamLoop.mockImplementationOnce(
async (_url: string, _opts: RequestInit, _context: any, _exec: any, loopOptions: any) => {
await loopOptions.onEvent({
type: 'done',
data: {
response: {
async_pause: {
checkpointId: 'checkpoint-1',
runId: 'run-1',
},
},
},
})
}
)
await orchestrateCopilotStream(
{ message: 'hello' },
{
userId: 'user-1',
workflowId: 'workflow-1',
chatId: 'chat-1',
executionId: 'exec-1',
runId: 'run-1',
...streamOptions,
}
)
expect(onEvent).toHaveBeenCalledWith(expect.objectContaining({ type: 'done' }))
expect(updateRunStatus).toHaveBeenCalledWith('run-1', 'paused_waiting_for_tool')
})
it('waits for a local running tool before retrying the claim', async () => {
const localPendingPromise = Promise.resolve({
status: 'success',
data: { ok: true },
})
claimCompletedAsyncToolCall
.mockResolvedValueOnce(null)
.mockResolvedValueOnce({ toolCallId: 'tool-1' })
getAsyncToolCall
.mockResolvedValueOnce({
toolCallId: 'tool-1',
toolName: 'read',
status: 'running',
result: null,
error: null,
})
.mockResolvedValue({
toolCallId: 'tool-1',
toolName: 'read',
status: 'completed',
result: { ok: true },
error: null,
})
runStreamLoop
.mockImplementationOnce(async (_url: string, _opts: RequestInit, context: any) => {
context.awaitingAsyncContinuation = {
checkpointId: 'checkpoint-1',
runId: 'run-1',
pendingToolCallIds: ['tool-1'],
}
context.pendingToolPromises.set('tool-1', localPendingPromise)
})
.mockImplementationOnce(async (url: string, opts: RequestInit) => {
expect(url).toContain('/api/tools/resume')
const body = JSON.parse(String(opts.body))
expect(body.results[0]).toEqual({
callId: 'tool-1',
name: 'read',
data: { ok: true },
success: true,
})
})
const result = await orchestrateCopilotStream(
{ message: 'hello' },
{
userId: 'user-1',
workflowId: 'workflow-1',
chatId: 'chat-1',
executionId: 'exec-1',
runId: 'run-1',
}
)
expect(result.success).toBe(true)
expect(runStreamLoop).toHaveBeenCalledTimes(2)
expect(markAsyncToolDelivered).toHaveBeenCalledWith('tool-1')
})
it('releases claimed rows if the resume stream throws before delivery is marked', async () => {
runStreamLoop
.mockImplementationOnce(async (_url: string, _opts: RequestInit, context: any) => {
context.awaitingAsyncContinuation = {
checkpointId: 'checkpoint-1',
runId: 'run-1',
pendingToolCallIds: ['tool-1'],
}
})
.mockImplementationOnce(async () => {
throw new Error('resume failed')
})
const result = await orchestrateCopilotStream(
{ message: 'hello' },
{
userId: 'user-1',
workflowId: 'workflow-1',
chatId: 'chat-1',
executionId: 'exec-1',
runId: 'run-1',
}
)
expect(result.success).toBe(false)
expect(releaseCompletedAsyncToolClaim).toHaveBeenCalledWith('tool-1', 'run-1')
expect(markAsyncToolDelivered).not.toHaveBeenCalled()
})
})

View File

@@ -14,17 +14,12 @@ import {
updateRunStatus,
} from '@/lib/copilot/async-runs/repository'
import { SIM_AGENT_API_URL, SIM_AGENT_VERSION } from '@/lib/copilot/constants'
import {
isToolAvailableOnSimSide,
prepareExecutionContext,
} from '@/lib/copilot/orchestrator/tool-executor'
import {
type ExecutionContext,
isTerminalToolCallStatus,
type OrchestratorOptions,
type OrchestratorResult,
type SSEEvent,
type ToolCallState,
import { prepareExecutionContext } from '@/lib/copilot/orchestrator/tool-executor'
import type {
ExecutionContext,
OrchestratorOptions,
OrchestratorResult,
SSEEvent,
} from '@/lib/copilot/orchestrator/types'
import { env } from '@/lib/core/config/env'
import { getEffectiveDecryptedEnv } from '@/lib/environment/utils'
@@ -36,9 +31,18 @@ function didAsyncToolSucceed(input: {
durableStatus?: string | null
durableResult?: Record<string, unknown>
durableError?: string | null
completion?: { status: string } | undefined
toolStateSuccess?: boolean | undefined
toolStateStatus?: string | undefined
}) {
const { durableStatus, durableResult, durableError, toolStateStatus } = input
const {
durableStatus,
durableResult,
durableError,
completion,
toolStateSuccess,
toolStateStatus,
} = input
if (durableStatus === ASYNC_TOOL_STATUS.completed) {
return true
@@ -57,15 +61,7 @@ function didAsyncToolSucceed(input: {
if (toolStateStatus === 'success') return true
if (toolStateStatus === 'error' || toolStateStatus === 'cancelled') return false
return false
}
interface ReadyContinuationTool {
toolCallId: string
toolState?: ToolCallState
durableRow?: Awaited<ReturnType<typeof getAsyncToolCall>>
needsDurableClaim: boolean
alreadyClaimedByWorker: boolean
return completion?.status === 'success' || toolStateSuccess === true
}
export interface OrchestrateStreamOptions extends OrchestratorOptions {
@@ -194,21 +190,32 @@ export async function orchestrateCopilotStream(
if (!continuation) break
let resumeReady = false
let resumeRetries = 0
for (;;) {
claimedToolCallIds = []
claimedByWorkerId = null
const resumeWorkerId = continuation.runId || context.runId || context.messageId
const readyTools: ReadyContinuationTool[] = []
claimedByWorkerId = resumeWorkerId
const claimableToolCallIds: string[] = []
const localPendingPromises: Promise<unknown>[] = []
const missingToolCallIds: string[] = []
for (const toolCallId of continuation.pendingToolCallIds) {
const claimed = await claimCompletedAsyncToolCall(toolCallId, resumeWorkerId).catch(
() => null
)
if (claimed) {
claimableToolCallIds.push(toolCallId)
claimedToolCallIds.push(toolCallId)
continue
}
const durableRow = await getAsyncToolCall(toolCallId).catch(() => null)
const localPendingPromise = context.pendingToolPromises.get(toolCallId)
const toolState = context.toolCalls.get(toolCallId)
if (localPendingPromise) {
if (!durableRow && localPendingPromise) {
claimableToolCallIds.push(toolCallId)
continue
}
if (
durableRow &&
durableRow.status === ASYNC_TOOL_STATUS.running &&
localPendingPromise
) {
localPendingPromises.push(localPendingPromise)
logger.info('Waiting for local async tool completion before retrying resume claim', {
toolCallId,
@@ -216,55 +223,21 @@ export async function orchestrateCopilotStream(
})
continue
}
if (durableRow && isTerminalAsyncStatus(durableRow.status)) {
if (durableRow.claimedBy && durableRow.claimedBy !== resumeWorkerId) {
missingToolCallIds.push(toolCallId)
logger.warn('Async tool continuation is waiting on a claim held by another worker', {
toolCallId,
runId: continuation.runId,
claimedBy: durableRow.claimedBy,
})
continue
}
readyTools.push({
toolCallId,
toolState,
durableRow,
needsDurableClaim: durableRow.claimedBy !== resumeWorkerId,
alreadyClaimedByWorker: durableRow.claimedBy === resumeWorkerId,
})
continue
}
if (
!durableRow &&
toolState &&
isTerminalToolCallStatus(toolState.status) &&
!isToolAvailableOnSimSide(toolState.name)
) {
const toolState = context.toolCalls.get(toolCallId)
if (!durableRow && !localPendingPromise && toolState) {
logger.info('Including Go-handled tool in resume payload (no Sim-side row)', {
toolCallId,
toolName: toolState.name,
status: toolState.status,
runId: continuation.runId,
})
readyTools.push({
toolCallId,
toolState,
needsDurableClaim: false,
alreadyClaimedByWorker: false,
})
claimableToolCallIds.push(toolCallId)
continue
}
logger.warn('Skipping already-claimed or missing async tool resume', {
toolCallId,
runId: continuation.runId,
durableStatus: durableRow?.status,
toolStateStatus: toolState?.status,
})
missingToolCallIds.push(toolCallId)
}
if (localPendingPromises.length > 0) {
@@ -272,104 +245,30 @@ export async function orchestrateCopilotStream(
continue
}
if (missingToolCallIds.length > 0) {
if (resumeRetries < 3) {
resumeRetries++
logger.info('Retrying async resume after some tool calls were not yet ready', {
checkpointId: continuation.checkpointId,
runId: continuation.runId,
retry: resumeRetries,
missingToolCallIds,
})
await new Promise((resolve) => setTimeout(resolve, 250 * resumeRetries))
continue
}
throw new Error(
`Failed to resume async tool continuation: pending tool calls were not ready (${missingToolCallIds.join(', ')})`
)
if (claimableToolCallIds.length === 0) {
logger.warn('Skipping async resume because no tool calls were claimable', {
checkpointId: continuation.checkpointId,
runId: continuation.runId,
})
context.awaitingAsyncContinuation = undefined
break
}
if (readyTools.length === 0) {
if (resumeRetries < 3 && continuation.pendingToolCallIds.length > 0) {
resumeRetries++
logger.info('Retrying async resume because no tool calls were ready yet', {
checkpointId: continuation.checkpointId,
runId: continuation.runId,
retry: resumeRetries,
})
await new Promise((resolve) => setTimeout(resolve, 250 * resumeRetries))
continue
}
throw new Error('Failed to resume async tool continuation: no tool calls were ready')
}
const claimCandidates = readyTools.filter((tool) => tool.needsDurableClaim)
const newlyClaimedToolCallIds: string[] = []
const claimFailures: string[] = []
for (const tool of claimCandidates) {
const claimed = await claimCompletedAsyncToolCall(tool.toolCallId, resumeWorkerId).catch(
() => null
)
if (!claimed) {
claimFailures.push(tool.toolCallId)
continue
}
newlyClaimedToolCallIds.push(tool.toolCallId)
}
if (claimFailures.length > 0) {
if (newlyClaimedToolCallIds.length > 0) {
logger.info('Releasing async tool claims after claim contention during resume', {
checkpointId: continuation.checkpointId,
runId: continuation.runId,
newlyClaimedToolCallIds,
claimFailures,
})
await Promise.all(
newlyClaimedToolCallIds.map((toolCallId) =>
releaseCompletedAsyncToolClaim(toolCallId, resumeWorkerId).catch(() => null)
)
)
}
if (resumeRetries < 3) {
resumeRetries++
logger.info('Retrying async resume after claim contention', {
checkpointId: continuation.checkpointId,
runId: continuation.runId,
retry: resumeRetries,
claimFailures,
})
await new Promise((resolve) => setTimeout(resolve, 250 * resumeRetries))
continue
}
throw new Error(
`Failed to resume async tool continuation: unable to claim tool calls (${claimFailures.join(', ')})`
)
}
claimedToolCallIds = [
...readyTools
.filter((tool) => tool.alreadyClaimedByWorker)
.map((tool) => tool.toolCallId),
...newlyClaimedToolCallIds,
]
claimedByWorkerId = claimedToolCallIds.length > 0 ? resumeWorkerId : null
logger.info('Resuming async tool continuation', {
checkpointId: continuation.checkpointId,
runId: continuation.runId,
toolCallIds: readyTools.map((tool) => tool.toolCallId),
toolCallIds: claimableToolCallIds,
})
const durableRows = await getAsyncToolCalls(
readyTools.map((tool) => tool.toolCallId)
).catch(() => [])
const durableRows = await getAsyncToolCalls(claimableToolCallIds).catch(() => [])
const durableByToolCallId = new Map(durableRows.map((row) => [row.toolCallId, row]))
const results = await Promise.all(
readyTools.map(async (tool) => {
const durable = durableByToolCallId.get(tool.toolCallId) || tool.durableRow
claimableToolCallIds.map(async (toolCallId) => {
const completion = await context.pendingToolPromises.get(toolCallId)
const toolState = context.toolCalls.get(toolCallId)
const durable = durableByToolCallId.get(toolCallId)
const durableStatus = durable?.status
const durableResult =
durable?.result && typeof durable.result === 'object'
@@ -379,15 +278,19 @@ export async function orchestrateCopilotStream(
durableStatus,
durableResult,
durableError: durable?.error,
toolStateStatus: tool.toolState?.status,
completion,
toolStateSuccess: toolState?.result?.success,
toolStateStatus: toolState?.status,
})
const data =
durableResult ||
(tool.toolState?.result?.output as Record<string, unknown> | undefined) ||
completion?.data ||
(toolState?.result?.output as Record<string, unknown> | undefined) ||
(success
? { message: 'Tool completed' }
? { message: completion?.message || 'Tool completed' }
: {
error: durable?.error || tool.toolState?.error || 'Tool failed',
error:
completion?.message || durable?.error || toolState?.error || 'Tool failed',
})
if (
@@ -396,14 +299,14 @@ export async function orchestrateCopilotStream(
!isDeliveredAsyncStatus(durableStatus)
) {
logger.warn('Async tool row was claimed for resume without terminal durable state', {
toolCallId: tool.toolCallId,
toolCallId,
status: durableStatus,
})
}
return {
callId: tool.toolCallId,
name: durable?.toolName || tool.toolState?.name || '',
callId: toolCallId,
name: durable?.toolName || toolState?.name || '',
data,
success,
}

View File

@@ -209,76 +209,4 @@ describe('sse-handlers tool lifecycle', () => {
expect(markToolComplete).toHaveBeenCalledTimes(1)
expect(context.toolCalls.get('tool-upsert-fail')?.status).toBe('success')
})
it('does not execute a tool if a terminal tool_result arrives before local execution starts', async () => {
let resolveUpsert: ((value: null) => void) | undefined
upsertAsyncToolCall.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveUpsert = resolve
})
)
const onEvent = vi.fn()
await sseHandlers.tool_call(
{
type: 'tool_call',
data: { id: 'tool-race', name: 'read', arguments: { workflowId: 'workflow-1' } },
} as any,
context,
execContext,
{ onEvent, interactive: false, timeout: 1000 }
)
await sseHandlers.tool_result(
{
type: 'tool_result',
toolCallId: 'tool-race',
data: { id: 'tool-race', success: true, result: { ok: true } },
} as any,
context,
execContext,
{ onEvent, interactive: false, timeout: 1000 }
)
resolveUpsert?.(null)
await new Promise((resolve) => setTimeout(resolve, 0))
expect(executeToolServerSide).not.toHaveBeenCalled()
expect(markToolComplete).not.toHaveBeenCalled()
expect(context.toolCalls.get('tool-race')?.status).toBe('success')
expect(context.toolCalls.get('tool-race')?.result?.output).toEqual({ ok: true })
})
it('does not execute a tool if a tool_result arrives before the tool_call event', async () => {
const onEvent = vi.fn()
await sseHandlers.tool_result(
{
type: 'tool_result',
toolCallId: 'tool-early-result',
toolName: 'read',
data: { id: 'tool-early-result', name: 'read', success: true, result: { ok: true } },
} as any,
context,
execContext,
{ onEvent, interactive: false, timeout: 1000 }
)
await sseHandlers.tool_call(
{
type: 'tool_call',
data: { id: 'tool-early-result', name: 'read', arguments: { workflowId: 'workflow-1' } },
} as any,
context,
execContext,
{ onEvent, interactive: false, timeout: 1000 }
)
await new Promise((resolve) => setTimeout(resolve, 0))
expect(executeToolServerSide).not.toHaveBeenCalled()
expect(markToolComplete).not.toHaveBeenCalled()
expect(context.toolCalls.get('tool-early-result')?.status).toBe('success')
})
})

View File

@@ -213,27 +213,6 @@ function inferToolSuccess(data: Record<string, unknown> | undefined): {
return { success, hasResultData, hasError }
}
function ensureTerminalToolCallState(
context: StreamingContext,
toolCallId: string,
toolName: string
): ToolCallState {
const existing = context.toolCalls.get(toolCallId)
if (existing) {
return existing
}
const toolCall: ToolCallState = {
id: toolCallId,
name: toolName || 'unknown_tool',
status: 'pending',
startTime: Date.now(),
}
context.toolCalls.set(toolCallId, toolCall)
addContentBlock(context, { type: 'tool_call', toolCall })
return toolCall
}
export type SSEHandler = (
event: SSEEvent,
context: StreamingContext,
@@ -267,12 +246,8 @@ export const sseHandlers: Record<string, SSEHandler> = {
const data = getEventData(event)
const toolCallId = event.toolCallId || (data?.id as string | undefined)
if (!toolCallId) return
const toolName =
event.toolName ||
(data?.name as string | undefined) ||
context.toolCalls.get(toolCallId)?.name ||
''
const current = ensureTerminalToolCallState(context, toolCallId, toolName)
const current = context.toolCalls.get(toolCallId)
if (!current) return
const { success, hasResultData, hasError } = inferToolSuccess(data)
@@ -288,22 +263,16 @@ export const sseHandlers: Record<string, SSEHandler> = {
const resultObj = asRecord(data?.result)
current.error = (data?.error || resultObj.error) as string | undefined
}
markToolResultSeen(toolCallId)
},
tool_error: (event, context) => {
const data = getEventData(event)
const toolCallId = event.toolCallId || (data?.id as string | undefined)
if (!toolCallId) return
const toolName =
event.toolName ||
(data?.name as string | undefined) ||
context.toolCalls.get(toolCallId)?.name ||
''
const current = ensureTerminalToolCallState(context, toolCallId, toolName)
const current = context.toolCalls.get(toolCallId)
if (!current) return
current.status = 'error'
current.error = (data?.error as string | undefined) || 'Tool execution failed'
current.endTime = Date.now()
markToolResultSeen(toolCallId)
},
tool_call_delta: () => {
// Argument streaming delta — no action needed on orchestrator side
@@ -344,9 +313,6 @@ export const sseHandlers: Record<string, SSEHandler> = {
existing?.endTime ||
(existing && existing.status !== 'pending' && existing.status !== 'executing')
) {
if (!existing.name && toolName) {
existing.name = toolName
}
if (!existing.params && args) {
existing.params = args
}
@@ -592,12 +558,6 @@ export const subAgentHandlers: Record<string, SSEHandler> = {
const existing = context.toolCalls.get(toolCallId)
// Ignore late/duplicate tool_call events once we already have a result.
if (wasToolResultSeen(toolCallId) || existing?.endTime) {
if (existing && !existing.name && toolName) {
existing.name = toolName
}
if (existing && !existing.params && args) {
existing.params = args
}
return
}
@@ -726,14 +686,13 @@ export const subAgentHandlers: Record<string, SSEHandler> = {
const data = getEventData(event)
const toolCallId = event.toolCallId || (data?.id as string | undefined)
if (!toolCallId) return
const toolName = event.toolName || (data?.name as string | undefined) || ''
// Update in subAgentToolCalls.
const toolCalls = context.subAgentToolCalls[parentToolCallId] || []
const subAgentToolCall = toolCalls.find((tc) => tc.id === toolCallId)
// Also update in main toolCalls (where we added it for execution).
const mainToolCall = ensureTerminalToolCallState(context, toolCallId, toolName)
const mainToolCall = context.toolCalls.get(toolCallId)
const { success, hasResultData, hasError } = inferToolSuccess(data)
@@ -760,9 +719,6 @@ export const subAgentHandlers: Record<string, SSEHandler> = {
mainToolCall.error = (data?.error || resultObj.error) as string | undefined
}
}
if (subAgentToolCall || mainToolCall) {
markToolResultSeen(toolCallId)
}
},
}

View File

@@ -4,15 +4,18 @@ import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { completeAsyncToolCall, markAsyncToolRunning } from '@/lib/copilot/async-runs/repository'
import { waitForToolConfirmation } from '@/lib/copilot/orchestrator/persistence'
import { asRecord, markToolResultSeen } from '@/lib/copilot/orchestrator/sse/utils'
import { executeToolServerSide, markToolComplete } from '@/lib/copilot/orchestrator/tool-executor'
import {
type ExecutionContext,
isTerminalToolCallStatus,
type OrchestratorOptions,
type SSEEvent,
type StreamingContext,
type ToolCallResult,
asRecord,
markToolResultSeen,
wasToolResultSeen,
} from '@/lib/copilot/orchestrator/sse/utils'
import { executeToolServerSide, markToolComplete } from '@/lib/copilot/orchestrator/tool-executor'
import type {
ExecutionContext,
OrchestratorOptions,
SSEEvent,
StreamingContext,
ToolCallResult,
} from '@/lib/copilot/orchestrator/types'
import {
extractDeletedResourcesFromToolResult,
@@ -114,20 +117,6 @@ const FORMAT_TO_CONTENT_TYPE: Record<OutputFormat, string> = {
html: 'text/html',
}
function normalizeOutputWorkspaceFileName(outputPath: string): string {
const trimmed = outputPath.trim().replace(/^\/+/, '')
const withoutPrefix = trimmed.startsWith('files/') ? trimmed.slice('files/'.length) : trimmed
if (!withoutPrefix) {
throw new Error('outputPath must include a file name, e.g. "files/result.json"')
}
if (withoutPrefix.includes('/')) {
throw new Error(
'outputPath must target a flat workspace file, e.g. "files/result.json". Nested paths like "files/reports/result.json" are not supported.'
)
}
return withoutPrefix
}
function resolveOutputFormat(fileName: string, explicit?: string): OutputFormat {
if (explicit && explicit in FORMAT_TO_CONTENT_TYPE) return explicit as OutputFormat
const ext = fileName.slice(fileName.lastIndexOf('.')).toLowerCase()
@@ -164,10 +153,10 @@ async function maybeWriteOutputToFile(
const explicitFormat =
(params?.outputFormat as string | undefined) ?? (args?.outputFormat as string | undefined)
const fileName = outputPath.replace(/^files\//, '')
const format = resolveOutputFormat(fileName, explicitFormat)
try {
const fileName = normalizeOutputWorkspaceFileName(outputPath)
const format = resolveOutputFormat(fileName, explicitFormat)
if (context.abortSignal?.aborted) {
throw new Error('Request aborted before tool mutation could be applied')
}
@@ -204,16 +193,12 @@ async function maybeWriteOutputToFile(
},
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
logger.warn('Failed to write tool output to file', {
toolName,
outputPath,
error: message,
error: err instanceof Error ? err.message : String(err),
})
return {
success: false,
error: `Failed to write output file: ${message}`,
}
return result
}
}
@@ -244,48 +229,6 @@ function cancelledCompletion(message: string): AsyncToolCompletion {
}
}
function terminalCompletionFromToolCall(toolCall: {
status: string
error?: string
result?: { output?: unknown; error?: string }
}): AsyncToolCompletion {
if (toolCall.status === 'cancelled') {
return cancelledCompletion(toolCall.error || 'Tool execution cancelled')
}
if (toolCall.status === 'success') {
return {
status: 'success',
message: 'Tool completed',
data:
toolCall.result?.output &&
typeof toolCall.result.output === 'object' &&
!Array.isArray(toolCall.result.output)
? (toolCall.result.output as Record<string, unknown>)
: undefined,
}
}
if (toolCall.status === 'skipped') {
return {
status: 'success',
message: 'Tool skipped',
data:
toolCall.result?.output &&
typeof toolCall.result.output === 'object' &&
!Array.isArray(toolCall.result.output)
? (toolCall.result.output as Record<string, unknown>)
: undefined,
}
}
return {
status: toolCall.status === 'rejected' ? 'rejected' : 'error',
message: toolCall.error || toolCall.result?.error || 'Tool failed',
data: { error: toolCall.error || toolCall.result?.error || 'Tool failed' },
}
}
function reportCancelledTool(
toolCall: { id: string; name: string },
message: string,
@@ -548,8 +491,8 @@ export async function executeToolAndReport(
if (toolCall.status === 'executing') {
return { status: 'running', message: 'Tool already executing' }
}
if (toolCall.endTime || isTerminalToolCallStatus(toolCall.status)) {
return terminalCompletionFromToolCall(toolCall)
if (wasToolResultSeen(toolCall.id)) {
return { status: 'success', message: 'Tool result already processed' }
}
if (abortRequested(context, execContext, options)) {
@@ -577,9 +520,6 @@ export async function executeToolAndReport(
try {
let result = await executeToolServerSide(toolCall, execContext)
if (toolCall.endTime || isTerminalToolCallStatus(toolCall.status)) {
return terminalCompletionFromToolCall(toolCall)
}
if (abortRequested(context, execContext, options)) {
toolCall.status = 'cancelled'
toolCall.endTime = Date.now()
@@ -641,17 +581,10 @@ export async function executeToolAndReport(
toolCall.endTime = Date.now()
if (result.success) {
const raw = result.output
const preview =
typeof raw === 'string'
? raw.slice(0, 200)
: raw && typeof raw === 'object'
? JSON.stringify(raw).slice(0, 200)
: undefined
logger.info('Tool execution succeeded', {
toolCallId: toolCall.id,
toolName: toolCall.name,
outputPreview: preview,
output: result.output,
})
} else {
logger.warn('Tool execution failed', {

View File

@@ -3,7 +3,6 @@
*/
import { describe, expect, it } from 'vitest'
import {
markToolResultSeen,
normalizeSseEvent,
shouldSkipToolCallEvent,
shouldSkipToolResultEvent,
@@ -38,7 +37,6 @@ describe('sse-utils', () => {
it.concurrent('dedupes tool_result events', () => {
const event = { type: 'tool_result', data: { id: 'tool_result_1', name: 'plan' } }
expect(shouldSkipToolResultEvent(event as any)).toBe(false)
markToolResultSeen('tool_result_1')
expect(shouldSkipToolResultEvent(event as any)).toBe(true)
})
})

View File

@@ -125,5 +125,7 @@ export function shouldSkipToolResultEvent(event: SSEEvent): boolean {
if (event.type !== 'tool_result') return false
const toolCallId = getToolCallIdFromEvent(event)
if (!toolCallId) return false
return wasToolResultSeen(toolCallId)
if (wasToolResultSeen(toolCallId)) return true
markToolResultSeen(toolCallId)
return false
}

View File

@@ -1050,21 +1050,21 @@ const SIM_WORKFLOW_TOOL_HANDLERS: Record<
return {
success: false,
error:
'Opening a workspace file requires workspace context. Pass the canonical file UUID from files/by-id/<fileId>/meta.json.',
'Opening a workspace file requires workspace context. Pass the file UUID from files/<name>/meta.json.',
}
}
if (!isUuid(params.id)) {
return {
success: false,
error:
'open_resource for files requires the canonical file UUID. Read files/by-id/<fileId>/meta.json or files/<name>/meta.json and pass the "id" field. Do not pass VFS paths or display names.',
'open_resource for files requires the canonical UUID from files/<name>/meta.json (the "id" field). Do not pass VFS paths, display names, or file_<name> strings.',
}
}
const record = await getWorkspaceFile(c.workspaceId, params.id)
if (!record) {
return {
success: false,
error: `No workspace file with id "${params.id}". Confirm the UUID from files/by-id/<fileId>/meta.json.`,
error: `No workspace file with id "${params.id}". Confirm the UUID from meta.json.`,
}
}
resourceId = record.id

View File

@@ -16,7 +16,6 @@ import { getTableById, queryRows } from '@/lib/table/service'
import {
downloadWorkspaceFile,
findWorkspaceFileRecord,
getSandboxWorkspaceFilePath,
listWorkspaceFiles,
} from '@/lib/uploads/contexts/workspace/workspace-file-manager'
import { getWorkflowById } from '@/lib/workflows/utils'
@@ -180,30 +179,23 @@ export async function executeIntegrationToolDirect(
])
let totalSize = 0
const inputFileIds = executionParams.inputFiles as string[] | undefined
if (inputFileIds?.length) {
const inputFilePaths = executionParams.inputFiles as string[] | undefined
if (inputFilePaths?.length) {
const allFiles = await listWorkspaceFiles(workspaceId)
for (const fileRef of inputFileIds) {
const record = findWorkspaceFileRecord(allFiles, fileRef)
if (!record) {
logger.warn('Sandbox input file not found', { fileRef })
for (const filePath of inputFilePaths) {
const fileName = filePath.replace(/^files\//, '')
const ext = fileName.split('.').pop()?.toLowerCase() ?? ''
if (!TEXT_EXTENSIONS.has(ext)) {
logger.warn('Skipping non-text sandbox input file', { fileName, ext })
continue
}
const ext = record.name.split('.').pop()?.toLowerCase() ?? ''
if (!TEXT_EXTENSIONS.has(ext)) {
logger.warn('Skipping non-text sandbox input file', {
fileId: record.id,
fileName: record.name,
ext,
})
const record = findWorkspaceFileRecord(allFiles, filePath)
if (!record) {
logger.warn('Sandbox input file not found', { fileName })
continue
}
if (record.size > MAX_FILE_SIZE) {
logger.warn('Sandbox input file exceeds size limit', {
fileId: record.id,
fileName: record.name,
size: record.size,
})
logger.warn('Sandbox input file exceeds size limit', { fileName, size: record.size })
continue
}
if (totalSize + record.size > MAX_TOTAL_SIZE) {
@@ -212,15 +204,7 @@ export async function executeIntegrationToolDirect(
}
const buffer = await downloadWorkspaceFile(record)
totalSize += buffer.length
const textContent = buffer.toString('utf-8')
sandboxFiles.push({
path: getSandboxWorkspaceFilePath(record),
content: textContent,
})
sandboxFiles.push({
path: `/home/user/${record.name}`,
content: textContent,
})
sandboxFiles.push({ path: `/home/user/${fileName}`, content: buffer.toString('utf-8') })
}
}

View File

@@ -59,18 +59,6 @@ export type ToolCallStatus =
| 'rejected'
| 'cancelled'
const TERMINAL_TOOL_STATUSES: ReadonlySet<ToolCallStatus> = new Set([
'success',
'error',
'cancelled',
'skipped',
'rejected',
])
export function isTerminalToolCallStatus(status?: string): boolean {
return TERMINAL_TOOL_STATUSES.has(status as ToolCallStatus)
}
export interface ToolCallState {
id: string
name: string

View File

@@ -35,15 +35,6 @@ function inferContentType(fileName: string, explicitType?: string): string {
return EXT_TO_MIME[ext] || 'text/plain'
}
function validateFlatWorkspaceFileName(fileName: string): string | null {
const trimmed = fileName.trim()
if (!trimmed) return 'File name cannot be empty'
if (trimmed.includes('/')) {
return 'Workspace files use a flat namespace. Use a plain file name like "report.csv", not a path like "files/reports/report.csv".'
}
return null
}
export const workspaceFileServerTool: BaseServerTool<WorkspaceFileArgs, WorkspaceFileResult> = {
name: 'workspace_file',
async execute(
@@ -76,10 +67,6 @@ export const workspaceFileServerTool: BaseServerTool<WorkspaceFileArgs, Workspac
if (content === undefined || content === null) {
return { success: false, message: 'content is required for write operation' }
}
const fileNameValidationError = validateFlatWorkspaceFileName(fileName)
if (fileNameValidationError) {
return { success: false, message: fileNameValidationError }
}
const isPptx = fileName.toLowerCase().endsWith('.pptx')
let contentType: string
@@ -201,10 +188,6 @@ export const workspaceFileServerTool: BaseServerTool<WorkspaceFileArgs, Workspac
if (!newName) {
return { success: false, message: 'newName is required for rename operation' }
}
const fileNameValidationError = validateFlatWorkspaceFileName(newName)
if (fileNameValidationError) {
return { success: false, message: fileNameValidationError }
}
const fileRecord = await getWorkspaceFile(workspaceId, fileId)
if (!fileRecord) {

View File

@@ -27,15 +27,6 @@ const ASPECT_RATIO_TO_SIZE: Record<string, string> = {
'3:4': '768x1024',
}
function validateGeneratedWorkspaceFileName(fileName: string): string | null {
const trimmed = fileName.trim()
if (!trimmed) return 'File name cannot be empty'
if (trimmed.includes('/')) {
return 'Workspace files use a flat namespace. Use a plain file name like "generated-image.png", not a path like "images/generated-image.png".'
}
return null
}
interface GenerateImageArgs {
prompt: string
referenceFileIds?: string[]
@@ -160,10 +151,6 @@ export const generateImageServerTool: BaseServerTool<GenerateImageArgs, Generate
const ext = mimeType.includes('jpeg') || mimeType.includes('jpg') ? '.jpg' : '.png'
const fileName = params.fileName || `generated-image${ext}`
const fileNameValidationError = validateGeneratedWorkspaceFileName(fileName)
if (fileNameValidationError) {
return { success: false, message: fileNameValidationError }
}
const imageBuffer = Buffer.from(imageBase64, 'base64')
if (params.overwriteFileId) {

View File

@@ -230,12 +230,10 @@ export const knowledgeBaseServerTool: BaseServerTool<KnowledgeBaseArgs, Knowledg
}
}
const fileReference = args.fileId || args.filePath
if (!fileReference) {
if (!args.filePath) {
return {
success: false,
message:
'fileId is required for add_file. Read files/{name}/meta.json or files/by-id/*/meta.json to get the canonical file ID.',
message: 'filePath is required (e.g. "files/report.pdf")',
}
}
@@ -248,12 +246,12 @@ export const knowledgeBaseServerTool: BaseServerTool<KnowledgeBaseArgs, Knowledg
}
const kbWorkspaceId: string = targetKb.workspaceId
const fileRecord = await resolveWorkspaceFileReference(kbWorkspaceId, fileReference)
const fileRecord = await resolveWorkspaceFileReference(kbWorkspaceId, args.filePath)
if (!fileRecord) {
return {
success: false,
message: `Workspace file not found: "${fileReference}"`,
message: `Workspace file not found: "${args.filePath}"`,
}
}

View File

@@ -41,53 +41,14 @@ const SCHEMA_SAMPLE_SIZE = 100
type ColumnType = 'string' | 'number' | 'boolean' | 'date' | 'json'
function sanitizeColumnName(raw: string): string {
let name = raw
.trim()
.replace(/[^a-zA-Z0-9_]/g, '_')
.replace(/_+/g, '_')
.replace(/^_|_$/g, '')
if (!name || /^\d/.test(name)) name = `col_${name}`
return name
}
function sanitizeHeaders(
headers: string[],
rows: Record<string, unknown>[]
): { headers: string[]; rows: Record<string, unknown>[] } {
const renamed = new Map<string, string>()
const seen = new Set<string>()
for (const raw of headers) {
let safe = sanitizeColumnName(raw)
while (seen.has(safe)) safe = `${safe}_`
seen.add(safe)
renamed.set(raw, safe)
}
const noChange = headers.every((h) => renamed.get(h) === h)
if (noChange) return { headers, rows }
return {
headers: headers.map((h) => renamed.get(h)!),
rows: rows.map((row) => {
const out: Record<string, unknown> = {}
for (const [raw, safe] of renamed) {
if (raw in row) out[safe] = row[raw]
}
return out
}),
}
}
async function resolveWorkspaceFile(
fileReference: string,
filePath: string,
workspaceId: string
): Promise<{ buffer: Buffer; name: string; type: string }> {
const record = await resolveWorkspaceFileReference(workspaceId, fileReference)
const record = await resolveWorkspaceFileReference(workspaceId, filePath)
if (!record) {
throw new Error(
`File not found: "${fileReference}". Use glob("files/by-id/*/meta.json") to list canonical file IDs.`
`File not found: "${filePath}". Use glob("files/*/meta.json") to list available files.`
)
}
const buffer = await downloadWorkspaceFile(record)
@@ -126,7 +87,7 @@ async function parseJsonRows(
}
for (const key of Object.keys(row)) headerSet.add(key)
}
return sanitizeHeaders([...headerSet], parsed)
return { headers: [...headerSet], rows: parsed }
}
async function parseCsvRows(
@@ -149,7 +110,7 @@ async function parseCsvRows(
if (headers.length === 0) {
throw new Error('CSV file has no headers')
}
return sanitizeHeaders(headers, parsed)
return { headers, rows: parsed }
}
function inferColumnType(values: unknown[]): ColumnType {
@@ -684,21 +645,15 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult>
}
case 'create_from_file': {
const fileId = (args as Record<string, unknown>).fileId as string | undefined
const filePath = (args as Record<string, unknown>).filePath as string | undefined
const fileReference = fileId || filePath
if (!fileReference) {
return {
success: false,
message:
'fileId is required for create_from_file. Read files/{name}/meta.json or files/by-id/*/meta.json to get the canonical file ID.',
}
if (!filePath) {
return { success: false, message: 'filePath is required (e.g. "files/data.csv")' }
}
if (!workspaceId) {
return { success: false, message: 'Workspace ID is required' }
}
const file = await resolveWorkspaceFile(fileReference, workspaceId)
const file = await resolveWorkspaceFile(filePath, workspaceId)
const { headers, rows } = await parseFileRows(file.buffer, file.name, file.type)
if (rows.length === 0) {
return { success: false, message: 'File contains no data rows' }
@@ -745,16 +700,10 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult>
}
case 'import_file': {
const fileId = (args as Record<string, unknown>).fileId as string | undefined
const filePath = (args as Record<string, unknown>).filePath as string | undefined
const tableId = (args as Record<string, unknown>).tableId as string | undefined
const fileReference = fileId || filePath
if (!fileReference) {
return {
success: false,
message:
'fileId is required for import_file. Read files/{name}/meta.json or files/by-id/*/meta.json to get the canonical file ID.',
}
if (!filePath) {
return { success: false, message: 'filePath is required (e.g. "files/data.csv")' }
}
if (!tableId) {
return { success: false, message: 'tableId is required for import_file' }
@@ -768,7 +717,7 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult>
return { success: false, message: `Table not found: ${tableId}` }
}
const file = await resolveWorkspaceFile(fileReference, workspaceId)
const file = await resolveWorkspaceFile(filePath, workspaceId)
const { headers, rows } = await parseFileRows(file.buffer, file.name, file.type)
if (rows.length === 0) {
return { success: false, message: 'File contains no data rows' }

View File

@@ -11,7 +11,6 @@ import { getServePathPrefix } from '@/lib/uploads'
import {
downloadWorkspaceFile,
findWorkspaceFileRecord,
getSandboxWorkspaceFilePath,
getWorkspaceFile,
listWorkspaceFiles,
updateWorkspaceFileContent,
@@ -50,15 +49,6 @@ const TEXT_EXTENSIONS = new Set(['csv', 'json', 'txt', 'md', 'html', 'xml', 'tsv
const MAX_FILE_SIZE = 10 * 1024 * 1024
const MAX_TOTAL_SIZE = 50 * 1024 * 1024
function validateGeneratedWorkspaceFileName(fileName: string): string | null {
const trimmed = fileName.trim()
if (!trimmed) return 'File name cannot be empty'
if (trimmed.includes('/')) {
return 'Workspace files use a flat namespace. Use a plain file name like "chart.png", not a path like "charts/chart.png".'
}
return null
}
async function collectSandboxFiles(
workspaceId: string,
inputFiles?: string[],
@@ -69,27 +59,20 @@ async function collectSandboxFiles(
if (inputFiles?.length) {
const allFiles = await listWorkspaceFiles(workspaceId)
for (const fileRef of inputFiles) {
const record = findWorkspaceFileRecord(allFiles, fileRef)
if (!record) {
logger.warn('Sandbox input file not found', { fileRef })
for (const filePath of inputFiles) {
const fileName = filePath.replace(/^files\//, '')
const ext = fileName.split('.').pop()?.toLowerCase() ?? ''
if (!TEXT_EXTENSIONS.has(ext)) {
logger.warn('Skipping non-text sandbox input file', { fileName, ext })
continue
}
const ext = record.name.split('.').pop()?.toLowerCase() ?? ''
if (!TEXT_EXTENSIONS.has(ext)) {
logger.warn('Skipping non-text sandbox input file', {
fileId: record.id,
fileName: record.name,
ext,
})
const record = findWorkspaceFileRecord(allFiles, filePath)
if (!record) {
logger.warn('Sandbox input file not found', { fileName })
continue
}
if (record.size > MAX_FILE_SIZE) {
logger.warn('Sandbox input file exceeds size limit', {
fileId: record.id,
fileName: record.name,
size: record.size,
})
logger.warn('Sandbox input file exceeds size limit', { fileName, size: record.size })
continue
}
if (totalSize + record.size > MAX_TOTAL_SIZE) {
@@ -98,15 +81,7 @@ async function collectSandboxFiles(
}
const buffer = await downloadWorkspaceFile(record)
totalSize += buffer.length
const textContent = buffer.toString('utf-8')
sandboxFiles.push({
path: getSandboxWorkspaceFilePath(record),
content: textContent,
})
sandboxFiles.push({
path: `/home/user/${record.name}`,
content: textContent,
})
sandboxFiles.push({ path: `/home/user/${fileName}`, content: buffer.toString('utf-8') })
}
}
@@ -210,10 +185,6 @@ export const generateVisualizationServerTool: BaseServerTool<
}
const fileName = params.fileName || 'chart.png'
const fileNameValidationError = validateGeneratedWorkspaceFileName(fileName)
if (fileNameValidationError) {
return { success: false, message: fileNameValidationError }
}
const imageBuffer = Buffer.from(imageBase64, 'base64')
if (params.overwriteFileId) {

View File

@@ -50,9 +50,7 @@ export const KnowledgeBaseArgsSchema = z.object({
workspaceId: z.string().optional(),
/** Knowledge base ID (required for get, query, add_file, list_tags, create_tag, get_tag_usage, add_connector) */
knowledgeBaseId: z.string().optional(),
/** Workspace file ID to add as a document (required for add_file). */
fileId: z.string().optional(),
/** Legacy workspace file reference for add_file. Prefer fileId. */
/** Workspace file path to add as a document (required for add_file). Example: "files/report.pdf" */
filePath: z.string().optional(),
/** Search query text (required for query) */
query: z.string().optional(),
@@ -147,7 +145,6 @@ export const UserTableArgsSchema = z.object({
sort: z.record(z.enum(['asc', 'desc'])).optional(),
limit: z.number().optional(),
offset: z.number().optional(),
fileId: z.string().optional(),
filePath: z.string().optional(),
column: z
.object({

View File

@@ -9,16 +9,6 @@ function vfsFromEntries(entries: [string, string][]): Map<string, string> {
}
describe('glob', () => {
it('matches canonical file metadata paths by id', () => {
const files = vfsFromEntries([
['files/by-id/wf_123/meta.json', '{}'],
['files/data.csv/meta.json', '{}'],
])
const hits = glob(files, 'files/by-id/*/meta.json')
expect(hits).toContain('files/by-id/wf_123/meta.json')
expect(hits).not.toContain('files/data.csv/meta.json')
})
it('matches one path segment for single star (files listing pattern)', () => {
const files = vfsFromEntries([
['files/a/meta.json', '{}'],

View File

@@ -262,7 +262,6 @@ export function serializeConnectorOverview(connectors: SerializableConnectorConf
/**
* Serialize workspace file metadata for VFS files/{name}/meta.json
* and files/by-id/{id}/meta.json.
*/
export function serializeFileMeta(file: {
id: string

View File

@@ -271,7 +271,6 @@ function getStaticComponentFiles(): Map<string, string> {
* knowledgebases/{name}/connectors.json
* tables/{name}/meta.json
* files/{name}/meta.json
* files/by-id/{id}/meta.json
* jobs/{title}/meta.json
* jobs/{title}/history.json
* jobs/{title}/executions.json
@@ -391,7 +390,7 @@ export class WorkspaceVFS {
/**
* Attempt to read dynamic workspace file content from storage.
* Handles images (base64), parseable documents (PDF, etc.), and text files.
* Returns null if the path doesn't match `files/{name}` / `files/by-id/{id}` or the file isn't found.
* Returns null if the path doesn't match `files/{name}` or the file isn't found.
*/
async readFileContent(path: string): Promise<FileReadResult | null> {
const match = path.match(/^files\/(.+?)(?:\/content)?$/)
@@ -677,16 +676,6 @@ export class WorkspaceVFS {
uploadedAt: file.uploadedAt,
})
)
this.files.set(
`files/by-id/${file.id}/meta.json`,
serializeFileMeta({
id: file.id,
name: file.name,
contentType: file.type,
size: file.size,
uploadedAt: file.uploadedAt,
})
)
}
return files.map((f) => ({ name: f.name, type: f.type, size: f.size }))

View File

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

View File

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

View File

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

View File

@@ -382,21 +382,16 @@ export async function listWorkspaceFiles(
}
/**
* Normalize a workspace file reference to either a display name or canonical file ID.
* Supports raw IDs, `files/{name}`, `files/{name}/content`, `files/{name}/meta.json`,
* and canonical VFS aliases like `files/by-id/{fileId}/content`.
* Normalize a workspace file reference to its display name.
* Supports raw names and VFS-style paths like `files/name`, `files/name/content`,
* and `files/name/meta.json`.
*
* Used by storage resolution (`findWorkspaceFileRecord`), not by `open_resource`, which
* requires the canonical database UUID only.
*/
export function normalizeWorkspaceFileReference(fileReference: string): string {
const trimmed = fileReference.trim().replace(/^\/+/, '')
if (trimmed.startsWith('files/by-id/')) {
const byIdRef = trimmed.slice('files/by-id/'.length)
const match = byIdRef.match(/^([^/]+)(?:\/(?:meta\.json|content))?$/)
if (match?.[1]) {
return match[1]
}
}
if (trimmed.startsWith('files/')) {
const withoutPrefix = trimmed.slice('files/'.length)
if (withoutPrefix.endsWith('/meta.json')) {
@@ -411,13 +406,6 @@ export function normalizeWorkspaceFileReference(fileReference: string): string {
return trimmed
}
/**
* Canonical sandbox mount path for an existing workspace file.
*/
export function getSandboxWorkspaceFilePath(file: Pick<WorkspaceFileRecord, 'id' | 'name'>): string {
return `/home/user/files/${file.id}/${file.name}`
}
/**
* Find a workspace file record in an existing list from either its id or a VFS/name reference.
* For copilot `open_resource` and the resource panel, use {@link getWorkspaceFile} with a UUID only.
@@ -432,13 +420,10 @@ export function findWorkspaceFileRecord(
}
const normalizedReference = normalizeWorkspaceFileReference(fileReference)
const normalizedIdMatch = files.find((file) => file.id === normalizedReference)
if (normalizedIdMatch) {
return normalizedIdMatch
}
const segmentKey = normalizeVfsSegment(normalizedReference)
return files.find((file) => normalizeVfsSegment(file.name) === segmentKey) ?? null
return (
files.find((file) => normalizeVfsSegment(file.name) === segmentKey) ?? null
)
}
/**

View File

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

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

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

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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