mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
Compare commits
2 Commits
dev
...
feat/agent
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ef91e56b8 | ||
|
|
1b25460a62 |
63
README.md
63
README.md
@@ -1,16 +1,20 @@
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://sim.ai" target="_blank" rel="noopener noreferrer">
|
<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>
|
</a>
|
||||||
</p>
|
</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">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">
|
<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://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://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>
|
||||||
|
|
||||||
<p align="center">
|
<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)
|
### 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
|
### Self-hosted: NPM Package
|
||||||
|
|
||||||
@@ -70,43 +74,7 @@ docker compose -f docker-compose.prod.yml up -d
|
|||||||
|
|
||||||
Open [http://localhost:3000](http://localhost:3000)
|
Open [http://localhost:3000](http://localhost:3000)
|
||||||
|
|
||||||
#### Using Local Models with Ollama
|
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.
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
### Self-hosted: Manual Setup
|
### Self-hosted: Manual Setup
|
||||||
|
|
||||||
@@ -159,18 +127,7 @@ Copilot is a Sim-managed service. To use Copilot on a self-hosted instance:
|
|||||||
|
|
||||||
## Environment Variables
|
## 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.
|
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.
|
||||||
|
|
||||||
| 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 |
|
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
|
|||||||
236
apps/sim/app/api/agents/[agentId]/deployments/slack/route.ts
Normal file
236
apps/sim/app/api/agents/[agentId]/deployments/slack/route.ts
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
import { db } from '@sim/db'
|
||||||
|
import { account, agent, agentDeployment, credential } from '@sim/db/schema'
|
||||||
|
import { createLogger } from '@sim/logger'
|
||||||
|
import { and, eq, isNull } from 'drizzle-orm'
|
||||||
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { v4 as uuidv4 } from 'uuid'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { getSession } from '@/lib/auth'
|
||||||
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
|
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
|
||||||
|
|
||||||
|
const logger = createLogger('AgentSlackDeployAPI')
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
interface RouteParams {
|
||||||
|
agentId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const DeploySlackSchema = z.object({
|
||||||
|
credentialId: z.string().min(1, 'Credential ID is required'),
|
||||||
|
channelIds: z.array(z.string().min(1)).default([]),
|
||||||
|
respondTo: z.enum(['mentions', 'all', 'threads', 'dm']),
|
||||||
|
botName: z.string().max(80).optional(),
|
||||||
|
replyInThread: z.boolean().default(true),
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/agents/{agentId}/deployments/slack
|
||||||
|
* Configure a Slack deployment for an agent.
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest, { params }: { params: Promise<RouteParams> }) {
|
||||||
|
const requestId = generateRequestId()
|
||||||
|
const { agentId } = await params
|
||||||
|
|
||||||
|
try {
|
||||||
|
const session = await getSession()
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json()
|
||||||
|
const parsed = DeploySlackSchema.safeParse(body)
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: parsed.error.errors[0]?.message ?? 'Invalid request' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (parsed.data.respondTo !== 'dm' && parsed.data.channelIds.length === 0) {
|
||||||
|
return NextResponse.json({ error: 'At least one channel is required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const [agentRow] = await db
|
||||||
|
.select()
|
||||||
|
.from(agent)
|
||||||
|
.where(and(eq(agent.id, agentId), isNull(agent.archivedAt)))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!agentRow) {
|
||||||
|
return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const access = await checkWorkspaceAccess(agentRow.workspaceId, session.user.id)
|
||||||
|
if (!access.hasAccess) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { credentialId, channelIds, respondTo, botName, replyInThread } = parsed.data
|
||||||
|
|
||||||
|
const [credentialRow] = await db
|
||||||
|
.select({
|
||||||
|
id: credential.id,
|
||||||
|
accountId: credential.accountId,
|
||||||
|
workspaceId: credential.workspaceId,
|
||||||
|
})
|
||||||
|
.from(credential)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(credential.id, credentialId),
|
||||||
|
eq(credential.workspaceId, agentRow.workspaceId),
|
||||||
|
eq(credential.type, 'oauth'),
|
||||||
|
eq(credential.providerId, 'slack')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!credentialRow?.accountId) {
|
||||||
|
return NextResponse.json({ error: 'Slack credential not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const [accountRow] = await db
|
||||||
|
.select({ accessToken: account.accessToken })
|
||||||
|
.from(account)
|
||||||
|
.where(eq(account.id, credentialRow.accountId))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!accountRow?.accessToken) {
|
||||||
|
return NextResponse.json({ error: 'Slack token not available' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const authTest = await fetch('https://slack.com/api/auth.test', {
|
||||||
|
headers: { Authorization: `Bearer ${accountRow.accessToken}` },
|
||||||
|
})
|
||||||
|
const authData = await authTest.json()
|
||||||
|
if (!authData.ok) {
|
||||||
|
logger.warn(`[${requestId}] Slack auth.test failed`, { error: authData.error })
|
||||||
|
return NextResponse.json({ error: 'Failed to verify Slack token' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const teamId: string = authData.team_id
|
||||||
|
const botUserId: string = authData.user_id ?? ''
|
||||||
|
|
||||||
|
if (!teamId || !botUserId) {
|
||||||
|
logger.warn(`[${requestId}] Slack auth.test returned incomplete identity`, {
|
||||||
|
teamId,
|
||||||
|
botUserId,
|
||||||
|
})
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Could not determine Slack workspace or bot identity' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingDeployment = await db
|
||||||
|
.select({ id: agentDeployment.id })
|
||||||
|
.from(agentDeployment)
|
||||||
|
.where(and(eq(agentDeployment.agentId, agentId), eq(agentDeployment.platform, 'slack')))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
const deploymentConfig: import('@/lib/agents/types').SlackDeploymentConfig = {
|
||||||
|
teamId,
|
||||||
|
botUserId,
|
||||||
|
channelIds,
|
||||||
|
respondTo,
|
||||||
|
replyInThread,
|
||||||
|
...(botName ? { botName } : {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
let deploymentRow: typeof agentDeployment.$inferSelect
|
||||||
|
|
||||||
|
if (existingDeployment.length > 0) {
|
||||||
|
const [updated] = await db
|
||||||
|
.update(agentDeployment)
|
||||||
|
.set({
|
||||||
|
credentialId,
|
||||||
|
config: deploymentConfig,
|
||||||
|
isActive: true,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(agentDeployment.id, existingDeployment[0].id))
|
||||||
|
.returning()
|
||||||
|
deploymentRow = updated
|
||||||
|
} else {
|
||||||
|
const [inserted] = await db
|
||||||
|
.insert(agentDeployment)
|
||||||
|
.values({
|
||||||
|
id: uuidv4(),
|
||||||
|
agentId,
|
||||||
|
platform: 'slack',
|
||||||
|
credentialId,
|
||||||
|
config: deploymentConfig,
|
||||||
|
isActive: true,
|
||||||
|
})
|
||||||
|
.returning()
|
||||||
|
deploymentRow = inserted
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(agent)
|
||||||
|
.set({ isDeployed: true, deployedAt: new Date(), updatedAt: new Date() })
|
||||||
|
.where(eq(agent.id, agentId))
|
||||||
|
|
||||||
|
logger.info(`[${requestId}] Agent ${agentId} deployed to Slack`, { teamId, channelIds })
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, data: deploymentRow })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`[${requestId}] Failed to deploy agent ${agentId} to Slack`, { error })
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/agents/{agentId}/deployments/slack
|
||||||
|
* Remove a Slack deployment from an agent.
|
||||||
|
*/
|
||||||
|
export async function DELETE(request: NextRequest, { params }: { params: Promise<RouteParams> }) {
|
||||||
|
const requestId = generateRequestId()
|
||||||
|
const { agentId } = await params
|
||||||
|
|
||||||
|
try {
|
||||||
|
const session = await getSession()
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const [agentRow] = await db
|
||||||
|
.select({ id: agent.id, workspaceId: agent.workspaceId })
|
||||||
|
.from(agent)
|
||||||
|
.where(and(eq(agent.id, agentId), isNull(agent.archivedAt)))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!agentRow) {
|
||||||
|
return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const access = await checkWorkspaceAccess(agentRow.workspaceId, session.user.id)
|
||||||
|
if (!access.hasAccess) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.delete(agentDeployment)
|
||||||
|
.where(and(eq(agentDeployment.agentId, agentId), eq(agentDeployment.platform, 'slack')))
|
||||||
|
|
||||||
|
const remainingDeployments = await db
|
||||||
|
.select({ id: agentDeployment.id })
|
||||||
|
.from(agentDeployment)
|
||||||
|
.where(and(eq(agentDeployment.agentId, agentId), eq(agentDeployment.isActive, true)))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (remainingDeployments.length === 0) {
|
||||||
|
await db
|
||||||
|
.update(agent)
|
||||||
|
.set({ isDeployed: false, updatedAt: new Date() })
|
||||||
|
.where(eq(agent.id, agentId))
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`[${requestId}] Slack deployment removed for agent ${agentId}`)
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`[${requestId}] Failed to remove Slack deployment for agent ${agentId}`, { error })
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
99
apps/sim/app/api/agents/[agentId]/execute/route.ts
Normal file
99
apps/sim/app/api/agents/[agentId]/execute/route.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { db } from '@sim/db'
|
||||||
|
import { agent } from '@sim/db/schema'
|
||||||
|
import { createLogger } from '@sim/logger'
|
||||||
|
import { and, eq, isNull } from 'drizzle-orm'
|
||||||
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { executeAgent } from '@/lib/agents/execute'
|
||||||
|
import type { AgentConfig } from '@/lib/agents/types'
|
||||||
|
import { getSession } from '@/lib/auth'
|
||||||
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
|
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
|
||||||
|
|
||||||
|
const logger = createLogger('AgentExecuteAPI')
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
interface RouteParams {
|
||||||
|
agentId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const ExecuteAgentSchema = z.object({
|
||||||
|
message: z.string().min(1, 'Message is required'),
|
||||||
|
conversationId: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/agents/{agentId}/execute
|
||||||
|
* Test-execute an agent from the UI. Requires an active session.
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest, { params }: { params: Promise<RouteParams> }) {
|
||||||
|
const requestId = generateRequestId()
|
||||||
|
const { agentId } = await params
|
||||||
|
|
||||||
|
try {
|
||||||
|
const session = await getSession()
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json()
|
||||||
|
const parsed = ExecuteAgentSchema.safeParse(body)
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: parsed.error.errors[0]?.message ?? 'Invalid request' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const [row] = await db
|
||||||
|
.select()
|
||||||
|
.from(agent)
|
||||||
|
.where(and(eq(agent.id, agentId), isNull(agent.archivedAt)))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const access = await checkWorkspaceAccess(row.workspaceId, session.user.id)
|
||||||
|
if (!access.hasAccess) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { message, conversationId } = parsed.data
|
||||||
|
|
||||||
|
const memoryConversationId = conversationId ?? `agent:${agentId}:test:${session.user.id}`
|
||||||
|
|
||||||
|
logger.info(`[${requestId}] Executing agent ${agentId}`, { userId: session.user.id })
|
||||||
|
|
||||||
|
const result = await executeAgent({
|
||||||
|
config: row.config as AgentConfig,
|
||||||
|
message,
|
||||||
|
conversationId: memoryConversationId,
|
||||||
|
agentId,
|
||||||
|
workspaceId: row.workspaceId,
|
||||||
|
userId: session.user.id,
|
||||||
|
isDeployedContext: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const streamingResult =
|
||||||
|
result && typeof result === 'object' && 'stream' in result
|
||||||
|
? (result as { stream: unknown }).stream
|
||||||
|
: null
|
||||||
|
if (streamingResult instanceof ReadableStream) {
|
||||||
|
return new NextResponse(streamingResult, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/event-stream',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
Connection: 'keep-alive',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, data: result as Record<string, unknown> })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`[${requestId}] Agent execution failed for ${agentId}`, { error })
|
||||||
|
return NextResponse.json({ error: 'Execution failed' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
171
apps/sim/app/api/agents/[agentId]/route.ts
Normal file
171
apps/sim/app/api/agents/[agentId]/route.ts
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
import { db } from '@sim/db'
|
||||||
|
import { agent, agentDeployment } from '@sim/db/schema'
|
||||||
|
import { createLogger } from '@sim/logger'
|
||||||
|
import { and, eq, isNull } from 'drizzle-orm'
|
||||||
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { getSession } from '@/lib/auth'
|
||||||
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
|
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
|
||||||
|
|
||||||
|
const logger = createLogger('AgentByIdAPI')
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
interface RouteParams {
|
||||||
|
agentId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const UpdateAgentSchema = z.object({
|
||||||
|
name: z.string().min(1).max(100).optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
config: z.record(z.unknown()).optional(),
|
||||||
|
isDeployed: z.boolean().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/agents/{agentId}
|
||||||
|
* Get a single agent with its deployments.
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest, { params }: { params: Promise<RouteParams> }) {
|
||||||
|
const requestId = generateRequestId()
|
||||||
|
const { agentId } = await params
|
||||||
|
|
||||||
|
try {
|
||||||
|
const session = await getSession()
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const [row] = await db
|
||||||
|
.select()
|
||||||
|
.from(agent)
|
||||||
|
.where(and(eq(agent.id, agentId), isNull(agent.archivedAt)))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const access = await checkWorkspaceAccess(row.workspaceId, session.user.id)
|
||||||
|
if (!access.hasAccess) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const deployments = await db
|
||||||
|
.select()
|
||||||
|
.from(agentDeployment)
|
||||||
|
.where(eq(agentDeployment.agentId, agentId))
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, data: { ...row, deployments } })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`[${requestId}] Failed to get agent ${agentId}`, { error })
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /api/agents/{agentId}
|
||||||
|
* Update an agent's name, description, or config.
|
||||||
|
*/
|
||||||
|
export async function PATCH(request: NextRequest, { params }: { params: Promise<RouteParams> }) {
|
||||||
|
const requestId = generateRequestId()
|
||||||
|
const { agentId } = await params
|
||||||
|
|
||||||
|
try {
|
||||||
|
const session = await getSession()
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json()
|
||||||
|
const parsed = UpdateAgentSchema.safeParse(body)
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: parsed.error.errors[0]?.message ?? 'Invalid request' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const [row] = await db
|
||||||
|
.select({ id: agent.id, workspaceId: agent.workspaceId })
|
||||||
|
.from(agent)
|
||||||
|
.where(and(eq(agent.id, agentId), isNull(agent.archivedAt)))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const access = await checkWorkspaceAccess(row.workspaceId, session.user.id)
|
||||||
|
if (!access.hasAccess) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates: Record<string, unknown> = { updatedAt: new Date() }
|
||||||
|
if (parsed.data.name !== undefined) updates.name = parsed.data.name
|
||||||
|
if (parsed.data.description !== undefined) updates.description = parsed.data.description
|
||||||
|
if (parsed.data.config !== undefined) updates.config = parsed.data.config
|
||||||
|
if (parsed.data.isDeployed !== undefined) {
|
||||||
|
updates.isDeployed = parsed.data.isDeployed
|
||||||
|
if (parsed.data.isDeployed) updates.deployedAt = new Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
const [updated] = await db.update(agent).set(updates).where(eq(agent.id, agentId)).returning()
|
||||||
|
|
||||||
|
logger.info(`[${requestId}] Agent updated`, { agentId })
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, data: updated })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`[${requestId}] Failed to update agent ${agentId}`, { error })
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/agents/{agentId}
|
||||||
|
* Soft-delete an agent and deactivate all its deployments.
|
||||||
|
*/
|
||||||
|
export async function DELETE(request: NextRequest, { params }: { params: Promise<RouteParams> }) {
|
||||||
|
const requestId = generateRequestId()
|
||||||
|
const { agentId } = await params
|
||||||
|
|
||||||
|
try {
|
||||||
|
const session = await getSession()
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const [row] = await db
|
||||||
|
.select({ id: agent.id, workspaceId: agent.workspaceId })
|
||||||
|
.from(agent)
|
||||||
|
.where(and(eq(agent.id, agentId), isNull(agent.archivedAt)))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const access = await checkWorkspaceAccess(row.workspaceId, session.user.id)
|
||||||
|
if (!access.hasAccess) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(agentDeployment)
|
||||||
|
.set({ isActive: false, updatedAt: new Date() })
|
||||||
|
.where(eq(agentDeployment.agentId, agentId))
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(agent)
|
||||||
|
.set({ archivedAt: new Date(), isDeployed: false, updatedAt: new Date() })
|
||||||
|
.where(eq(agent.id, agentId))
|
||||||
|
|
||||||
|
logger.info(`[${requestId}] Agent archived`, { agentId })
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`[${requestId}] Failed to delete agent ${agentId}`, { error })
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
128
apps/sim/app/api/agents/route.ts
Normal file
128
apps/sim/app/api/agents/route.ts
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import { db } from '@sim/db'
|
||||||
|
import { agent } from '@sim/db/schema'
|
||||||
|
import { createLogger } from '@sim/logger'
|
||||||
|
import { and, eq, isNotNull, isNull } from 'drizzle-orm'
|
||||||
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { v4 as uuidv4 } from 'uuid'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { getSession } from '@/lib/auth'
|
||||||
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
|
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
|
||||||
|
|
||||||
|
const logger = createLogger('AgentsAPI')
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
type AgentQueryScope = 'active' | 'archived' | 'all'
|
||||||
|
|
||||||
|
const CreateAgentSchema = z.object({
|
||||||
|
workspaceId: z.string().min(1, 'Workspace ID is required'),
|
||||||
|
name: z.string().min(1, 'Name is required').max(100, 'Name must be 100 characters or fewer'),
|
||||||
|
description: z.string().optional(),
|
||||||
|
config: z.record(z.unknown()).default({}),
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/agents?workspaceId={id}&scope=active|archived|all
|
||||||
|
* List agents for a workspace.
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const requestId = generateRequestId()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const session = await getSession()
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
logger.warn(`[${requestId}] Unauthorized agents list access`)
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url)
|
||||||
|
const workspaceId = searchParams.get('workspaceId')
|
||||||
|
const scopeParam = (searchParams.get('scope') ?? 'active') as AgentQueryScope
|
||||||
|
|
||||||
|
if (!workspaceId) {
|
||||||
|
return NextResponse.json({ error: 'workspaceId is required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!['active', 'archived', 'all'].includes(scopeParam)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid scope' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const access = await checkWorkspaceAccess(workspaceId, session.user.id)
|
||||||
|
if (!access.exists) {
|
||||||
|
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
if (!access.hasAccess) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const conditions = [eq(agent.workspaceId, workspaceId)]
|
||||||
|
if (scopeParam === 'active') conditions.push(isNull(agent.archivedAt))
|
||||||
|
if (scopeParam === 'archived') conditions.push(isNotNull(agent.archivedAt))
|
||||||
|
|
||||||
|
const agents = await db
|
||||||
|
.select()
|
||||||
|
.from(agent)
|
||||||
|
.where(and(...conditions))
|
||||||
|
.orderBy(agent.updatedAt)
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, data: agents })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`[${requestId}] Failed to list agents`, { error })
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/agents
|
||||||
|
* Create a new agent.
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const requestId = generateRequestId()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const session = await getSession()
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
logger.warn(`[${requestId}] Unauthorized agent create attempt`)
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json()
|
||||||
|
const parsed = CreateAgentSchema.safeParse(body)
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: parsed.error.errors[0]?.message ?? 'Invalid request' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { workspaceId, name, description, config } = parsed.data
|
||||||
|
|
||||||
|
const access = await checkWorkspaceAccess(workspaceId, session.user.id)
|
||||||
|
if (!access.exists) {
|
||||||
|
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
if (!access.hasAccess) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const [created] = await db
|
||||||
|
.insert(agent)
|
||||||
|
.values({
|
||||||
|
id: uuidv4(),
|
||||||
|
workspaceId,
|
||||||
|
createdBy: session.user.id,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
config,
|
||||||
|
})
|
||||||
|
.returning()
|
||||||
|
|
||||||
|
logger.info(`[${requestId}] Agent created`, { agentId: created.id, workspaceId })
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, data: created }, { status: 201 })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`[${requestId}] Failed to create agent`, { error })
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
489
apps/sim/app/api/agents/slack/webhook/route.ts
Normal file
489
apps/sim/app/api/agents/slack/webhook/route.ts
Normal file
@@ -0,0 +1,489 @@
|
|||||||
|
import crypto from 'crypto'
|
||||||
|
import { db } from '@sim/db'
|
||||||
|
import { account, agent, agentConversation, agentDeployment, credential } from '@sim/db/schema'
|
||||||
|
import { createLogger } from '@sim/logger'
|
||||||
|
import { and, eq, isNull } from 'drizzle-orm'
|
||||||
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { v4 as uuidv4 } from 'uuid'
|
||||||
|
import { executeAgent } from '@/lib/agents/execute'
|
||||||
|
import type { AgentConfig, SlackDeploymentConfig } from '@/lib/agents/types'
|
||||||
|
import { env } from '@/lib/core/config/env'
|
||||||
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
|
import { handleSlackChallenge } from '@/lib/webhooks/utils.server'
|
||||||
|
|
||||||
|
const logger = createLogger('AgentSlackWebhook')
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
/** Verify the Slack request signature using HMAC-SHA256. */
|
||||||
|
async function verifySlackSignature(
|
||||||
|
signingSecret: string,
|
||||||
|
rawBody: string,
|
||||||
|
timestamp: string,
|
||||||
|
signature: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
const now = Math.floor(Date.now() / 1000)
|
||||||
|
if (Math.abs(now - Number.parseInt(timestamp, 10)) > 300) return false
|
||||||
|
|
||||||
|
const baseString = `v0:${timestamp}:${rawBody}`
|
||||||
|
const hmac = crypto.createHmac('sha256', signingSecret)
|
||||||
|
hmac.update(baseString)
|
||||||
|
const computed = `v0=${hmac.digest('hex')}`
|
||||||
|
return crypto.timingSafeEqual(Buffer.from(computed), Buffer.from(signature))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Consume a StreamingExecution ReadableStream (SSE-formatted).
|
||||||
|
* Calls onChunk with (delta, accumulated) on each new token.
|
||||||
|
* Returns the final complete text.
|
||||||
|
*/
|
||||||
|
async function consumeAgentStream(
|
||||||
|
stream: ReadableStream,
|
||||||
|
onChunk?: (delta: string, accumulated: string) => void | Promise<void>
|
||||||
|
): Promise<string> {
|
||||||
|
const reader = stream.getReader()
|
||||||
|
const decoder = new TextDecoder()
|
||||||
|
let buf = ''
|
||||||
|
let accumulated = ''
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read()
|
||||||
|
if (done) break
|
||||||
|
buf += decoder.decode(value, { stream: true })
|
||||||
|
const lines = buf.split('\n')
|
||||||
|
buf = lines.pop() ?? ''
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!line.startsWith('data: ')) continue
|
||||||
|
const data = line.slice(6).trim()
|
||||||
|
if (data === '[DONE]') return accumulated
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(data) as { content?: string }
|
||||||
|
if (parsed.content) {
|
||||||
|
const delta = parsed.content
|
||||||
|
accumulated += delta
|
||||||
|
await onChunk?.(delta, accumulated)
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return accumulated
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Call Slack's chat.postMessage. Returns the message ts or null on failure. */
|
||||||
|
async function postMessage(
|
||||||
|
botToken: string,
|
||||||
|
payload: {
|
||||||
|
channel: string
|
||||||
|
text: string
|
||||||
|
thread_ts?: string
|
||||||
|
username?: string
|
||||||
|
}
|
||||||
|
): Promise<string | null> {
|
||||||
|
const res = await fetch('https://slack.com/api/chat.postMessage', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${botToken}`, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
const data = (await res.json()) as { ok: boolean; ts?: string; error?: string }
|
||||||
|
if (!data.ok) {
|
||||||
|
logger.warn('chat.postMessage failed', { error: data.error })
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return data.ts ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Call Slack's chat.update. */
|
||||||
|
async function updateMessage(
|
||||||
|
botToken: string,
|
||||||
|
channel: string,
|
||||||
|
ts: string,
|
||||||
|
text: string,
|
||||||
|
username?: string
|
||||||
|
): Promise<void> {
|
||||||
|
const res = await fetch('https://slack.com/api/chat.update', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${botToken}`, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ channel, ts, text, ...(username ? { username } : {}) }),
|
||||||
|
})
|
||||||
|
const data = (await res.json()) as { ok: boolean; error?: string }
|
||||||
|
if (!data.ok) logger.warn('chat.update failed', { error: data.error })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start a native Slack stream (Oct 2025 streaming API).
|
||||||
|
* Returns the stream ts, or null if unsupported (e.g. Enterprise Grid).
|
||||||
|
*/
|
||||||
|
async function startStream(
|
||||||
|
botToken: string,
|
||||||
|
channel: string,
|
||||||
|
threadTs: string,
|
||||||
|
recipientUserId: string,
|
||||||
|
recipientTeamId: string,
|
||||||
|
username?: string
|
||||||
|
): Promise<string | null> {
|
||||||
|
const res = await fetch('https://slack.com/api/chat.startStream', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${botToken}`, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
channel,
|
||||||
|
thread_ts: threadTs,
|
||||||
|
recipient_user_id: recipientUserId,
|
||||||
|
recipient_team_id: recipientTeamId,
|
||||||
|
...(username ? { username } : {}),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
const data = (await res.json()) as { ok: boolean; ts?: string; error?: string }
|
||||||
|
if (!data.ok) {
|
||||||
|
// enterprise_is_restricted = Enterprise Grid; fall back silently
|
||||||
|
if (data.error !== 'enterprise_is_restricted') {
|
||||||
|
logger.warn('chat.startStream failed', { error: data.error })
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return data.ts ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Append a markdown chunk to a native Slack stream. */
|
||||||
|
async function appendStream(
|
||||||
|
botToken: string,
|
||||||
|
channel: string,
|
||||||
|
ts: string,
|
||||||
|
markdownText: string
|
||||||
|
): Promise<void> {
|
||||||
|
const res = await fetch('https://slack.com/api/chat.appendStream', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${botToken}`, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ channel, ts, markdown_text: markdownText }),
|
||||||
|
})
|
||||||
|
const data = (await res.json()) as { ok: boolean; error?: string }
|
||||||
|
if (!data.ok) logger.warn('chat.appendStream failed', { error: data.error })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Finalize a native Slack stream. */
|
||||||
|
async function stopStream(botToken: string, channel: string, ts: string): Promise<void> {
|
||||||
|
const res = await fetch('https://slack.com/api/chat.stopStream', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${botToken}`, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ channel, ts }),
|
||||||
|
})
|
||||||
|
const data = (await res.json()) as { ok: boolean; error?: string }
|
||||||
|
if (!data.ok) logger.warn('chat.stopStream failed', { error: data.error })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/agents/slack/webhook
|
||||||
|
* Receives Slack event callbacks for all agent deployments.
|
||||||
|
* Responds 200 immediately and processes events asynchronously.
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const requestId = generateRequestId()
|
||||||
|
|
||||||
|
const signingSecret = env.SLACK_SIGNING_SECRET
|
||||||
|
if (!signingSecret) {
|
||||||
|
logger.error(`[${requestId}] SLACK_SIGNING_SECRET is not configured`)
|
||||||
|
return NextResponse.json({ error: 'Webhook not configured' }, { status: 500 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawBody = await request.text()
|
||||||
|
const timestamp = request.headers.get('x-slack-request-timestamp') ?? ''
|
||||||
|
const signature = request.headers.get('x-slack-signature') ?? ''
|
||||||
|
const isValid = await verifySlackSignature(signingSecret, rawBody, timestamp, signature)
|
||||||
|
if (!isValid) {
|
||||||
|
logger.warn(`[${requestId}] Invalid Slack signature`)
|
||||||
|
return NextResponse.json({ error: 'Invalid signature' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: Record<string, unknown>
|
||||||
|
try {
|
||||||
|
body = JSON.parse(rawBody) as Record<string, unknown>
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const challengeResponse = handleSlackChallenge(body)
|
||||||
|
if (challengeResponse) return challengeResponse
|
||||||
|
|
||||||
|
if (body.type !== 'event_callback') {
|
||||||
|
return new NextResponse(null, { status: 200 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deduplicate: if Slack retried (infrastructure-level duplicate delivery), skip
|
||||||
|
const retryNum = request.headers.get('x-slack-retry-num')
|
||||||
|
if (retryNum) {
|
||||||
|
return new NextResponse(null, { status: 200 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = (body.event ?? {}) as Record<string, unknown>
|
||||||
|
const teamId = String(body.team_id ?? '')
|
||||||
|
const channel = String(event.channel ?? '')
|
||||||
|
const eventTs = String(event.ts ?? '')
|
||||||
|
const threadTs = String(event.thread_ts ?? '')
|
||||||
|
const text = String(event.text ?? '')
|
||||||
|
const userId = String(event.user ?? '')
|
||||||
|
const botId = event.bot_id as string | undefined
|
||||||
|
const subtype = event.subtype as string | undefined
|
||||||
|
const eventType = String(event.type ?? '')
|
||||||
|
|
||||||
|
// Ignore bot messages (our own replies or other bots)
|
||||||
|
if (botId || subtype === 'bot_message') {
|
||||||
|
return new NextResponse(null, { status: 200 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Respond 200 immediately — Slack never retries a 200, so no header needed
|
||||||
|
void processSlackEvent({ requestId, teamId, channel, eventTs, threadTs, text, userId, eventType })
|
||||||
|
|
||||||
|
return new NextResponse(null, { status: 200 })
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SlackEventContext {
|
||||||
|
requestId: string
|
||||||
|
teamId: string
|
||||||
|
channel: string
|
||||||
|
eventTs: string
|
||||||
|
threadTs: string
|
||||||
|
text: string
|
||||||
|
userId: string
|
||||||
|
eventType: string
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processSlackEvent(ctx: SlackEventContext): Promise<void> {
|
||||||
|
const { requestId, teamId, channel, eventTs, threadTs, text, userId, eventType } = ctx
|
||||||
|
|
||||||
|
try {
|
||||||
|
const deployments = await db
|
||||||
|
.select({ deployment: agentDeployment, agentRow: agent })
|
||||||
|
.from(agentDeployment)
|
||||||
|
.innerJoin(agent, and(eq(agent.id, agentDeployment.agentId), isNull(agent.archivedAt)))
|
||||||
|
.where(and(eq(agentDeployment.platform, 'slack'), eq(agentDeployment.isActive, true)))
|
||||||
|
|
||||||
|
const isDm = channel.startsWith('D')
|
||||||
|
|
||||||
|
const match = deployments.find((row) => {
|
||||||
|
const config = row.deployment.config as SlackDeploymentConfig
|
||||||
|
if (config.teamId !== teamId) return false
|
||||||
|
|
||||||
|
const respondTo = config.respondTo
|
||||||
|
if (respondTo === 'dm') return isDm
|
||||||
|
if (!config.channelIds.includes(channel)) return false
|
||||||
|
if (respondTo === 'mentions' && eventType !== 'app_mention') return false
|
||||||
|
if (respondTo === 'threads' && !threadTs) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!match) {
|
||||||
|
logger.info(`[${requestId}] No agent matched for team=${teamId} channel=${channel}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const { deployment, agentRow } = match
|
||||||
|
const agentId = agentRow.id
|
||||||
|
const workspaceId = agentRow.workspaceId
|
||||||
|
const config = deployment.config as SlackDeploymentConfig
|
||||||
|
|
||||||
|
let botToken: string | undefined
|
||||||
|
if (deployment.credentialId) {
|
||||||
|
const [row] = await db
|
||||||
|
.select({ accessToken: account.accessToken })
|
||||||
|
.from(credential)
|
||||||
|
.innerJoin(account, eq(account.id, credential.accountId))
|
||||||
|
.where(eq(credential.id, deployment.credentialId))
|
||||||
|
.limit(1)
|
||||||
|
botToken = row?.accessToken ?? undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!botToken) {
|
||||||
|
logger.warn(`[${requestId}] No bot token for agent ${agentId}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Thread → thread-scoped memory; DM or main channel → channel-scoped memory
|
||||||
|
const externalId = threadTs ? `${channel}:${threadTs}` : channel
|
||||||
|
const conversationId = `agent:${agentId}:slack:${externalId}`
|
||||||
|
|
||||||
|
await db
|
||||||
|
.insert(agentConversation)
|
||||||
|
.values({
|
||||||
|
id: uuidv4(),
|
||||||
|
agentId,
|
||||||
|
platform: 'slack',
|
||||||
|
externalId,
|
||||||
|
conversationId,
|
||||||
|
metadata: { channel, threadTs, teamId },
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: [
|
||||||
|
agentConversation.agentId,
|
||||||
|
agentConversation.platform,
|
||||||
|
agentConversation.externalId,
|
||||||
|
],
|
||||||
|
set: { updatedAt: new Date(), metadata: { channel, threadTs, teamId } },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Strip bot mention (use stored botUserId if available, else strip any @mention)
|
||||||
|
const mentionPattern = config.botUserId
|
||||||
|
? new RegExp(`<@${config.botUserId}>`, 'g')
|
||||||
|
: /<@[UW][A-Z0-9]+>/g
|
||||||
|
const cleanedText = text.replace(mentionPattern, '').trim()
|
||||||
|
if (!cleanedText) return
|
||||||
|
|
||||||
|
const replyInThread = config.replyInThread !== false
|
||||||
|
const replyThreadTs = replyInThread ? threadTs || eventTs : undefined
|
||||||
|
const botName = config.botName || undefined
|
||||||
|
|
||||||
|
logger.info(`[${requestId}] Executing agent ${agentId} for Slack event`, {
|
||||||
|
channel,
|
||||||
|
threadTs,
|
||||||
|
eventType,
|
||||||
|
})
|
||||||
|
|
||||||
|
const timeout = AbortSignal.timeout(30_000)
|
||||||
|
const result = await executeAgent({
|
||||||
|
config: agentRow.config as AgentConfig,
|
||||||
|
message: cleanedText,
|
||||||
|
conversationId,
|
||||||
|
agentId,
|
||||||
|
workspaceId,
|
||||||
|
isDeployedContext: true,
|
||||||
|
abortSignal: timeout,
|
||||||
|
})
|
||||||
|
|
||||||
|
const streamingResult =
|
||||||
|
result && typeof result === 'object' && 'stream' in result
|
||||||
|
? (result as { stream: ReadableStream }).stream
|
||||||
|
: null
|
||||||
|
|
||||||
|
let responseText: string
|
||||||
|
|
||||||
|
if (streamingResult instanceof ReadableStream) {
|
||||||
|
responseText = await streamResponse({
|
||||||
|
botToken,
|
||||||
|
channel,
|
||||||
|
replyThreadTs,
|
||||||
|
teamId,
|
||||||
|
userId,
|
||||||
|
botName,
|
||||||
|
stream: streamingResult,
|
||||||
|
requestId,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
responseText = String((result as Record<string, unknown>).content ?? '')
|
||||||
|
if (responseText) {
|
||||||
|
await postMessage(botToken, {
|
||||||
|
channel,
|
||||||
|
text: responseText,
|
||||||
|
thread_ts: replyThreadTs,
|
||||||
|
...(botName ? { username: botName } : {}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!responseText) {
|
||||||
|
await postMessage(botToken, {
|
||||||
|
channel,
|
||||||
|
text: '_No response._',
|
||||||
|
thread_ts: replyThreadTs,
|
||||||
|
...(botName ? { username: botName } : {}),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`[${requestId}] Agent ${agentId} responded to Slack event`)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`[${requestId}] Error processing Slack event`, { error })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StreamResponseParams {
|
||||||
|
botToken: string
|
||||||
|
channel: string
|
||||||
|
replyThreadTs: string | undefined
|
||||||
|
teamId: string
|
||||||
|
userId: string
|
||||||
|
botName: string | undefined
|
||||||
|
stream: ReadableStream
|
||||||
|
requestId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deliver a streaming agent response to Slack.
|
||||||
|
*
|
||||||
|
* Prefers the native streaming API (chat.startStream / appendStream / stopStream)
|
||||||
|
* introduced in October 2025, which renders a real-time typewriter effect in
|
||||||
|
* Slack clients and uses the higher Tier 4 rate limit (100+/min).
|
||||||
|
*
|
||||||
|
* Falls back to chat.postMessage + throttled chat.update (~1/sec) when:
|
||||||
|
* - The workspace is on Enterprise Grid (startStream returns enterprise_is_restricted)
|
||||||
|
* - No thread context is available (startStream requires thread_ts)
|
||||||
|
*/
|
||||||
|
async function streamResponse({
|
||||||
|
botToken,
|
||||||
|
channel,
|
||||||
|
replyThreadTs,
|
||||||
|
teamId,
|
||||||
|
userId,
|
||||||
|
botName,
|
||||||
|
stream,
|
||||||
|
requestId,
|
||||||
|
}: StreamResponseParams): Promise<string> {
|
||||||
|
// Native streaming API requires a thread context
|
||||||
|
if (replyThreadTs) {
|
||||||
|
const streamTs = await startStream(botToken, channel, replyThreadTs, userId, teamId, botName)
|
||||||
|
|
||||||
|
if (streamTs) {
|
||||||
|
// Native streaming path — Tier 4 (100+/min), flush every ~600ms
|
||||||
|
let pendingDelta = ''
|
||||||
|
let lastFlushTime = 0
|
||||||
|
|
||||||
|
const responseText = await consumeAgentStream(stream, async (delta) => {
|
||||||
|
pendingDelta += delta
|
||||||
|
const now = Date.now()
|
||||||
|
if (now - lastFlushTime >= 600 && pendingDelta) {
|
||||||
|
lastFlushTime = now
|
||||||
|
const toSend = pendingDelta
|
||||||
|
pendingDelta = ''
|
||||||
|
await appendStream(botToken, channel, streamTs, toSend)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Flush remaining buffer
|
||||||
|
if (pendingDelta) {
|
||||||
|
await appendStream(botToken, channel, streamTs, pendingDelta)
|
||||||
|
}
|
||||||
|
await stopStream(botToken, channel, streamTs)
|
||||||
|
|
||||||
|
logger.info(`[${requestId}] Streamed via native streaming API`)
|
||||||
|
return responseText
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: post placeholder → throttled chat.update (~1/sec, Tier 3)
|
||||||
|
const placeholderTs = await postMessage(botToken, {
|
||||||
|
channel,
|
||||||
|
text: '_Thinking…_',
|
||||||
|
thread_ts: replyThreadTs,
|
||||||
|
...(botName ? { username: botName } : {}),
|
||||||
|
})
|
||||||
|
|
||||||
|
let lastUpdateTime = 0
|
||||||
|
const responseText = await consumeAgentStream(stream, async (_, accumulated) => {
|
||||||
|
const now = Date.now()
|
||||||
|
if (placeholderTs && now - lastUpdateTime >= 1200) {
|
||||||
|
lastUpdateTime = now
|
||||||
|
void updateMessage(botToken, channel, placeholderTs, `${accumulated} ▌`, botName)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (placeholderTs) {
|
||||||
|
await updateMessage(botToken, channel, placeholderTs, responseText || '_No response._', botName)
|
||||||
|
} else if (responseText) {
|
||||||
|
await postMessage(botToken, {
|
||||||
|
channel,
|
||||||
|
text: responseText,
|
||||||
|
thread_ts: replyThreadTs,
|
||||||
|
...(botName ? { username: botName } : {}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`[${requestId}] Streamed via chat.update fallback`)
|
||||||
|
return responseText
|
||||||
|
}
|
||||||
110
apps/sim/app/api/skills/import/route.ts
Normal file
110
apps/sim/app/api/skills/import/route.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import { createLogger } from '@sim/logger'
|
||||||
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||||
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
|
|
||||||
|
const logger = createLogger('SkillsImportAPI')
|
||||||
|
|
||||||
|
const FETCH_TIMEOUT_MS = 15_000
|
||||||
|
|
||||||
|
const ImportSchema = z.object({
|
||||||
|
url: z.string().url('A valid URL is required'),
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a standard GitHub file URL to its raw.githubusercontent.com equivalent.
|
||||||
|
*
|
||||||
|
* Supported formats:
|
||||||
|
* github.com/{owner}/{repo}/blob/{branch}/{path}
|
||||||
|
* raw.githubusercontent.com/{owner}/{repo}/{branch}/{path} (passthrough)
|
||||||
|
*/
|
||||||
|
function toRawGitHubUrl(url: string): string {
|
||||||
|
const parsed = new URL(url)
|
||||||
|
|
||||||
|
if (parsed.hostname === 'raw.githubusercontent.com') {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.hostname !== 'github.com') {
|
||||||
|
throw new Error('Only GitHub URLs are supported')
|
||||||
|
}
|
||||||
|
|
||||||
|
// /owner/repo/blob/branch/path...
|
||||||
|
const segments = parsed.pathname.split('/').filter(Boolean)
|
||||||
|
if (segments.length < 5 || segments[2] !== 'blob') {
|
||||||
|
throw new Error(
|
||||||
|
'Invalid GitHub URL format. Expected: https://github.com/{owner}/{repo}/blob/{branch}/{path}'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const [owner, repo, , branch, ...pathParts] = segments
|
||||||
|
return `https://raw.githubusercontent.com/${owner}/${repo}/${branch}/${pathParts.join('/')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST - Fetch a SKILL.md from a GitHub URL and return its raw content */
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const requestId = generateRequestId()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const authResult = await checkSessionOrInternalAuth(req, { requireWorkflowId: false })
|
||||||
|
if (!authResult.success || !authResult.userId) {
|
||||||
|
logger.warn(`[${requestId}] Unauthorized skill import attempt`)
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await req.json()
|
||||||
|
const { url } = ImportSchema.parse(body)
|
||||||
|
|
||||||
|
let rawUrl: string
|
||||||
|
try {
|
||||||
|
rawUrl = toRawGitHubUrl(url)
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Invalid URL'
|
||||||
|
return NextResponse.json({ error: message }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(rawUrl, {
|
||||||
|
signal: controller.signal,
|
||||||
|
headers: { Accept: 'text/plain' },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
logger.warn(`[${requestId}] GitHub fetch failed`, {
|
||||||
|
status: response.status,
|
||||||
|
url: rawUrl,
|
||||||
|
})
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Failed to fetch file (HTTP ${response.status}). Is the repository public?` },
|
||||||
|
{ status: 502 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = await response.text()
|
||||||
|
|
||||||
|
if (content.length > 100_000) {
|
||||||
|
return NextResponse.json({ error: 'File is too large (max 100KB)' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ content })
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return NextResponse.json({ error: 'Invalid request', details: error.errors }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof Error && error.name === 'AbortError') {
|
||||||
|
logger.warn(`[${requestId}] GitHub fetch timed out`)
|
||||||
|
return NextResponse.json({ error: 'Request timed out' }, { status: 504 })
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.error(`[${requestId}] Error importing skill`, error)
|
||||||
|
return NextResponse.json({ error: 'Failed to import skill' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
102
apps/sim/app/api/v1/agents/[agentId]/route.ts
Normal file
102
apps/sim/app/api/v1/agents/[agentId]/route.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { db } from '@sim/db'
|
||||||
|
import { agent } from '@sim/db/schema'
|
||||||
|
import { createLogger } from '@sim/logger'
|
||||||
|
import { and, eq, isNull } from 'drizzle-orm'
|
||||||
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { executeAgent } from '@/lib/agents/execute'
|
||||||
|
import type { AgentConfig } from '@/lib/agents/types'
|
||||||
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
|
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
|
||||||
|
import { checkRateLimit, createRateLimitResponse } from '@/app/api/v1/middleware'
|
||||||
|
|
||||||
|
const logger = createLogger('V1AgentAPI')
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
interface RouteParams {
|
||||||
|
agentId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const ExecuteSchema = z.object({
|
||||||
|
message: z.string().min(1, 'Message is required'),
|
||||||
|
conversationId: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/v1/agents/{agentId}
|
||||||
|
* Execute an agent via the public API. Requires a workspace or personal API key.
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest, { params }: { params: Promise<RouteParams> }) {
|
||||||
|
const requestId = generateRequestId()
|
||||||
|
const { agentId } = await params
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rateLimit = await checkRateLimit(request, 'agent-detail')
|
||||||
|
if (!rateLimit.allowed) {
|
||||||
|
return createRateLimitResponse(rateLimit)
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = rateLimit.userId!
|
||||||
|
|
||||||
|
const body = await request.json()
|
||||||
|
const parsed = ExecuteSchema.safeParse(body)
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: parsed.error.errors[0]?.message ?? 'Invalid request' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const [agentRow] = await db
|
||||||
|
.select()
|
||||||
|
.from(agent)
|
||||||
|
.where(and(eq(agent.id, agentId), isNull(agent.archivedAt), eq(agent.isDeployed, true)))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!agentRow) {
|
||||||
|
return NextResponse.json({ error: 'Agent not found or not deployed' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const access = await checkWorkspaceAccess(agentRow.workspaceId, userId)
|
||||||
|
if (!access.hasAccess) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { message, conversationId } = parsed.data
|
||||||
|
const memoryConversationId = conversationId
|
||||||
|
? `agent:${agentId}:api:${conversationId}`
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
logger.info(`[${requestId}] V1 API executing agent ${agentId}`, { userId })
|
||||||
|
|
||||||
|
const result = await executeAgent({
|
||||||
|
config: agentRow.config as AgentConfig,
|
||||||
|
message,
|
||||||
|
conversationId: memoryConversationId,
|
||||||
|
agentId,
|
||||||
|
workspaceId: agentRow.workspaceId,
|
||||||
|
userId,
|
||||||
|
isDeployedContext: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const streamingResult =
|
||||||
|
result && typeof result === 'object' && 'stream' in result
|
||||||
|
? (result as { stream: unknown }).stream
|
||||||
|
: null
|
||||||
|
if (streamingResult instanceof ReadableStream) {
|
||||||
|
return new NextResponse(streamingResult, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/event-stream',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
Connection: 'keep-alive',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, data: result as Record<string, unknown> })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`[${requestId}] V1 agent execution failed for ${agentId}`, { error })
|
||||||
|
return NextResponse.json({ error: 'Execution failed' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,7 +37,9 @@ export async function checkRateLimit(
|
|||||||
| 'file-detail'
|
| 'file-detail'
|
||||||
| 'knowledge'
|
| 'knowledge'
|
||||||
| 'knowledge-detail'
|
| 'knowledge-detail'
|
||||||
| 'knowledge-search' = 'logs'
|
| 'knowledge-search'
|
||||||
|
| 'agents'
|
||||||
|
| 'agent-detail' = 'logs'
|
||||||
): Promise<RateLimitResult> {
|
): Promise<RateLimitResult> {
|
||||||
try {
|
try {
|
||||||
const auth = await authenticateV1Request(request)
|
const auth = await authenticateV1Request(request)
|
||||||
|
|||||||
@@ -0,0 +1,226 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { createLogger } from '@sim/logger'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { Pencil, Trash } from '@/components/emcn/icons'
|
||||||
|
import { AgentIcon } from '@/components/icons'
|
||||||
|
import type { AgentConfig } from '@/lib/agents/types'
|
||||||
|
import { AgentConfigPanel } from '@/app/workspace/[workspaceId]/agents/[agentId]/components/agent-config/agent-config-panel'
|
||||||
|
import { AgentTestPanel } from '@/app/workspace/[workspaceId]/agents/[agentId]/components/agent-test-panel'
|
||||||
|
import { DeployModal } from '@/app/workspace/[workspaceId]/agents/[agentId]/components/deploy-modal'
|
||||||
|
import type { BreadcrumbItem } from '@/app/workspace/[workspaceId]/components'
|
||||||
|
import { ResourceHeader } from '@/app/workspace/[workspaceId]/components'
|
||||||
|
import { useAgent, useDeleteAgent, useUpdateAgent } from '@/hooks/queries/agents'
|
||||||
|
import { useInlineRename } from '@/hooks/use-inline-rename'
|
||||||
|
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||||
|
|
||||||
|
const logger = createLogger('AgentDetail')
|
||||||
|
|
||||||
|
const AUTOSAVE_DELAY_MS = 1000
|
||||||
|
|
||||||
|
interface AgentDetailProps {
|
||||||
|
agentId: string
|
||||||
|
workspaceId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AgentDetail({ agentId, workspaceId }: AgentDetailProps) {
|
||||||
|
const router = useRouter()
|
||||||
|
const { data: agent, isLoading } = useAgent(agentId)
|
||||||
|
const { mutateAsync: updateAgent } = useUpdateAgent()
|
||||||
|
const { mutateAsync: deleteAgent } = useDeleteAgent()
|
||||||
|
|
||||||
|
const [localConfig, setLocalConfig] = useState<AgentConfig>({})
|
||||||
|
const [saveStatus, setSaveStatus] = useState<'saved' | 'saving' | 'unsaved'>('saved')
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false)
|
||||||
|
const [isDeployModalOpen, setIsDeployModalOpen] = useState(false)
|
||||||
|
|
||||||
|
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
const isInitializedRef = useRef(false)
|
||||||
|
|
||||||
|
// SubBlockStore.setValue reads activeWorkflowId from the workflow registry and no-ops when null.
|
||||||
|
// Set the agentId as the active "workflow" so tool sub-block params (credentials, etc.) persist.
|
||||||
|
useEffect(() => {
|
||||||
|
useWorkflowRegistry.setState({ activeWorkflowId: agentId })
|
||||||
|
return () => {
|
||||||
|
if (useWorkflowRegistry.getState().activeWorkflowId === agentId) {
|
||||||
|
useWorkflowRegistry.setState({ activeWorkflowId: null })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [agentId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (agent && !isInitializedRef.current) {
|
||||||
|
setLocalConfig(agent.config ?? {})
|
||||||
|
isInitializedRef.current = true
|
||||||
|
}
|
||||||
|
}, [agent])
|
||||||
|
|
||||||
|
const agentRename = useInlineRename({
|
||||||
|
onSave: (id, name) => updateAgent({ agentId: id, name }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const scheduleSave = useCallback(
|
||||||
|
(updatedConfig: AgentConfig) => {
|
||||||
|
setSaveStatus('unsaved')
|
||||||
|
if (saveTimerRef.current) clearTimeout(saveTimerRef.current)
|
||||||
|
saveTimerRef.current = setTimeout(async () => {
|
||||||
|
saveTimerRef.current = null
|
||||||
|
setSaveStatus('saving')
|
||||||
|
try {
|
||||||
|
await updateAgent({ agentId, config: updatedConfig })
|
||||||
|
setSaveStatus('saved')
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to auto-save agent', { error })
|
||||||
|
setSaveStatus('unsaved')
|
||||||
|
}
|
||||||
|
}, AUTOSAVE_DELAY_MS)
|
||||||
|
},
|
||||||
|
[agentId, updateAgent]
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (saveTimerRef.current) clearTimeout(saveTimerRef.current)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleConfigChange = useCallback(
|
||||||
|
(patch: Partial<AgentConfig>) => {
|
||||||
|
setLocalConfig((prev) => {
|
||||||
|
const next = { ...prev, ...patch }
|
||||||
|
scheduleSave(next)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[scheduleSave]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleDelete = useCallback(async () => {
|
||||||
|
setIsDeleting(true)
|
||||||
|
try {
|
||||||
|
await deleteAgent({ agentId })
|
||||||
|
router.push(`/workspace/${workspaceId}/agents`)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to delete agent', { error })
|
||||||
|
setIsDeleting(false)
|
||||||
|
}
|
||||||
|
}, [agentId, deleteAgent, router, workspaceId])
|
||||||
|
|
||||||
|
const currentName = agent?.name ?? ''
|
||||||
|
|
||||||
|
const breadcrumbs = useMemo<BreadcrumbItem[]>(
|
||||||
|
() => [
|
||||||
|
{ label: 'Agents', onClick: () => router.push(`/workspace/${workspaceId}/agents`) },
|
||||||
|
{
|
||||||
|
label: currentName || '…',
|
||||||
|
editing:
|
||||||
|
agentRename.editingId === agentId
|
||||||
|
? {
|
||||||
|
isEditing: true,
|
||||||
|
value: agentRename.editValue,
|
||||||
|
onChange: agentRename.setEditValue,
|
||||||
|
onSubmit: agentRename.submitRename,
|
||||||
|
onCancel: agentRename.cancelRename,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
dropdownItems: [
|
||||||
|
{
|
||||||
|
label: 'Rename',
|
||||||
|
icon: Pencil,
|
||||||
|
onClick: () => agentRename.startRename(agentId, currentName),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Delete',
|
||||||
|
icon: Trash,
|
||||||
|
onClick: handleDelete,
|
||||||
|
disabled: isDeleting,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
agentId,
|
||||||
|
currentName,
|
||||||
|
agentRename.editingId,
|
||||||
|
agentRename.editValue,
|
||||||
|
agentRename.setEditValue,
|
||||||
|
agentRename.submitRename,
|
||||||
|
agentRename.cancelRename,
|
||||||
|
agentRename.startRename,
|
||||||
|
handleDelete,
|
||||||
|
isDeleting,
|
||||||
|
router,
|
||||||
|
workspaceId,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
const saveStatusLabel =
|
||||||
|
saveStatus === 'saving' ? 'Saving…' : saveStatus === 'unsaved' ? 'Unsaved changes' : undefined
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className='flex h-full flex-1 flex-col overflow-hidden bg-[var(--surface-1)]'>
|
||||||
|
<ResourceHeader icon={AgentIcon} breadcrumbs={[{ label: 'Agents' }, { label: '…' }]} />
|
||||||
|
<div className='flex flex-1 items-center justify-center'>
|
||||||
|
<span className='text-[14px] text-[var(--text-muted)]'>Loading…</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!agent) {
|
||||||
|
return (
|
||||||
|
<div className='flex h-full flex-1 flex-col overflow-hidden bg-[var(--surface-1)]'>
|
||||||
|
<ResourceHeader
|
||||||
|
icon={AgentIcon}
|
||||||
|
breadcrumbs={[
|
||||||
|
{ label: 'Agents', onClick: () => router.push(`/workspace/${workspaceId}/agents`) },
|
||||||
|
{ label: 'Not found' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<div className='flex flex-1 items-center justify-center'>
|
||||||
|
<span className='text-[14px] text-[var(--text-muted)]'>Agent not found.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='flex h-full flex-1 flex-col overflow-hidden bg-[var(--surface-1)]'>
|
||||||
|
<ResourceHeader
|
||||||
|
icon={AgentIcon}
|
||||||
|
breadcrumbs={breadcrumbs}
|
||||||
|
actions={[
|
||||||
|
...(saveStatusLabel
|
||||||
|
? [{ label: saveStatusLabel, onClick: () => {}, disabled: true }]
|
||||||
|
: []),
|
||||||
|
{ label: 'Deploy', onClick: () => setIsDeployModalOpen(true) },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className='flex min-h-0 flex-1 overflow-hidden'>
|
||||||
|
<div className='flex w-[400px] min-w-[320px] flex-shrink-0 flex-col overflow-y-auto border-[var(--border-1)] border-r'>
|
||||||
|
<AgentConfigPanel
|
||||||
|
config={localConfig}
|
||||||
|
agentId={agentId}
|
||||||
|
workspaceId={workspaceId}
|
||||||
|
onConfigChange={handleConfigChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='flex min-w-0 flex-1 flex-col overflow-hidden'>
|
||||||
|
<AgentTestPanel agentId={agentId} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DeployModal
|
||||||
|
agentId={agentId}
|
||||||
|
workspaceId={workspaceId}
|
||||||
|
isDeployed={agent.isDeployed}
|
||||||
|
open={isDeployModalOpen}
|
||||||
|
onOpenChange={setIsDeployModalOpen}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,789 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Combobox,
|
||||||
|
type ComboboxOption,
|
||||||
|
type ComboboxOptionGroup,
|
||||||
|
Input,
|
||||||
|
Label,
|
||||||
|
Textarea,
|
||||||
|
} from '@/components/emcn'
|
||||||
|
import { ArrowUp, ChevronDown, Plus, X } from '@/components/emcn/icons'
|
||||||
|
import { AgentSkillsIcon } from '@/components/icons'
|
||||||
|
import type { AgentConfig, SkillInput } from '@/lib/agents/types'
|
||||||
|
import { getScopesForService } from '@/lib/oauth/utils'
|
||||||
|
import { AgentToolInput } from '@/app/workspace/[workspaceId]/agents/[agentId]/components/agent-config/agent-tool-input'
|
||||||
|
import {
|
||||||
|
type AgentWandState,
|
||||||
|
useAgentWand,
|
||||||
|
} from '@/app/workspace/[workspaceId]/agents/[agentId]/hooks/use-agent-wand'
|
||||||
|
import { SkillModal } from '@/app/workspace/[workspaceId]/settings/components/skills/components/skill-modal'
|
||||||
|
import {
|
||||||
|
checkEnvVarTrigger,
|
||||||
|
EnvVarDropdown,
|
||||||
|
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/env-var-dropdown'
|
||||||
|
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text'
|
||||||
|
import { ToolCredentialSelector } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/credential-selector'
|
||||||
|
import {
|
||||||
|
getModelOptions,
|
||||||
|
RESPONSE_FORMAT_WAND_CONFIG,
|
||||||
|
shouldRequireApiKeyForModel,
|
||||||
|
} from '@/blocks/utils'
|
||||||
|
import { type SkillDefinition, useSkills } from '@/hooks/queries/skills'
|
||||||
|
import {
|
||||||
|
getMaxTemperature,
|
||||||
|
getReasoningEffortValuesForModel,
|
||||||
|
getThinkingLevelsForModel,
|
||||||
|
getVerbosityValuesForModel,
|
||||||
|
MODELS_WITH_DEEP_RESEARCH,
|
||||||
|
MODELS_WITH_REASONING_EFFORT,
|
||||||
|
MODELS_WITH_THINKING,
|
||||||
|
MODELS_WITH_VERBOSITY,
|
||||||
|
MODELS_WITHOUT_MEMORY,
|
||||||
|
providers,
|
||||||
|
supportsTemperature,
|
||||||
|
} from '@/providers/utils'
|
||||||
|
import { useProvidersStore } from '@/stores/providers/store'
|
||||||
|
|
||||||
|
const MEMORY_OPTIONS: ComboboxOption[] = [
|
||||||
|
{ value: 'none', label: 'None' },
|
||||||
|
{ value: 'conversation', label: 'Conversation history' },
|
||||||
|
{ value: 'sliding_window', label: 'Sliding window (messages)' },
|
||||||
|
{ value: 'sliding_window_tokens', label: 'Sliding window (tokens)' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const AZURE_MODELS = [...providers['azure-openai'].models, ...providers['azure-anthropic'].models]
|
||||||
|
|
||||||
|
const DASHED_DIVIDER_STYLE = {
|
||||||
|
backgroundImage:
|
||||||
|
'repeating-linear-gradient(to right, var(--border) 0px, var(--border) 6px, transparent 6px, transparent 12px)',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
interface AgentConfigPanelProps {
|
||||||
|
config: AgentConfig
|
||||||
|
agentId: string
|
||||||
|
workspaceId: string
|
||||||
|
onConfigChange: (patch: Partial<AgentConfig>) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const SYSTEM_PROMPT_WAND_PROMPT = `You are an expert at creating professional, comprehensive LLM agent system prompts. Generate or modify a system prompt based on the user's request.
|
||||||
|
|
||||||
|
Current system prompt: {context}
|
||||||
|
|
||||||
|
RULES:
|
||||||
|
1. Generate ONLY the system prompt text — no JSON, no explanations, no markdown fences
|
||||||
|
2. Start with a clear role definition ("You are...")
|
||||||
|
3. Include specific methodology, response format requirements, and edge case handling
|
||||||
|
4. If editing, preserve structure unless asked to change it
|
||||||
|
5. Be detailed and professional`
|
||||||
|
|
||||||
|
function Divider() {
|
||||||
|
return (
|
||||||
|
<div className='px-[2px] pt-[16px] pb-[13px]'>
|
||||||
|
<div className='h-[1.25px]' style={DASHED_DIVIDER_STYLE} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldLabelRow({ label }: { label: string }) {
|
||||||
|
return (
|
||||||
|
<div className='flex items-center justify-between gap-[6px] pl-[2px]'>
|
||||||
|
<Label className='font-medium text-[13px]'>{label}</Label>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AgentConfigPanel({
|
||||||
|
config,
|
||||||
|
agentId,
|
||||||
|
workspaceId,
|
||||||
|
onConfigChange,
|
||||||
|
}: AgentConfigPanelProps) {
|
||||||
|
const storeProviders = useProvidersStore((s) => s.providers)
|
||||||
|
const model = config.model ?? ''
|
||||||
|
const [showAdvanced, setShowAdvanced] = useState(false)
|
||||||
|
|
||||||
|
const derived = useMemo(() => {
|
||||||
|
const reasoningValues = model ? getReasoningEffortValuesForModel(model) : null
|
||||||
|
const thinkingValues = model ? getThinkingLevelsForModel(model) : null
|
||||||
|
const verbosityValues = model ? getVerbosityValuesForModel(model) : null
|
||||||
|
|
||||||
|
const toOptions = (vals: string[]): ComboboxOption[] =>
|
||||||
|
vals.map((v) => ({ value: v, label: v }))
|
||||||
|
|
||||||
|
const isDeepResearch = MODELS_WITH_DEEP_RESEARCH.includes(model)
|
||||||
|
const showTemperature = Boolean(model) && supportsTemperature(model) && !isDeepResearch
|
||||||
|
|
||||||
|
return {
|
||||||
|
isVertexModel: providers.vertex.models.includes(model),
|
||||||
|
isAzureModel: AZURE_MODELS.includes(model),
|
||||||
|
isBedrockModel: providers.bedrock.models.includes(model),
|
||||||
|
showApiKey: shouldRequireApiKeyForModel(model),
|
||||||
|
isDeepResearch,
|
||||||
|
showMemory: !MODELS_WITHOUT_MEMORY.includes(model),
|
||||||
|
showReasoningEffort: MODELS_WITH_REASONING_EFFORT.includes(model),
|
||||||
|
showThinking: MODELS_WITH_THINKING.includes(model),
|
||||||
|
showVerbosity: MODELS_WITH_VERBOSITY.includes(model),
|
||||||
|
showTemperature,
|
||||||
|
maxTemperature: (model && getMaxTemperature(model)) ?? 1,
|
||||||
|
reasoningEffortOptions: toOptions(reasoningValues ?? ['auto', 'low', 'medium', 'high']),
|
||||||
|
thinkingLevelOptions: toOptions(
|
||||||
|
thinkingValues ? ['none', ...thinkingValues] : ['none', 'low', 'high']
|
||||||
|
),
|
||||||
|
verbosityOptions: toOptions(verbosityValues ?? ['auto', 'low', 'medium', 'high']),
|
||||||
|
}
|
||||||
|
}, [model])
|
||||||
|
|
||||||
|
const hasAdvancedFields =
|
||||||
|
derived.showReasoningEffort ||
|
||||||
|
derived.showThinking ||
|
||||||
|
derived.showVerbosity ||
|
||||||
|
derived.showTemperature ||
|
||||||
|
!derived.isDeepResearch
|
||||||
|
|
||||||
|
const modelOptions: ComboboxOption[] = useMemo(
|
||||||
|
() =>
|
||||||
|
getModelOptions().map((opt) => ({
|
||||||
|
label: opt.label,
|
||||||
|
value: opt.id,
|
||||||
|
...(opt.icon && { icon: opt.icon }),
|
||||||
|
})),
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[storeProviders]
|
||||||
|
)
|
||||||
|
|
||||||
|
const systemPrompt = config.messages?.find((m) => m.role === 'system')?.content ?? ''
|
||||||
|
|
||||||
|
const handleSystemPromptChange = useCallback(
|
||||||
|
(value: string) => {
|
||||||
|
const filtered = (config.messages ?? []).filter((m) => m.role !== 'system')
|
||||||
|
const messages = value.trim()
|
||||||
|
? [{ role: 'system' as const, content: value }, ...filtered]
|
||||||
|
: filtered
|
||||||
|
onConfigChange({ messages })
|
||||||
|
},
|
||||||
|
[config.messages, onConfigChange]
|
||||||
|
)
|
||||||
|
|
||||||
|
const systemPromptWand = useAgentWand({
|
||||||
|
systemPrompt: SYSTEM_PROMPT_WAND_PROMPT,
|
||||||
|
maintainHistory: true,
|
||||||
|
currentValue: systemPrompt,
|
||||||
|
onGeneratedContent: handleSystemPromptChange,
|
||||||
|
})
|
||||||
|
|
||||||
|
const responseFormatWand = useAgentWand({
|
||||||
|
systemPrompt: RESPONSE_FORMAT_WAND_CONFIG.prompt,
|
||||||
|
generationType: RESPONSE_FORMAT_WAND_CONFIG.generationType,
|
||||||
|
maintainHistory: RESPONSE_FORMAT_WAND_CONFIG.maintainHistory,
|
||||||
|
currentValue: config.responseFormat ?? '',
|
||||||
|
onGeneratedContent: (content) => onConfigChange({ responseFormat: content }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const [apiKeyFocused, setApiKeyFocused] = useState(false)
|
||||||
|
const [apiKeyEnvDropdown, setApiKeyEnvDropdown] = useState({ visible: false, searchTerm: '' })
|
||||||
|
const apiKeyInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='px-[8px] pt-[12px] pb-[8px]'>
|
||||||
|
{/* System prompt */}
|
||||||
|
<div className='flex flex-col gap-[10px]'>
|
||||||
|
<WandLabelRow label='System prompt' wand={systemPromptWand} />
|
||||||
|
<Textarea
|
||||||
|
placeholder='You are a helpful assistant…'
|
||||||
|
value={systemPrompt}
|
||||||
|
onChange={(e) => handleSystemPromptChange(e.target.value)}
|
||||||
|
rows={6}
|
||||||
|
className='resize-none text-[13px]'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
{/* Model */}
|
||||||
|
<div className='flex flex-col gap-[10px]'>
|
||||||
|
<FieldLabelRow label='Model' />
|
||||||
|
<Combobox
|
||||||
|
options={modelOptions}
|
||||||
|
value={config.model ?? ''}
|
||||||
|
onChange={(v) => onConfigChange({ model: v })}
|
||||||
|
placeholder='claude-sonnet-4-6'
|
||||||
|
editable
|
||||||
|
searchable
|
||||||
|
searchPlaceholder='Search models…'
|
||||||
|
emptyMessage='No models found'
|
||||||
|
maxHeight={240}
|
||||||
|
inputProps={{ autoComplete: 'off' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
{/* Vertex AI */}
|
||||||
|
{derived.isVertexModel && (
|
||||||
|
<>
|
||||||
|
<div className='flex flex-col gap-[10px]'>
|
||||||
|
<FieldLabelRow label='Google Cloud account' />
|
||||||
|
<ToolCredentialSelector
|
||||||
|
value={config.vertexCredential ?? ''}
|
||||||
|
onChange={(v) => onConfigChange({ vertexCredential: v || undefined })}
|
||||||
|
provider='vertex-ai'
|
||||||
|
serviceId='vertex-ai'
|
||||||
|
requiredScopes={getScopesForService('vertex-ai')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Divider />
|
||||||
|
<div className='flex flex-col gap-[10px]'>
|
||||||
|
<FieldLabelRow label='Google Cloud project' />
|
||||||
|
<Input
|
||||||
|
value={config.vertexProject ?? ''}
|
||||||
|
placeholder='your-gcp-project-id'
|
||||||
|
autoComplete='off'
|
||||||
|
onChange={(e) => onConfigChange({ vertexProject: e.target.value || undefined })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Divider />
|
||||||
|
<div className='flex flex-col gap-[10px]'>
|
||||||
|
<FieldLabelRow label='Google Cloud location' />
|
||||||
|
<Input
|
||||||
|
value={config.vertexLocation ?? ''}
|
||||||
|
placeholder='us-central1'
|
||||||
|
autoComplete='off'
|
||||||
|
onChange={(e) => onConfigChange({ vertexLocation: e.target.value || undefined })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Divider />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Azure */}
|
||||||
|
{derived.isAzureModel && (
|
||||||
|
<>
|
||||||
|
<div className='flex flex-col gap-[10px]'>
|
||||||
|
<FieldLabelRow label='Azure endpoint' />
|
||||||
|
<Input
|
||||||
|
value={config.azureEndpoint ?? ''}
|
||||||
|
placeholder='https://your-resource.services.ai.azure.com'
|
||||||
|
autoComplete='off'
|
||||||
|
onChange={(e) => onConfigChange({ azureEndpoint: e.target.value || undefined })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Divider />
|
||||||
|
<div className='flex flex-col gap-[10px]'>
|
||||||
|
<FieldLabelRow label='Azure API version' />
|
||||||
|
<Input
|
||||||
|
value={config.azureApiVersion ?? ''}
|
||||||
|
placeholder='2024-12-01-preview'
|
||||||
|
autoComplete='off'
|
||||||
|
onChange={(e) => onConfigChange({ azureApiVersion: e.target.value || undefined })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Divider />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* AWS Bedrock */}
|
||||||
|
{derived.isBedrockModel && (
|
||||||
|
<>
|
||||||
|
<div className='flex flex-col gap-[10px]'>
|
||||||
|
<FieldLabelRow label='AWS access key ID' />
|
||||||
|
<Input
|
||||||
|
value={config.bedrockAccessKeyId ?? ''}
|
||||||
|
placeholder='Enter your AWS Access Key ID'
|
||||||
|
autoComplete='off'
|
||||||
|
onChange={(e) => onConfigChange({ bedrockAccessKeyId: e.target.value || undefined })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Divider />
|
||||||
|
<div className='flex flex-col gap-[10px]'>
|
||||||
|
<FieldLabelRow label='AWS secret access key' />
|
||||||
|
<Input
|
||||||
|
value={config.bedrockSecretKey ?? ''}
|
||||||
|
placeholder='Enter your AWS Secret Access Key'
|
||||||
|
autoComplete='off'
|
||||||
|
onChange={(e) => onConfigChange({ bedrockSecretKey: e.target.value || undefined })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Divider />
|
||||||
|
<div className='flex flex-col gap-[10px]'>
|
||||||
|
<FieldLabelRow label='AWS region' />
|
||||||
|
<Input
|
||||||
|
value={config.bedrockRegion ?? ''}
|
||||||
|
placeholder='us-east-1'
|
||||||
|
autoComplete='off'
|
||||||
|
onChange={(e) => onConfigChange({ bedrockRegion: e.target.value || undefined })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Divider />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* API key */}
|
||||||
|
{derived.showApiKey && (
|
||||||
|
<>
|
||||||
|
<div className='flex flex-col gap-[10px]'>
|
||||||
|
<FieldLabelRow label='API key' />
|
||||||
|
<div className='relative'>
|
||||||
|
<Input
|
||||||
|
ref={apiKeyInputRef}
|
||||||
|
type='text'
|
||||||
|
value={
|
||||||
|
apiKeyFocused ? (config.apiKey ?? '') : '•'.repeat(config.apiKey?.length ?? 0)
|
||||||
|
}
|
||||||
|
placeholder='Enter your API key'
|
||||||
|
autoComplete='off'
|
||||||
|
data-lpignore='true'
|
||||||
|
data-form-type='other'
|
||||||
|
className='allow-scroll w-full overflow-auto text-transparent caret-foreground [-ms-overflow-style:none] [scrollbar-width:none] placeholder:text-muted-foreground/50 [&::-webkit-scrollbar]:hidden'
|
||||||
|
onFocus={() => setApiKeyFocused(true)}
|
||||||
|
onBlur={() => setApiKeyFocused(false)}
|
||||||
|
onChange={(e) => {
|
||||||
|
const val = e.target.value
|
||||||
|
onConfigChange({ apiKey: val || undefined })
|
||||||
|
const cursor = e.target.selectionStart ?? val.length
|
||||||
|
const { show, searchTerm } = checkEnvVarTrigger(val, cursor)
|
||||||
|
setApiKeyEnvDropdown({ visible: show, searchTerm })
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className='pointer-events-none absolute inset-0 flex items-center overflow-x-auto px-[8px] py-[6px] pr-3 font-medium font-sans text-foreground text-sm [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'>
|
||||||
|
<div className='min-w-fit whitespace-pre'>
|
||||||
|
{apiKeyFocused
|
||||||
|
? formatDisplayText(config.apiKey ?? '')
|
||||||
|
: '•'.repeat(config.apiKey?.length ?? 0)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<EnvVarDropdown
|
||||||
|
visible={apiKeyEnvDropdown.visible}
|
||||||
|
searchTerm={apiKeyEnvDropdown.searchTerm}
|
||||||
|
inputValue={config.apiKey ?? ''}
|
||||||
|
cursorPosition={
|
||||||
|
apiKeyInputRef.current?.selectionStart ?? config.apiKey?.length ?? 0
|
||||||
|
}
|
||||||
|
workspaceId={workspaceId}
|
||||||
|
inputRef={apiKeyInputRef as React.RefObject<HTMLInputElement>}
|
||||||
|
onSelect={(newValue) => {
|
||||||
|
onConfigChange({ apiKey: newValue || undefined })
|
||||||
|
setApiKeyEnvDropdown({ visible: false, searchTerm: '' })
|
||||||
|
}}
|
||||||
|
onClose={() => setApiKeyEnvDropdown({ visible: false, searchTerm: '' })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Divider />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tools */}
|
||||||
|
{!derived.isDeepResearch && (
|
||||||
|
<>
|
||||||
|
<div className='flex flex-col gap-[10px]'>
|
||||||
|
<FieldLabelRow label='Tools' />
|
||||||
|
<AgentToolInput
|
||||||
|
workspaceId={workspaceId}
|
||||||
|
selectedTools={config.tools ?? []}
|
||||||
|
model={config.model}
|
||||||
|
onChange={(tools) => onConfigChange({ tools })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Divider />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Skills */}
|
||||||
|
{!derived.isDeepResearch && (
|
||||||
|
<>
|
||||||
|
<div className='flex flex-col gap-[10px]'>
|
||||||
|
<FieldLabelRow label='Skills' />
|
||||||
|
<SkillsInput
|
||||||
|
workspaceId={workspaceId}
|
||||||
|
selectedSkills={config.skills ?? []}
|
||||||
|
onChange={(skills) => onConfigChange({ skills })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Divider />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Memory */}
|
||||||
|
{derived.showMemory && (
|
||||||
|
<>
|
||||||
|
<div className='flex flex-col gap-[10px]'>
|
||||||
|
<FieldLabelRow label='Memory' />
|
||||||
|
<Combobox
|
||||||
|
options={MEMORY_OPTIONS}
|
||||||
|
value={config.memoryType ?? 'none'}
|
||||||
|
onChange={(v) => onConfigChange({ memoryType: v as AgentConfig['memoryType'] })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{config.memoryType && config.memoryType !== 'none' && (
|
||||||
|
<>
|
||||||
|
<Divider />
|
||||||
|
<div className='flex flex-col gap-[10px]'>
|
||||||
|
<FieldLabelRow label='Conversation ID' />
|
||||||
|
<Input
|
||||||
|
value={config.conversationId ?? ''}
|
||||||
|
placeholder='e.g., user-123, session-abc'
|
||||||
|
autoComplete='off'
|
||||||
|
onChange={(e) => onConfigChange({ conversationId: e.target.value || undefined })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{config.memoryType === 'sliding_window' && (
|
||||||
|
<>
|
||||||
|
<Divider />
|
||||||
|
<div className='flex flex-col gap-[10px]'>
|
||||||
|
<FieldLabelRow label='Window size (messages)' />
|
||||||
|
<Input
|
||||||
|
value={config.slidingWindowSize ?? ''}
|
||||||
|
placeholder='20'
|
||||||
|
autoComplete='off'
|
||||||
|
onChange={(e) =>
|
||||||
|
onConfigChange({ slidingWindowSize: e.target.value || undefined })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{config.memoryType === 'sliding_window_tokens' && (
|
||||||
|
<>
|
||||||
|
<Divider />
|
||||||
|
<div className='flex flex-col gap-[10px]'>
|
||||||
|
<FieldLabelRow label='Window size (tokens)' />
|
||||||
|
<Input
|
||||||
|
value={config.slidingWindowTokens ?? ''}
|
||||||
|
placeholder='4000'
|
||||||
|
autoComplete='off'
|
||||||
|
onChange={(e) =>
|
||||||
|
onConfigChange({ slidingWindowTokens: e.target.value || undefined })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Divider />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Response format */}
|
||||||
|
{!derived.isDeepResearch && (
|
||||||
|
<div className='flex flex-col gap-[10px]'>
|
||||||
|
<WandLabelRow label='Response format' wand={responseFormatWand} />
|
||||||
|
<Textarea
|
||||||
|
placeholder={
|
||||||
|
'{\n "name": "my_schema",\n "strict": true,\n "schema": { "type": "object", "properties": {} }\n}'
|
||||||
|
}
|
||||||
|
value={config.responseFormat ?? ''}
|
||||||
|
onChange={(e) => onConfigChange({ responseFormat: e.target.value || undefined })}
|
||||||
|
rows={4}
|
||||||
|
className='resize-none font-mono text-[12px]'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Previous interaction ID (deep research) */}
|
||||||
|
{derived.isDeepResearch && (
|
||||||
|
<div className='flex flex-col gap-[10px]'>
|
||||||
|
<FieldLabelRow label='Previous interaction ID' />
|
||||||
|
<Input
|
||||||
|
value={config.previousInteractionId ?? ''}
|
||||||
|
placeholder='e.g., {{agent_1.interactionId}}'
|
||||||
|
autoComplete='off'
|
||||||
|
onChange={(e) => onConfigChange({ previousInteractionId: e.target.value || undefined })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Advanced fields toggle */}
|
||||||
|
{hasAdvancedFields && (
|
||||||
|
<>
|
||||||
|
<div className='flex items-center gap-[10px] px-[2px] pt-[14px] pb-[12px]'>
|
||||||
|
<div className='h-[1.25px] flex-1' style={DASHED_DIVIDER_STYLE} />
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
onClick={() => setShowAdvanced((v) => !v)}
|
||||||
|
className='flex items-center gap-[6px] whitespace-nowrap font-medium text-[13px] text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
|
||||||
|
>
|
||||||
|
{showAdvanced ? 'Hide additional fields' : 'Show additional fields'}
|
||||||
|
<ChevronDown
|
||||||
|
className={`h-[14px] w-[14px] transition-transform duration-200 ${showAdvanced ? 'rotate-180' : ''}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<div className='h-[1.25px] flex-1' style={DASHED_DIVIDER_STYLE} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showAdvanced && (
|
||||||
|
<>
|
||||||
|
{derived.showReasoningEffort && (
|
||||||
|
<>
|
||||||
|
<div className='flex flex-col gap-[10px]'>
|
||||||
|
<FieldLabelRow label='Reasoning effort' />
|
||||||
|
<Combobox
|
||||||
|
options={derived.reasoningEffortOptions}
|
||||||
|
value={config.reasoningEffort ?? 'auto'}
|
||||||
|
onChange={(v) => onConfigChange({ reasoningEffort: v })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Divider />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{derived.showThinking && (
|
||||||
|
<>
|
||||||
|
<div className='flex flex-col gap-[10px]'>
|
||||||
|
<FieldLabelRow label='Thinking level' />
|
||||||
|
<Combobox
|
||||||
|
options={derived.thinkingLevelOptions}
|
||||||
|
value={config.thinkingLevel ?? 'none'}
|
||||||
|
onChange={(v) => onConfigChange({ thinkingLevel: v })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Divider />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{derived.showVerbosity && (
|
||||||
|
<>
|
||||||
|
<div className='flex flex-col gap-[10px]'>
|
||||||
|
<FieldLabelRow label='Verbosity' />
|
||||||
|
<Combobox
|
||||||
|
options={derived.verbosityOptions}
|
||||||
|
value={config.verbosity ?? 'auto'}
|
||||||
|
onChange={(v) => onConfigChange({ verbosity: v })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Divider />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{derived.showTemperature && (
|
||||||
|
<>
|
||||||
|
<div className='flex flex-col gap-[10px]'>
|
||||||
|
<FieldLabelRow label={`Temperature (0–${derived.maxTemperature})`} />
|
||||||
|
<Input
|
||||||
|
type='number'
|
||||||
|
value={config.temperature ?? ''}
|
||||||
|
min={0}
|
||||||
|
max={derived.maxTemperature}
|
||||||
|
step={0.1}
|
||||||
|
placeholder='1.0'
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = Number.parseFloat(e.target.value)
|
||||||
|
onConfigChange({ temperature: Number.isNaN(v) ? undefined : v })
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Divider />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!derived.isDeepResearch && (
|
||||||
|
<div className='flex flex-col gap-[10px]'>
|
||||||
|
<FieldLabelRow label='Max tokens' />
|
||||||
|
<Input
|
||||||
|
type='number'
|
||||||
|
value={config.maxTokens ?? ''}
|
||||||
|
min={1}
|
||||||
|
max={200000}
|
||||||
|
placeholder='default'
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = Number.parseFloat(e.target.value)
|
||||||
|
onConfigChange({ maxTokens: Number.isNaN(v) ? undefined : v })
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WandLabelRowProps {
|
||||||
|
label: string
|
||||||
|
wand: AgentWandState
|
||||||
|
}
|
||||||
|
|
||||||
|
function WandLabelRow({ label, wand }: WandLabelRowProps) {
|
||||||
|
return (
|
||||||
|
<div className='flex items-center justify-between gap-[6px] pl-[2px]'>
|
||||||
|
<Label className='font-medium text-[13px]'>{label}</Label>
|
||||||
|
<div className='flex min-w-0 flex-1 items-center justify-end'>
|
||||||
|
{!wand.isSearchActive ? (
|
||||||
|
<Button
|
||||||
|
variant='active'
|
||||||
|
className='-my-1 h-5 px-2 py-0 text-[11px]'
|
||||||
|
onClick={wand.onSearchClick}
|
||||||
|
>
|
||||||
|
Generate
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<div className='-my-1 flex min-w-[120px] max-w-[280px] flex-1 items-center gap-[4px]'>
|
||||||
|
<Input
|
||||||
|
ref={wand.searchInputRef}
|
||||||
|
value={wand.isStreaming ? 'Generating...' : wand.searchQuery}
|
||||||
|
onChange={(e) => wand.onSearchChange(e.target.value)}
|
||||||
|
onBlur={(e) => {
|
||||||
|
const relatedTarget = e.relatedTarget as HTMLElement | null
|
||||||
|
if (relatedTarget?.closest('button')) return
|
||||||
|
wand.onSearchBlur()
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' && wand.searchQuery.trim() && !wand.isStreaming) {
|
||||||
|
wand.onSearchSubmit()
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
wand.onSearchCancel()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={wand.isStreaming}
|
||||||
|
className='h-5 min-w-[80px] flex-1 text-[11px]'
|
||||||
|
placeholder='Generate with AI...'
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant='primary'
|
||||||
|
disabled={!wand.searchQuery.trim() || wand.isStreaming}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
}}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
wand.onSearchSubmit()
|
||||||
|
}}
|
||||||
|
className='h-[20px] w-[20px] flex-shrink-0 p-0'
|
||||||
|
>
|
||||||
|
<ArrowUp className='h-[12px] w-[12px]' />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SkillsInputProps {
|
||||||
|
workspaceId: string
|
||||||
|
selectedSkills: SkillInput[]
|
||||||
|
onChange: (skills: SkillInput[]) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function SkillsInput({ workspaceId, selectedSkills, onChange }: SkillsInputProps) {
|
||||||
|
const { data: workspaceSkills = [] } = useSkills(workspaceId)
|
||||||
|
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||||
|
const [editingSkill, setEditingSkill] = useState<SkillDefinition | null>(null)
|
||||||
|
|
||||||
|
const selectedIds = useMemo(() => new Set(selectedSkills.map((s) => s.skillId)), [selectedSkills])
|
||||||
|
|
||||||
|
const skillGroups = useMemo((): ComboboxOptionGroup[] => {
|
||||||
|
const available = workspaceSkills.filter((s) => !selectedIds.has(s.id))
|
||||||
|
const groups: ComboboxOptionGroup[] = [
|
||||||
|
{
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: 'Create skill',
|
||||||
|
value: 'action-create-skill',
|
||||||
|
icon: Plus,
|
||||||
|
onSelect: () => setShowCreateModal(true),
|
||||||
|
keepOpen: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
if (available.length > 0) {
|
||||||
|
groups.push({
|
||||||
|
section: 'Skills',
|
||||||
|
items: available.map((s) => ({
|
||||||
|
label: s.name,
|
||||||
|
value: `skill-${s.id}`,
|
||||||
|
icon: AgentSkillsIcon,
|
||||||
|
onSelect: () => onChange([...selectedSkills, { skillId: s.id, name: s.name }]),
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return groups
|
||||||
|
}, [workspaceSkills, selectedIds, selectedSkills, onChange])
|
||||||
|
|
||||||
|
const handleRemove = useCallback(
|
||||||
|
(skillId: string) => onChange(selectedSkills.filter((s) => s.skillId !== skillId)),
|
||||||
|
[selectedSkills, onChange]
|
||||||
|
)
|
||||||
|
|
||||||
|
const resolveSkillName = useCallback(
|
||||||
|
(stored: SkillInput): string => {
|
||||||
|
const found = workspaceSkills.find((s) => s.id === stored.skillId)
|
||||||
|
return found?.name ?? stored.name ?? stored.skillId
|
||||||
|
},
|
||||||
|
[workspaceSkills]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className='w-full space-y-[8px]'>
|
||||||
|
<Combobox
|
||||||
|
options={[]}
|
||||||
|
groups={skillGroups}
|
||||||
|
placeholder='Add skill…'
|
||||||
|
searchable
|
||||||
|
searchPlaceholder='Search skills…'
|
||||||
|
maxHeight={240}
|
||||||
|
emptyMessage='No skills found'
|
||||||
|
/>
|
||||||
|
|
||||||
|
{selectedSkills.map((stored) => {
|
||||||
|
const fullSkill = workspaceSkills.find((s) => s.id === stored.skillId)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={stored.skillId}
|
||||||
|
className='group relative flex flex-col overflow-hidden rounded-[4px] border border-[var(--border-1)] transition-all duration-200 ease-in-out'
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className='flex cursor-pointer items-center justify-between gap-[8px] rounded-[4px] bg-[var(--surface-4)] px-[8px] py-[6.5px]'
|
||||||
|
onClick={() => fullSkill && setEditingSkill(fullSkill)}
|
||||||
|
>
|
||||||
|
<div className='flex min-w-0 flex-1 items-center gap-[8px]'>
|
||||||
|
<div className='flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center rounded-[4px] bg-[var(--surface-4)]'>
|
||||||
|
<AgentSkillsIcon className='h-[10px] w-[10px] text-[#333]' />
|
||||||
|
</div>
|
||||||
|
<span className='truncate font-medium text-[13px] text-[var(--text-primary)]'>
|
||||||
|
{resolveSkillName(stored)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleRemove(stored.skillId)
|
||||||
|
}}
|
||||||
|
className='flex items-center justify-center text-[var(--text-tertiary)] transition-colors hover:text-[var(--text-primary)]'
|
||||||
|
aria-label='Remove skill'
|
||||||
|
>
|
||||||
|
<X className='h-[13px] w-[13px]' />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SkillModal
|
||||||
|
open={showCreateModal || !!editingSkill}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
setShowCreateModal(false)
|
||||||
|
setEditingSkill(null)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onSave={() => {
|
||||||
|
setShowCreateModal(false)
|
||||||
|
setEditingSkill(null)
|
||||||
|
}}
|
||||||
|
initialValues={editingSkill ?? undefined}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,739 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useCallback, useMemo, useState } from 'react'
|
||||||
|
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||||
|
import { useParams } from 'next/navigation'
|
||||||
|
import { ReactFlowProvider } from 'reactflow'
|
||||||
|
import {
|
||||||
|
Combobox,
|
||||||
|
type ComboboxOptionGroup,
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverItem,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from '@/components/emcn'
|
||||||
|
import { Plus, Wrench, X } from '@/components/emcn/icons'
|
||||||
|
import { McpIcon, WorkflowIcon } from '@/components/icons'
|
||||||
|
import type { ToolInput } from '@/lib/agents/types'
|
||||||
|
import { cn } from '@/lib/core/utils/cn'
|
||||||
|
import { McpServerFormModal } from '@/app/workspace/[workspaceId]/settings/components/mcp/components/mcp-server-form-modal/mcp-server-form-modal'
|
||||||
|
import {
|
||||||
|
type CustomTool,
|
||||||
|
CustomToolModal,
|
||||||
|
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/custom-tool-modal/custom-tool-modal'
|
||||||
|
import { ToolSubBlockRenderer } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/sub-block-renderer'
|
||||||
|
import { getAllBlocks } from '@/blocks'
|
||||||
|
import { BUILT_IN_TOOL_TYPES } from '@/blocks/utils'
|
||||||
|
import { type McpToolForUI, useMcpTools } from '@/hooks/mcp/use-mcp-tools'
|
||||||
|
import { useCustomTools } from '@/hooks/queries/custom-tools'
|
||||||
|
import { useAllowedMcpDomains, useCreateMcpServer, useMcpServers } from '@/hooks/queries/mcp'
|
||||||
|
import { useWorkflows } from '@/hooks/queries/workflows'
|
||||||
|
import { useAvailableEnvVarKeys } from '@/hooks/use-available-env-vars'
|
||||||
|
import { getProviderFromModel, supportsToolUsageControl } from '@/providers/utils'
|
||||||
|
import { getSubBlocksForToolInput, getToolParametersConfig } from '@/tools/params'
|
||||||
|
|
||||||
|
/** Returns true if a block type has more than one tool operation. */
|
||||||
|
function hasMultipleOperations(blockType: string): boolean {
|
||||||
|
const block = getAllBlocks().find((b) => b.type === blockType)
|
||||||
|
return (block?.tools?.access?.length || 0) > 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the available operation options for a multi-operation block. */
|
||||||
|
function getOperationOptions(blockType: string): { label: string; id: string }[] {
|
||||||
|
const block = getAllBlocks().find((b) => b.type === blockType)
|
||||||
|
if (!block?.tools?.access) return []
|
||||||
|
|
||||||
|
const opSubBlock = block.subBlocks.find((sb) => sb.id === 'operation')
|
||||||
|
if (opSubBlock?.type === 'dropdown' && Array.isArray(opSubBlock.options)) {
|
||||||
|
return opSubBlock.options as { label: string; id: string }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
return block.tools.access.map((toolId) => {
|
||||||
|
const params = getToolParametersConfig(toolId)
|
||||||
|
return { id: toolId, label: params?.toolConfig?.name || toolId }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the concrete toolId for a given block type and optional operation. */
|
||||||
|
function getToolIdForOperation(blockType: string, operation?: string): string | undefined {
|
||||||
|
const block = getAllBlocks().find((b) => b.type === blockType)
|
||||||
|
if (!block?.tools?.access) return undefined
|
||||||
|
if (block.tools.access.length === 1) return block.tools.access[0]
|
||||||
|
if (operation && block.tools?.config?.tool) {
|
||||||
|
try {
|
||||||
|
return block.tools.config.tool({ operation })
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
if (operation && block.tools.access.includes(operation)) return operation
|
||||||
|
return block.tools.access[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AgentToolInputProps {
|
||||||
|
workspaceId: string
|
||||||
|
selectedTools: ToolInput[]
|
||||||
|
model?: string
|
||||||
|
onChange: (tools: ToolInput[]) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AgentToolInput({
|
||||||
|
workspaceId,
|
||||||
|
selectedTools,
|
||||||
|
model,
|
||||||
|
onChange,
|
||||||
|
}: AgentToolInputProps) {
|
||||||
|
const params = useParams()
|
||||||
|
const agentId = (params?.agentId as string) || 'agent'
|
||||||
|
|
||||||
|
const [activeMcpServerId, setActiveMcpServerId] = useState<string | null>(null)
|
||||||
|
const [showCustomToolModal, setShowCustomToolModal] = useState(false)
|
||||||
|
const [showMcpModal, setShowMcpModal] = useState(false)
|
||||||
|
const [usageControlIndex, setUsageControlIndex] = useState<number | null>(null)
|
||||||
|
|
||||||
|
const { data: customTools = [] } = useCustomTools(workspaceId)
|
||||||
|
const { mcpTools } = useMcpTools(workspaceId)
|
||||||
|
const { data: servers = [] } = useMcpServers(workspaceId)
|
||||||
|
const { data: workflowsList = [] } = useWorkflows(workspaceId, { syncRegistry: false })
|
||||||
|
const createMcpServer = useCreateMcpServer()
|
||||||
|
const { data: allowedMcpDomains = null } = useAllowedMcpDomains()
|
||||||
|
const availableEnvVars = useAvailableEnvVarKeys(workspaceId)
|
||||||
|
|
||||||
|
const toolBlocks = useMemo(
|
||||||
|
() =>
|
||||||
|
getAllBlocks().filter(
|
||||||
|
(block) =>
|
||||||
|
!block.hideFromToolbar &&
|
||||||
|
(block.category === 'tools' ||
|
||||||
|
block.type === 'api' ||
|
||||||
|
block.type === 'webhook_request' ||
|
||||||
|
block.type === 'knowledge' ||
|
||||||
|
block.type === 'function' ||
|
||||||
|
block.type === 'table') &&
|
||||||
|
block.type !== 'evaluator' &&
|
||||||
|
block.type !== 'mcp' &&
|
||||||
|
block.type !== 'file'
|
||||||
|
),
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleSelectBlock = useCallback(
|
||||||
|
(block: ReturnType<typeof getAllBlocks>[number]) => {
|
||||||
|
const hasOps = hasMultipleOperations(block.type)
|
||||||
|
const ops = hasOps ? getOperationOptions(block.type) : []
|
||||||
|
const defaultOp = ops.length > 0 ? ops[0].id : undefined
|
||||||
|
const toolId = getToolIdForOperation(block.type, defaultOp) || ''
|
||||||
|
const newTool: ToolInput = {
|
||||||
|
type: block.type,
|
||||||
|
title: block.name,
|
||||||
|
toolId,
|
||||||
|
params: {},
|
||||||
|
operation: defaultOp,
|
||||||
|
isExpanded: true,
|
||||||
|
usageControl: 'auto',
|
||||||
|
}
|
||||||
|
onChange([...selectedTools.map((t) => ({ ...t, isExpanded: false })), newTool])
|
||||||
|
},
|
||||||
|
[selectedTools, onChange]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleSelectCustomTool = useCallback(
|
||||||
|
(tool: { id: string; title: string }) => {
|
||||||
|
const newTool: ToolInput = {
|
||||||
|
type: 'custom-tool',
|
||||||
|
title: tool.title,
|
||||||
|
customToolId: tool.id,
|
||||||
|
usageControl: 'auto',
|
||||||
|
}
|
||||||
|
onChange([...selectedTools, newTool])
|
||||||
|
},
|
||||||
|
[selectedTools, onChange]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleSelectMcpTool = useCallback(
|
||||||
|
(mcpTool: McpToolForUI) => {
|
||||||
|
const newTool: ToolInput = {
|
||||||
|
type: 'mcp',
|
||||||
|
title: mcpTool.name,
|
||||||
|
toolId: mcpTool.id,
|
||||||
|
params: {
|
||||||
|
serverId: mcpTool.serverId,
|
||||||
|
toolName: mcpTool.name,
|
||||||
|
serverName: mcpTool.serverName,
|
||||||
|
},
|
||||||
|
schema: mcpTool.inputSchema,
|
||||||
|
usageControl: 'auto',
|
||||||
|
}
|
||||||
|
onChange([...selectedTools, newTool])
|
||||||
|
},
|
||||||
|
[selectedTools, onChange]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleSelectWorkflow = useCallback(
|
||||||
|
(workflow: { id: string; name: string }) => {
|
||||||
|
const alreadySelected = selectedTools.some(
|
||||||
|
(t) => t.type === 'workflow_input' && t.params?.workflowId === workflow.id
|
||||||
|
)
|
||||||
|
if (alreadySelected) return
|
||||||
|
const newTool: ToolInput = {
|
||||||
|
type: 'workflow_input',
|
||||||
|
title: workflow.name,
|
||||||
|
toolId: 'workflow_executor',
|
||||||
|
params: { workflowId: workflow.id },
|
||||||
|
isExpanded: true,
|
||||||
|
usageControl: 'auto',
|
||||||
|
}
|
||||||
|
onChange([...selectedTools.map((t) => ({ ...t, isExpanded: false })), newTool])
|
||||||
|
},
|
||||||
|
[selectedTools, onChange]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleRemove = useCallback(
|
||||||
|
(index: number) => {
|
||||||
|
const next = [...selectedTools]
|
||||||
|
next.splice(index, 1)
|
||||||
|
onChange(next)
|
||||||
|
},
|
||||||
|
[selectedTools, onChange]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleParamChange = useCallback(
|
||||||
|
(index: number, paramId: string, value: string) => {
|
||||||
|
onChange(
|
||||||
|
selectedTools.map((t, i) =>
|
||||||
|
i === index ? { ...t, params: { ...(t.params || {}), [paramId]: value } } : t
|
||||||
|
)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
[selectedTools, onChange]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleOperationChange = useCallback(
|
||||||
|
(index: number, operation: string) => {
|
||||||
|
const tool = selectedTools[index]
|
||||||
|
const newToolId = getToolIdForOperation(tool.type ?? '', operation) || ''
|
||||||
|
onChange(
|
||||||
|
selectedTools.map((t, i) =>
|
||||||
|
i === index ? { ...t, operation, toolId: newToolId, params: {} } : t
|
||||||
|
)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
[selectedTools, onChange]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleUsageControlChange = useCallback(
|
||||||
|
(index: number, value: 'auto' | 'force' | 'none') => {
|
||||||
|
onChange(selectedTools.map((t, i) => (i === index ? { ...t, usageControl: value } : t)))
|
||||||
|
},
|
||||||
|
[selectedTools, onChange]
|
||||||
|
)
|
||||||
|
|
||||||
|
const toggleExpansion = useCallback(
|
||||||
|
(index: number) => {
|
||||||
|
onChange(selectedTools.map((t, i) => (i === index ? { ...t, isExpanded: !t.isExpanded } : t)))
|
||||||
|
},
|
||||||
|
[selectedTools, onChange]
|
||||||
|
)
|
||||||
|
|
||||||
|
const selectedCustomIds = useMemo(
|
||||||
|
() => new Set(selectedTools.filter((t) => t.type === 'custom-tool').map((t) => t.customToolId)),
|
||||||
|
[selectedTools]
|
||||||
|
)
|
||||||
|
|
||||||
|
const selectedMcpKeys = useMemo(
|
||||||
|
() =>
|
||||||
|
new Set(
|
||||||
|
selectedTools
|
||||||
|
.filter((t) => t.type === 'mcp')
|
||||||
|
.map((t) => `${t.params?.serverId ?? ''}:${t.params?.toolName ?? ''}`)
|
||||||
|
),
|
||||||
|
[selectedTools]
|
||||||
|
)
|
||||||
|
|
||||||
|
const showUsageControl = useMemo(() => {
|
||||||
|
if (!model) return false
|
||||||
|
const provider = getProviderFromModel(model)
|
||||||
|
return Boolean(provider && supportsToolUsageControl(provider))
|
||||||
|
}, [model])
|
||||||
|
|
||||||
|
const toolGroups = useMemo((): ComboboxOptionGroup[] => {
|
||||||
|
if (activeMcpServerId) {
|
||||||
|
const server = servers.find((s) => s.id === activeMcpServerId)
|
||||||
|
const serverTools = mcpTools.filter(
|
||||||
|
(t) => t.serverId === activeMcpServerId && !selectedMcpKeys.has(`${t.serverId}:${t.name}`)
|
||||||
|
)
|
||||||
|
|
||||||
|
const groups: ComboboxOptionGroup[] = [
|
||||||
|
{
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: 'Back',
|
||||||
|
value: 'action-back',
|
||||||
|
icon: ChevronLeft,
|
||||||
|
onSelect: () => setActiveMcpServerId(null),
|
||||||
|
keepOpen: true,
|
||||||
|
},
|
||||||
|
...(serverTools.length > 0
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
label: `Use all ${serverTools.length} tool${serverTools.length !== 1 ? 's' : ''}`,
|
||||||
|
value: 'action-use-all',
|
||||||
|
icon: McpIcon,
|
||||||
|
onSelect: () => {
|
||||||
|
const newTools: ToolInput[] = serverTools.map((t) => ({
|
||||||
|
type: 'mcp',
|
||||||
|
title: t.name,
|
||||||
|
toolId: t.id,
|
||||||
|
params: {
|
||||||
|
serverId: t.serverId,
|
||||||
|
toolName: t.name,
|
||||||
|
serverName: t.serverName,
|
||||||
|
},
|
||||||
|
schema: t.inputSchema,
|
||||||
|
usageControl: 'auto' as const,
|
||||||
|
}))
|
||||||
|
onChange([...selectedTools, ...newTools])
|
||||||
|
setActiveMcpServerId(null)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
if (serverTools.length > 0) {
|
||||||
|
groups.push({
|
||||||
|
section: server?.name ?? activeMcpServerId,
|
||||||
|
items: serverTools.map((tool) => ({
|
||||||
|
label: tool.name,
|
||||||
|
value: `mcp:${tool.serverId}:${tool.name}`,
|
||||||
|
icon: McpIcon,
|
||||||
|
onSelect: () => {
|
||||||
|
handleSelectMcpTool(tool)
|
||||||
|
setActiveMcpServerId(null)
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups: ComboboxOptionGroup[] = []
|
||||||
|
|
||||||
|
groups.push({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: 'Create custom tool',
|
||||||
|
value: 'action-create-tool',
|
||||||
|
icon: Plus,
|
||||||
|
onSelect: () => setShowCustomToolModal(true),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Add MCP server',
|
||||||
|
value: 'action-add-mcp',
|
||||||
|
icon: Plus,
|
||||||
|
onSelect: () => setShowMcpModal(true),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const availableCustom = customTools.filter((t) => !selectedCustomIds.has(t.id))
|
||||||
|
if (availableCustom.length > 0) {
|
||||||
|
groups.push({
|
||||||
|
section: 'Custom tools',
|
||||||
|
items: availableCustom.map((t) => ({
|
||||||
|
label: t.title,
|
||||||
|
value: `custom:${t.id}`,
|
||||||
|
icon: Wrench,
|
||||||
|
onSelect: () => handleSelectCustomTool(t),
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const enabledServers = servers.filter((s) => s.enabled)
|
||||||
|
if (enabledServers.length > 0) {
|
||||||
|
groups.push({
|
||||||
|
section: 'MCP servers',
|
||||||
|
items: enabledServers.map((server) => {
|
||||||
|
const count = mcpTools.filter((t) => t.serverId === server.id).length
|
||||||
|
return {
|
||||||
|
label: server.name,
|
||||||
|
value: `server:${server.id}`,
|
||||||
|
icon: McpIcon,
|
||||||
|
suffixElement: (
|
||||||
|
<div className='flex items-center gap-[3px] text-[11px] text-[var(--text-muted)]'>
|
||||||
|
<span>{count}</span>
|
||||||
|
<ChevronRight className='h-[9px] w-[9px]' />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
onSelect: () => setActiveMcpServerId(server.id),
|
||||||
|
keepOpen: true,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const builtInBlocks = toolBlocks.filter((b) => BUILT_IN_TOOL_TYPES.has(b.type))
|
||||||
|
if (builtInBlocks.length > 0) {
|
||||||
|
groups.push({
|
||||||
|
section: 'Tools',
|
||||||
|
items: builtInBlocks.map((block) => ({
|
||||||
|
label: block.name,
|
||||||
|
value: `block:${block.type}`,
|
||||||
|
iconElement: (
|
||||||
|
<div
|
||||||
|
className='flex h-[14px] w-[14px] flex-shrink-0 items-center justify-center rounded-[3px]'
|
||||||
|
style={{ backgroundColor: block.bgColor }}
|
||||||
|
>
|
||||||
|
<block.icon className='h-[9px] w-[9px] text-white' />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
onSelect: () => handleSelectBlock(block),
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const integrationBlocks = toolBlocks.filter((b) => !BUILT_IN_TOOL_TYPES.has(b.type))
|
||||||
|
if (integrationBlocks.length > 0) {
|
||||||
|
groups.push({
|
||||||
|
section: 'Integrations',
|
||||||
|
items: integrationBlocks.map((block) => ({
|
||||||
|
label: block.name,
|
||||||
|
value: `integration:${block.type}`,
|
||||||
|
iconElement: (
|
||||||
|
<div
|
||||||
|
className='flex h-[14px] w-[14px] flex-shrink-0 items-center justify-center rounded-[3px]'
|
||||||
|
style={{ backgroundColor: block.bgColor }}
|
||||||
|
>
|
||||||
|
<block.icon className='h-[9px] w-[9px] text-white' />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
onSelect: () => handleSelectBlock(block),
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedWorkflowIds = new Set(
|
||||||
|
selectedTools
|
||||||
|
.filter((t) => t.type === 'workflow_input')
|
||||||
|
.map((t) => t.params?.workflowId as string)
|
||||||
|
)
|
||||||
|
const availableWorkflows = workflowsList.filter((w) => !selectedWorkflowIds.has(w.id))
|
||||||
|
if (availableWorkflows.length > 0) {
|
||||||
|
groups.push({
|
||||||
|
section: 'Workflows',
|
||||||
|
items: availableWorkflows.map((workflow) => ({
|
||||||
|
label: workflow.name,
|
||||||
|
value: `workflow:${workflow.id}`,
|
||||||
|
iconElement: (
|
||||||
|
<div className='flex h-[14px] w-[14px] flex-shrink-0 items-center justify-center rounded-[3px] bg-[#6366F1]'>
|
||||||
|
<WorkflowIcon className='h-[9px] w-[9px] text-white' />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
onSelect: () => handleSelectWorkflow(workflow),
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups
|
||||||
|
}, [
|
||||||
|
activeMcpServerId,
|
||||||
|
servers,
|
||||||
|
mcpTools,
|
||||||
|
selectedMcpKeys,
|
||||||
|
customTools,
|
||||||
|
selectedCustomIds,
|
||||||
|
toolBlocks,
|
||||||
|
selectedTools,
|
||||||
|
workflowsList,
|
||||||
|
onChange,
|
||||||
|
handleSelectMcpTool,
|
||||||
|
handleSelectCustomTool,
|
||||||
|
handleSelectBlock,
|
||||||
|
handleSelectWorkflow,
|
||||||
|
])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='w-full space-y-[8px]'>
|
||||||
|
<Combobox
|
||||||
|
options={[]}
|
||||||
|
groups={toolGroups}
|
||||||
|
placeholder='Add tool…'
|
||||||
|
searchable
|
||||||
|
searchPlaceholder='Search tools…'
|
||||||
|
maxHeight={280}
|
||||||
|
emptyMessage='No tools available'
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setActiveMcpServerId(null)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{selectedTools.map((tool, idx) => (
|
||||||
|
<ToolCard
|
||||||
|
key={`${tool.type}-${tool.customToolId ?? ''}-${tool.toolId ?? ''}-${tool.params?.serverId ?? ''}-${tool.params?.toolName ?? ''}-${tool.params?.workflowId ?? ''}-${idx}`}
|
||||||
|
tool={tool}
|
||||||
|
toolIndex={idx}
|
||||||
|
agentId={agentId}
|
||||||
|
toolBlocks={toolBlocks}
|
||||||
|
showUsageControl={showUsageControl}
|
||||||
|
usageControlOpen={usageControlIndex === idx}
|
||||||
|
onUsageControlOpenChange={(open) => setUsageControlIndex(open ? idx : null)}
|
||||||
|
onRemove={() => handleRemove(idx)}
|
||||||
|
onParamChange={(paramId, value) => handleParamChange(idx, paramId, value)}
|
||||||
|
onOperationChange={(op) => handleOperationChange(idx, op)}
|
||||||
|
onUsageControlChange={(v) => handleUsageControlChange(idx, v)}
|
||||||
|
onToggleExpansion={() => toggleExpansion(idx)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<CustomToolModal
|
||||||
|
open={showCustomToolModal}
|
||||||
|
onOpenChange={setShowCustomToolModal}
|
||||||
|
onSave={(tool: CustomTool) => {
|
||||||
|
if (tool.id) {
|
||||||
|
handleSelectCustomTool({ id: tool.id, title: tool.title })
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
blockId=''
|
||||||
|
/>
|
||||||
|
|
||||||
|
<McpServerFormModal
|
||||||
|
open={showMcpModal}
|
||||||
|
onOpenChange={setShowMcpModal}
|
||||||
|
mode='add'
|
||||||
|
onSubmit={async (config) => {
|
||||||
|
await createMcpServer.mutateAsync({ workspaceId, config: { ...config, enabled: true } })
|
||||||
|
}}
|
||||||
|
workspaceId={workspaceId}
|
||||||
|
availableEnvVars={availableEnvVars}
|
||||||
|
allowedMcpDomains={allowedMcpDomains}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ToolCardProps {
|
||||||
|
tool: ToolInput
|
||||||
|
toolIndex: number
|
||||||
|
agentId: string
|
||||||
|
toolBlocks: ReturnType<typeof getAllBlocks>
|
||||||
|
showUsageControl: boolean
|
||||||
|
usageControlOpen: boolean
|
||||||
|
onUsageControlOpenChange: (open: boolean) => void
|
||||||
|
onRemove: () => void
|
||||||
|
onParamChange: (paramId: string, value: string) => void
|
||||||
|
onOperationChange: (op: string) => void
|
||||||
|
onUsageControlChange: (value: 'auto' | 'force' | 'none') => void
|
||||||
|
onToggleExpansion: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToolCard({
|
||||||
|
tool,
|
||||||
|
toolIndex,
|
||||||
|
agentId,
|
||||||
|
toolBlocks,
|
||||||
|
showUsageControl,
|
||||||
|
usageControlOpen,
|
||||||
|
onUsageControlOpenChange,
|
||||||
|
onRemove,
|
||||||
|
onParamChange,
|
||||||
|
onOperationChange,
|
||||||
|
onUsageControlChange,
|
||||||
|
onToggleExpansion,
|
||||||
|
}: ToolCardProps) {
|
||||||
|
const isCustomTool = tool.type === 'custom-tool'
|
||||||
|
const isMcpTool = tool.type === 'mcp'
|
||||||
|
const isWorkflowTool = tool.type === 'workflow_input' || tool.type === 'workflow'
|
||||||
|
|
||||||
|
const block = !isCustomTool && !isMcpTool ? toolBlocks.find((b) => b.type === tool.type) : null
|
||||||
|
|
||||||
|
const currentToolId =
|
||||||
|
!isCustomTool && !isMcpTool
|
||||||
|
? (getToolIdForOperation(tool.type ?? '', tool.operation) ?? tool.toolId ?? '')
|
||||||
|
: (tool.toolId ?? '')
|
||||||
|
|
||||||
|
const subBlocksResult =
|
||||||
|
!isCustomTool && !isMcpTool && currentToolId
|
||||||
|
? getSubBlocksForToolInput(currentToolId, tool.type ?? '', {
|
||||||
|
operation: tool.operation,
|
||||||
|
...(tool.params || {}),
|
||||||
|
})
|
||||||
|
: null
|
||||||
|
|
||||||
|
// Exclude input-mapping sub-blocks — the LLM resolves these at runtime
|
||||||
|
const displaySubBlocks = (subBlocksResult?.subBlocks ?? []).filter(
|
||||||
|
(sb) => sb.type !== 'input-mapping'
|
||||||
|
)
|
||||||
|
|
||||||
|
const hasOps = !isCustomTool && !isMcpTool && hasMultipleOperations(tool.type ?? '')
|
||||||
|
const hasBody = hasOps || displaySubBlocks.length > 0
|
||||||
|
const isExpanded = hasBody ? !!tool.isExpanded : false
|
||||||
|
|
||||||
|
const iconNode = isMcpTool ? (
|
||||||
|
<div className='flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center rounded-[4px] bg-[#ede9fb]'>
|
||||||
|
<McpIcon className='h-[10px] w-[10px] text-[#6b5fc9]' />
|
||||||
|
</div>
|
||||||
|
) : isCustomTool ? (
|
||||||
|
<div className='flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center rounded-[4px] bg-[#dbeafe]'>
|
||||||
|
<Wrench className='h-[10px] w-[10px] text-[#3b82f6]' />
|
||||||
|
</div>
|
||||||
|
) : isWorkflowTool ? (
|
||||||
|
<div className='flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center rounded-[4px] bg-[#6366F1]'>
|
||||||
|
<WorkflowIcon className='h-[10px] w-[10px] text-white' />
|
||||||
|
</div>
|
||||||
|
) : block ? (
|
||||||
|
<div
|
||||||
|
className='flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center rounded-[4px]'
|
||||||
|
style={{ backgroundColor: block.bgColor }}
|
||||||
|
>
|
||||||
|
<block.icon className='h-[10px] w-[10px] text-white' />
|
||||||
|
</div>
|
||||||
|
) : null
|
||||||
|
|
||||||
|
const displayTitle = tool.title ?? tool.params?.toolName ?? tool.type
|
||||||
|
|
||||||
|
const subtitle =
|
||||||
|
tool.type === 'mcp' && tool.params?.serverName ? String(tool.params.serverName) : undefined
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'group relative flex flex-col overflow-hidden rounded-[4px] border border-[var(--border-1)] transition-all duration-200 ease-in-out',
|
||||||
|
isExpanded ? 'rounded-b-[4px]' : ''
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Header row */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex items-center justify-between gap-[8px] bg-[var(--surface-4)] px-[8px] py-[6.5px]',
|
||||||
|
hasBody ? 'cursor-pointer rounded-t-[4px]' : 'rounded-[4px]'
|
||||||
|
)}
|
||||||
|
onClick={hasBody ? onToggleExpansion : undefined}
|
||||||
|
>
|
||||||
|
<div className='flex min-w-0 flex-1 items-center gap-[8px]'>
|
||||||
|
{iconNode}
|
||||||
|
<div className='min-w-0 flex-1'>
|
||||||
|
<span className='block truncate font-medium text-[13px] text-[var(--text-primary)]'>
|
||||||
|
{displayTitle}
|
||||||
|
</span>
|
||||||
|
{subtitle && (
|
||||||
|
<span className='block truncate text-[11px] text-[var(--text-muted)]'>
|
||||||
|
{subtitle}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='flex flex-shrink-0 items-center gap-[6px]'>
|
||||||
|
{showUsageControl && (
|
||||||
|
<Popover open={usageControlOpen} onOpenChange={onUsageControlOpenChange}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className='flex items-center justify-center font-medium text-[12px] text-[var(--text-tertiary)] transition-colors hover:text-[var(--text-primary)]'
|
||||||
|
aria-label='Tool usage control'
|
||||||
|
>
|
||||||
|
{tool.usageControl === 'force'
|
||||||
|
? 'Force'
|
||||||
|
: tool.usageControl === 'none'
|
||||||
|
? 'None'
|
||||||
|
: 'Auto'}
|
||||||
|
</button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
side='bottom'
|
||||||
|
align='end'
|
||||||
|
sideOffset={8}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className='gap-[2px]'
|
||||||
|
border
|
||||||
|
>
|
||||||
|
<PopoverItem
|
||||||
|
active={(tool.usageControl || 'auto') === 'auto'}
|
||||||
|
onClick={() => {
|
||||||
|
onUsageControlChange('auto')
|
||||||
|
onUsageControlOpenChange(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Auto <span className='text-[var(--text-tertiary)]'>(model decides)</span>
|
||||||
|
</PopoverItem>
|
||||||
|
<PopoverItem
|
||||||
|
active={tool.usageControl === 'force'}
|
||||||
|
onClick={() => {
|
||||||
|
onUsageControlChange('force')
|
||||||
|
onUsageControlOpenChange(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Force <span className='text-[var(--text-tertiary)]'>(always use)</span>
|
||||||
|
</PopoverItem>
|
||||||
|
<PopoverItem
|
||||||
|
active={tool.usageControl === 'none'}
|
||||||
|
onClick={() => {
|
||||||
|
onUsageControlChange('none')
|
||||||
|
onUsageControlOpenChange(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
None
|
||||||
|
</PopoverItem>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onRemove()
|
||||||
|
}}
|
||||||
|
className='flex items-center justify-center text-[var(--text-tertiary)] transition-colors hover:text-[var(--text-primary)]'
|
||||||
|
aria-label='Remove tool'
|
||||||
|
>
|
||||||
|
<X className='h-[13px] w-[13px]' />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expanded body */}
|
||||||
|
{isExpanded && (
|
||||||
|
<ReactFlowProvider>
|
||||||
|
<div className='flex flex-col gap-[10px] overflow-visible rounded-b-[4px] border-[var(--border-1)] border-t bg-[var(--surface-2)] px-[8px] py-[8px]'>
|
||||||
|
{/* Operation selector */}
|
||||||
|
{hasOps &&
|
||||||
|
(() => {
|
||||||
|
const opOptions = getOperationOptions(tool.type ?? '')
|
||||||
|
return opOptions.length > 0 ? (
|
||||||
|
<div className='space-y-[6px]'>
|
||||||
|
<div className='pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
|
||||||
|
Operation
|
||||||
|
</div>
|
||||||
|
<Combobox
|
||||||
|
options={opOptions
|
||||||
|
.filter((o) => o.id !== '')
|
||||||
|
.map((o) => ({ label: o.label, value: o.id }))}
|
||||||
|
value={tool.operation || opOptions[0]?.id || ''}
|
||||||
|
onChange={(v) => onOperationChange(v)}
|
||||||
|
placeholder='Select operation'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{/* Sub-block params */}
|
||||||
|
{displaySubBlocks.map((sb) => (
|
||||||
|
<ToolSubBlockRenderer
|
||||||
|
key={sb.id}
|
||||||
|
blockId={agentId}
|
||||||
|
subBlockId='agent-tools'
|
||||||
|
toolIndex={toolIndex}
|
||||||
|
subBlock={{ ...sb, title: sb.title || sb.id }}
|
||||||
|
effectiveParamId={sb.id}
|
||||||
|
toolParams={tool.params as Record<string, string> | undefined}
|
||||||
|
onParamChange={(_, paramId, value) => onParamChange(paramId, value)}
|
||||||
|
disabled={false}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ReactFlowProvider>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { createLogger } from '@sim/logger'
|
||||||
|
import { Button, Label } from '@/components/emcn'
|
||||||
|
import { Check, Copy } from '@/components/emcn/icons'
|
||||||
|
import { useUpdateAgent } from '@/hooks/queries/agents'
|
||||||
|
|
||||||
|
const logger = createLogger('ApiDeploy')
|
||||||
|
|
||||||
|
interface ApiDeployProps {
|
||||||
|
agentId: string
|
||||||
|
workspaceId: string
|
||||||
|
isDeployed: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ApiDeploy({ agentId, workspaceId, isDeployed }: ApiDeployProps) {
|
||||||
|
const { mutateAsync: updateAgent, isPending } = useUpdateAgent()
|
||||||
|
const [copied, setCopied] = useState(false)
|
||||||
|
|
||||||
|
const endpoint =
|
||||||
|
typeof window !== 'undefined'
|
||||||
|
? `${window.location.origin}/api/v1/agents/${agentId}`
|
||||||
|
: `/api/v1/agents/${agentId}`
|
||||||
|
|
||||||
|
const curlExample = `curl -X POST "${endpoint}" \\
|
||||||
|
-H "Authorization: Bearer <your-api-key>" \\
|
||||||
|
-H "Content-Type: application/json" \\
|
||||||
|
-d '{"message": "Hello, agent!"}'`
|
||||||
|
|
||||||
|
const handleCopy = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(curlExample)
|
||||||
|
setCopied(true)
|
||||||
|
setTimeout(() => setCopied(false), 2000)
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleToggle = async () => {
|
||||||
|
try {
|
||||||
|
await updateAgent({ agentId, isDeployed: !isDeployed })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to toggle API deployment', { error })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='space-y-[16px]'>
|
||||||
|
<p className='text-[13px] text-[var(--text-muted)]'>
|
||||||
|
Call your agent via REST API using your workspace API key.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<Label className='text-[13px]'>API access</Label>
|
||||||
|
<Button variant='subtle' size='sm' onClick={handleToggle} disabled={isPending}>
|
||||||
|
{isDeployed ? 'Disable' : 'Enable'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isDeployed && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<Label className='mb-[6px] block text-[12px] text-[var(--text-muted)]'>Endpoint</Label>
|
||||||
|
<div className='flex items-center gap-[8px] rounded-[6px] border border-[var(--border-1)] bg-[var(--surface-4)] px-[10px] py-[7px]'>
|
||||||
|
<code className='flex-1 truncate text-[12px] text-[var(--text-primary)]'>
|
||||||
|
{endpoint}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className='mb-[6px] flex items-center justify-between'>
|
||||||
|
<Label className='text-[12px] text-[var(--text-muted)]'>Example request</Label>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
onClick={handleCopy}
|
||||||
|
className='flex items-center gap-[4px] text-[11px] text-[var(--text-tertiary)] hover:text-[var(--text-primary)]'
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<>
|
||||||
|
<Check className='h-[11px] w-[11px]' />
|
||||||
|
Copied
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Copy className='h-[11px] w-[11px]' />
|
||||||
|
Copy
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<pre className='overflow-x-auto rounded-[6px] border border-[var(--border-1)] bg-[var(--surface-4)] px-[10px] py-[8px] text-[11px] text-[var(--text-primary)] leading-[1.6]'>
|
||||||
|
{curlExample}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,362 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useCallback, useMemo, useState } from 'react'
|
||||||
|
import { createLogger } from '@sim/logger'
|
||||||
|
import { Button, Combobox, type ComboboxOption, Input, Label } from '@/components/emcn'
|
||||||
|
import { Check, Loader, Plus } from '@/components/emcn/icons'
|
||||||
|
import type { AgentDeploymentRow, SlackDeploymentConfig } from '@/lib/agents/types'
|
||||||
|
import { cn } from '@/lib/core/utils/cn'
|
||||||
|
import { getCanonicalScopesForProvider } from '@/lib/oauth'
|
||||||
|
import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal'
|
||||||
|
import {
|
||||||
|
useAgent,
|
||||||
|
useDeployAgentToSlack,
|
||||||
|
useSlackChannels,
|
||||||
|
useUndeployAgentFromSlack,
|
||||||
|
} from '@/hooks/queries/agents'
|
||||||
|
import { useWorkspaceCredentials, type WorkspaceCredential } from '@/hooks/queries/credentials'
|
||||||
|
|
||||||
|
const logger = createLogger('SlackDeploy')
|
||||||
|
|
||||||
|
interface SlackDeployProps {
|
||||||
|
agentId: string
|
||||||
|
workspaceId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const RESPOND_TO_OPTIONS: ComboboxOption[] = [
|
||||||
|
{ value: 'mentions', label: '@mentions only' },
|
||||||
|
{ value: 'all', label: 'All messages in channel' },
|
||||||
|
{ value: 'threads', label: 'Thread replies only' },
|
||||||
|
{ value: 'dm', label: 'Direct messages (DMs)' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function SlackDeploy({ agentId, workspaceId }: SlackDeployProps) {
|
||||||
|
const { data: agent } = useAgent(agentId)
|
||||||
|
const { data: credentials = [], isLoading: isLoadingCredentials } = useWorkspaceCredentials({
|
||||||
|
workspaceId,
|
||||||
|
type: 'oauth',
|
||||||
|
providerId: 'slack',
|
||||||
|
})
|
||||||
|
|
||||||
|
const existingDeployment = agent?.deployments?.find((d) => d.platform === 'slack')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SlackDeployForm
|
||||||
|
key={existingDeployment?.id ?? 'new'}
|
||||||
|
agentId={agentId}
|
||||||
|
existingDeployment={existingDeployment}
|
||||||
|
credentials={credentials}
|
||||||
|
isLoadingCredentials={isLoadingCredentials}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SlackDeployFormProps {
|
||||||
|
agentId: string
|
||||||
|
existingDeployment: AgentDeploymentRow | undefined
|
||||||
|
credentials: WorkspaceCredential[]
|
||||||
|
isLoadingCredentials: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function SlackDeployForm({
|
||||||
|
agentId,
|
||||||
|
existingDeployment,
|
||||||
|
credentials,
|
||||||
|
isLoadingCredentials,
|
||||||
|
}: SlackDeployFormProps) {
|
||||||
|
const [showOAuthModal, setShowOAuthModal] = useState(false)
|
||||||
|
const [deployError, setDeployError] = useState<string | null>(null)
|
||||||
|
const { mutateAsync: deploy, isPending: isDeploying } = useDeployAgentToSlack()
|
||||||
|
const { mutateAsync: undeploy, isPending: isUndeploying } = useUndeployAgentFromSlack()
|
||||||
|
|
||||||
|
const existingCfg = existingDeployment?.config as SlackDeploymentConfig | undefined
|
||||||
|
|
||||||
|
const [selectedCredentialId, setSelectedCredentialId] = useState(
|
||||||
|
existingDeployment?.credentialId ?? ''
|
||||||
|
)
|
||||||
|
const [selectedChannelIds, setSelectedChannelIds] = useState<string[]>(
|
||||||
|
existingCfg?.channelIds ?? []
|
||||||
|
)
|
||||||
|
const [respondTo, setRespondTo] = useState<SlackDeploymentConfig['respondTo']>(
|
||||||
|
existingCfg?.respondTo ?? 'mentions'
|
||||||
|
)
|
||||||
|
const [botName, setBotName] = useState(existingCfg?.botName ?? '')
|
||||||
|
const [replyInThread, setReplyInThread] = useState(existingCfg?.replyInThread !== false)
|
||||||
|
|
||||||
|
const isDeployed = Boolean(existingDeployment?.isActive)
|
||||||
|
const isDmMode = respondTo === 'dm'
|
||||||
|
|
||||||
|
const { data: channels = [], isLoading: isLoadingChannels } =
|
||||||
|
useSlackChannels(selectedCredentialId)
|
||||||
|
|
||||||
|
const handleCredentialChange = useCallback((id: string) => {
|
||||||
|
setSelectedCredentialId(id)
|
||||||
|
setSelectedChannelIds([])
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleDeploy = useCallback(async () => {
|
||||||
|
if (!selectedCredentialId) return
|
||||||
|
if (!isDmMode && selectedChannelIds.length === 0) return
|
||||||
|
setDeployError(null)
|
||||||
|
try {
|
||||||
|
await deploy({
|
||||||
|
agentId,
|
||||||
|
credentialId: selectedCredentialId,
|
||||||
|
channelIds: isDmMode ? [] : selectedChannelIds,
|
||||||
|
respondTo,
|
||||||
|
botName: botName.trim() || undefined,
|
||||||
|
replyInThread,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to deploy agent to Slack', { error })
|
||||||
|
setDeployError(error instanceof Error ? error.message : 'Deployment failed')
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
agentId,
|
||||||
|
botName,
|
||||||
|
deploy,
|
||||||
|
isDmMode,
|
||||||
|
respondTo,
|
||||||
|
replyInThread,
|
||||||
|
selectedChannelIds,
|
||||||
|
selectedCredentialId,
|
||||||
|
])
|
||||||
|
|
||||||
|
const handleUndeploy = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await undeploy({ agentId })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to undeploy agent from Slack', { error })
|
||||||
|
}
|
||||||
|
}, [agentId, undeploy])
|
||||||
|
|
||||||
|
const toggleChannel = useCallback((channelId: string) => {
|
||||||
|
setSelectedChannelIds((prev) =>
|
||||||
|
prev.includes(channelId) ? prev.filter((id) => id !== channelId) : [...prev, channelId]
|
||||||
|
)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const credentialOptions: ComboboxOption[] = useMemo(
|
||||||
|
() => credentials.map((c) => ({ value: c.id, label: c.displayName })),
|
||||||
|
[credentials]
|
||||||
|
)
|
||||||
|
|
||||||
|
const hasValidConfig = selectedCredentialId && (isDmMode || selectedChannelIds.length > 0)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<OAuthRequiredModal
|
||||||
|
isOpen={showOAuthModal}
|
||||||
|
onClose={() => setShowOAuthModal(false)}
|
||||||
|
provider='slack'
|
||||||
|
toolName='Slack'
|
||||||
|
requiredScopes={getCanonicalScopesForProvider('slack')}
|
||||||
|
serviceId='slack'
|
||||||
|
/>
|
||||||
|
<div className='flex flex-col gap-[16px]'>
|
||||||
|
{/* Workspace */}
|
||||||
|
<div>
|
||||||
|
<Label className='mb-[6px] block text-[12px] text-[var(--text-muted)]'>
|
||||||
|
Slack workspace
|
||||||
|
</Label>
|
||||||
|
{isLoadingCredentials ? (
|
||||||
|
<div className='flex h-[36px] items-center gap-[6px] text-[12px] text-[var(--text-muted)]'>
|
||||||
|
<Loader className='h-[12px] w-[12px] animate-spin' />
|
||||||
|
Loading…
|
||||||
|
</div>
|
||||||
|
) : credentials.length === 0 ? (
|
||||||
|
<div className='flex flex-col gap-[8px]'>
|
||||||
|
<p className='text-[12px] text-[var(--text-muted)]'>
|
||||||
|
No Slack workspace connected yet.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant='outline'
|
||||||
|
size='sm'
|
||||||
|
onClick={() => setShowOAuthModal(true)}
|
||||||
|
className='w-fit'
|
||||||
|
>
|
||||||
|
<Plus className='mr-[4px] h-[12px] w-[12px]' />
|
||||||
|
Connect Slack
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className='flex items-center gap-[8px]'>
|
||||||
|
<div className='flex-1'>
|
||||||
|
<Combobox
|
||||||
|
options={credentialOptions}
|
||||||
|
value={selectedCredentialId}
|
||||||
|
onChange={handleCredentialChange}
|
||||||
|
placeholder='Select a Slack workspace…'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
onClick={() => setShowOAuthModal(true)}
|
||||||
|
className='flex-shrink-0 text-[var(--text-muted)] transition-colors hover:text-[var(--text-primary)]'
|
||||||
|
title='Connect another workspace'
|
||||||
|
>
|
||||||
|
<Plus className='h-[14px] w-[14px]' />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedCredentialId && (
|
||||||
|
<>
|
||||||
|
{/* Respond to */}
|
||||||
|
<div>
|
||||||
|
<Label className='mb-[6px] block text-[12px] text-[var(--text-muted)]'>
|
||||||
|
Respond to
|
||||||
|
</Label>
|
||||||
|
<Combobox
|
||||||
|
options={RESPOND_TO_OPTIONS}
|
||||||
|
value={respondTo}
|
||||||
|
onChange={(v) => setRespondTo(v as SlackDeploymentConfig['respondTo'])}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Channels — hidden in DM mode */}
|
||||||
|
{!isDmMode && (
|
||||||
|
<div>
|
||||||
|
<Label className='mb-[6px] block text-[12px] text-[var(--text-muted)]'>
|
||||||
|
Channels <span className='font-normal opacity-60'>(select one or more)</span>
|
||||||
|
</Label>
|
||||||
|
{isLoadingChannels ? (
|
||||||
|
<div className='flex h-[32px] items-center gap-[6px] text-[12px] text-[var(--text-muted)]'>
|
||||||
|
<Loader className='h-[12px] w-[12px] animate-spin' />
|
||||||
|
Loading channels…
|
||||||
|
</div>
|
||||||
|
) : channels.length === 0 ? (
|
||||||
|
<p className='text-[12px] text-[var(--text-muted)]'>
|
||||||
|
No accessible channels found. Make sure the bot has been added to at least one
|
||||||
|
channel.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className='max-h-[160px] overflow-y-auto rounded-[4px] border border-[var(--border-1)]'>
|
||||||
|
{channels.map((channel) => (
|
||||||
|
<button
|
||||||
|
key={channel.id}
|
||||||
|
type='button'
|
||||||
|
onClick={() => toggleChannel(channel.id)}
|
||||||
|
className={cn(
|
||||||
|
'flex w-full items-center gap-[8px] px-[10px] py-[7px] text-left text-[12px] transition-colors',
|
||||||
|
'hover:bg-[var(--surface-7)]',
|
||||||
|
selectedChannelIds.includes(channel.id) && 'bg-[var(--surface-4)]'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'flex h-[14px] w-[14px] flex-shrink-0 items-center justify-center rounded-[3px] border',
|
||||||
|
selectedChannelIds.includes(channel.id)
|
||||||
|
? 'border-[var(--text-primary)] bg-[var(--text-primary)]'
|
||||||
|
: 'border-[var(--border-1)]'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{selectedChannelIds.includes(channel.id) && (
|
||||||
|
<Check className='h-[9px] w-[9px] text-[var(--surface-1)]' />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className='text-[var(--text-primary)]'>
|
||||||
|
{channel.isPrivate ? '🔒 ' : '#'}
|
||||||
|
{channel.name}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Bot display name */}
|
||||||
|
<div>
|
||||||
|
<Label className='mb-[6px] block text-[12px] text-[var(--text-muted)]'>
|
||||||
|
Bot display name <span className='font-normal opacity-60'>(optional)</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
value={botName}
|
||||||
|
onChange={(e) => setBotName(e.target.value)}
|
||||||
|
placeholder='e.g. Sales Assistant'
|
||||||
|
maxLength={80}
|
||||||
|
className='h-[34px] text-[12px]'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reply in thread */}
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<div>
|
||||||
|
<p className='text-[12px] text-[var(--text-primary)]'>Reply in thread</p>
|
||||||
|
<p className='text-[11px] text-[var(--text-muted)]'>
|
||||||
|
Keep responses inside the original thread
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
onClick={() => setReplyInThread((v) => !v)}
|
||||||
|
className={cn(
|
||||||
|
'relative h-[20px] w-[36px] flex-shrink-0 rounded-full transition-colors',
|
||||||
|
replyInThread ? 'bg-[var(--text-primary)]' : 'bg-[var(--border-1)]'
|
||||||
|
)}
|
||||||
|
aria-checked={replyInThread}
|
||||||
|
role='switch'
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'absolute top-[2px] h-[16px] w-[16px] rounded-full bg-white shadow-sm transition-transform',
|
||||||
|
replyInThread ? 'translate-x-[18px]' : 'translate-x-[2px]'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{deployError && (
|
||||||
|
<p className='rounded-[4px] bg-[var(--error-bg,#fef2f2)] px-[10px] py-[6px] text-[12px] text-[var(--error,#dc2626)]'>
|
||||||
|
{deployError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className='flex items-center gap-[8px] pt-[4px]'>
|
||||||
|
{isDeployed ? (
|
||||||
|
<>
|
||||||
|
<div className='flex items-center gap-[6px] text-[12px] text-[var(--text-success,#16a34a)]'>
|
||||||
|
<span className='h-[6px] w-[6px] rounded-full bg-[var(--text-success,#16a34a)]' />
|
||||||
|
Deployed
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant='outline'
|
||||||
|
size='sm'
|
||||||
|
onClick={handleDeploy}
|
||||||
|
disabled={!hasValidConfig || isDeploying}
|
||||||
|
className='ml-auto'
|
||||||
|
>
|
||||||
|
{isDeploying ? (
|
||||||
|
<Loader className='mr-[4px] h-[12px] w-[12px] animate-spin' />
|
||||||
|
) : null}
|
||||||
|
Update
|
||||||
|
</Button>
|
||||||
|
<Button variant='outline' size='sm' onClick={handleUndeploy} disabled={isUndeploying}>
|
||||||
|
{isUndeploying ? (
|
||||||
|
<Loader className='mr-[4px] h-[12px] w-[12px] animate-spin' />
|
||||||
|
) : null}
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
size='sm'
|
||||||
|
onClick={handleDeploy}
|
||||||
|
disabled={!hasValidConfig || isDeploying}
|
||||||
|
className='ml-auto'
|
||||||
|
>
|
||||||
|
{isDeploying ? <Loader className='mr-[4px] h-[12px] w-[12px] animate-spin' /> : null}
|
||||||
|
Deploy to Slack
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,531 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { createLogger } from '@sim/logger'
|
||||||
|
import { Paperclip, Square } from 'lucide-react'
|
||||||
|
import ReactMarkdown from 'react-markdown'
|
||||||
|
import remarkGfm from 'remark-gfm'
|
||||||
|
import { Send, X } from '@/components/emcn/icons'
|
||||||
|
import { getDocumentIcon } from '@/components/icons/document-icons'
|
||||||
|
import { cn } from '@/lib/core/utils/cn'
|
||||||
|
import { CHAT_ACCEPT_ATTRIBUTE } from '@/lib/uploads/utils/validation'
|
||||||
|
|
||||||
|
const logger = createLogger('AgentTestPanel')
|
||||||
|
|
||||||
|
const MAX_FILES = 10
|
||||||
|
const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10MB
|
||||||
|
const TEXT_EXTENSIONS = new Set([
|
||||||
|
'.txt',
|
||||||
|
'.md',
|
||||||
|
'.csv',
|
||||||
|
'.json',
|
||||||
|
'.xml',
|
||||||
|
'.html',
|
||||||
|
'.ts',
|
||||||
|
'.tsx',
|
||||||
|
'.js',
|
||||||
|
'.jsx',
|
||||||
|
'.py',
|
||||||
|
'.sh',
|
||||||
|
])
|
||||||
|
const TEXT_MEDIA_TYPES = new Set([
|
||||||
|
'text/plain',
|
||||||
|
'text/markdown',
|
||||||
|
'text/csv',
|
||||||
|
'application/json',
|
||||||
|
'text/html',
|
||||||
|
'application/xml',
|
||||||
|
'text/xml',
|
||||||
|
])
|
||||||
|
|
||||||
|
interface AttachedFile {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
size: number
|
||||||
|
type: string
|
||||||
|
file: File
|
||||||
|
previewUrl?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChatMessage {
|
||||||
|
role: 'user' | 'assistant'
|
||||||
|
content: string
|
||||||
|
isStreaming?: boolean
|
||||||
|
attachments?: Array<{ id: string; name: string; type: string; previewUrl?: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AgentTestPanelProps {
|
||||||
|
agentId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTextFile(file: { type: string; name: string }) {
|
||||||
|
if (TEXT_MEDIA_TYPES.has(file.type)) return true
|
||||||
|
const ext = file.name.slice(file.name.lastIndexOf('.')).toLowerCase()
|
||||||
|
return TEXT_EXTENSIONS.has(ext)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StreamingCursor() {
|
||||||
|
return (
|
||||||
|
<span className='ml-[1px] inline-block h-[13px] w-[5px] translate-y-[1px] animate-pulse bg-current opacity-60' />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FilePill({
|
||||||
|
name,
|
||||||
|
type,
|
||||||
|
previewUrl,
|
||||||
|
onRemove,
|
||||||
|
}: {
|
||||||
|
name: string
|
||||||
|
type: string
|
||||||
|
previewUrl?: string
|
||||||
|
onRemove?: () => void
|
||||||
|
}) {
|
||||||
|
const isImage = type.startsWith('image/')
|
||||||
|
const Icon = getDocumentIcon(type, name)
|
||||||
|
|
||||||
|
if (isImage && previewUrl) {
|
||||||
|
return (
|
||||||
|
<div className='group relative h-[44px] w-[44px] flex-shrink-0 overflow-hidden rounded-[6px]'>
|
||||||
|
<img src={previewUrl} alt={name} className='h-full w-full object-cover' />
|
||||||
|
{onRemove && (
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
onClick={onRemove}
|
||||||
|
className='absolute inset-0 flex items-center justify-center bg-black/40 opacity-0 transition-opacity group-hover:opacity-100'
|
||||||
|
aria-label={`Remove ${name}`}
|
||||||
|
>
|
||||||
|
<X className='h-[11px] w-[11px] text-white' />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='flex max-w-[160px] items-center gap-[4px] rounded-[6px] bg-[var(--surface-4)] px-[7px] py-[3px]'>
|
||||||
|
<Icon className='h-[11px] w-[11px] flex-shrink-0 text-[var(--text-muted)]' />
|
||||||
|
<span className='min-w-0 truncate text-[11px] text-[var(--text-primary)]'>{name}</span>
|
||||||
|
{onRemove && (
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
onClick={onRemove}
|
||||||
|
className='ml-[2px] flex-shrink-0 text-[var(--text-tertiary)] hover:text-[var(--text-primary)]'
|
||||||
|
aria-label={`Remove ${name}`}
|
||||||
|
>
|
||||||
|
<X className='h-[9px] w-[9px]' />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function UserMessage({ message }: { message: ChatMessage }) {
|
||||||
|
const hasAttachments = (message.attachments?.length ?? 0) > 0
|
||||||
|
return (
|
||||||
|
<div className='flex w-full flex-col items-end gap-[4px]'>
|
||||||
|
{hasAttachments && (
|
||||||
|
<div className='flex flex-wrap justify-end gap-[5px]'>
|
||||||
|
{message.attachments!.map((att) => (
|
||||||
|
<FilePill key={att.id} name={att.name} type={att.type} previewUrl={att.previewUrl} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{message.content && (
|
||||||
|
<div className='max-w-[85%] rounded-[8px] border border-[var(--border-1)] bg-[var(--surface-4)] px-[10px] py-[7px]'>
|
||||||
|
<p className='whitespace-pre-wrap break-words font-medium text-[13px] text-[var(--text-primary)] leading-[1.5]'>
|
||||||
|
{message.content}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const REMARK_PLUGINS = [remarkGfm]
|
||||||
|
|
||||||
|
const PROSE_CLASSES = cn(
|
||||||
|
'prose prose-sm dark:prose-invert max-w-none',
|
||||||
|
'prose-p:text-[13px] prose-p:leading-[1.6] prose-p:text-[var(--text-primary)] first:prose-p:mt-0 last:prose-p:mb-0',
|
||||||
|
'prose-headings:text-[var(--text-primary)] prose-headings:font-semibold prose-headings:mt-4 prose-headings:mb-2',
|
||||||
|
'prose-li:text-[13px] prose-li:text-[var(--text-primary)] prose-li:my-0.5',
|
||||||
|
'prose-ul:my-2 prose-ol:my-2',
|
||||||
|
'prose-strong:text-[var(--text-primary)] prose-strong:font-semibold',
|
||||||
|
'prose-a:text-[var(--text-primary)] prose-a:underline prose-a:decoration-dashed prose-a:underline-offset-2',
|
||||||
|
'prose-code:rounded prose-code:bg-[var(--surface-4)] prose-code:px-1 prose-code:py-0.5 prose-code:text-[12px] prose-code:font-mono prose-code:text-[var(--text-primary)]',
|
||||||
|
'prose-code:before:content-none prose-code:after:content-none',
|
||||||
|
'prose-pre:bg-[var(--surface-4)] prose-pre:border prose-pre:border-[var(--border-1)] prose-pre:rounded-[6px] prose-pre:text-[12px]',
|
||||||
|
'prose-blockquote:border-[var(--border-1)] prose-blockquote:text-[var(--text-muted)]'
|
||||||
|
)
|
||||||
|
|
||||||
|
function AssistantMessage({ message }: { message: ChatMessage }) {
|
||||||
|
return (
|
||||||
|
<div className='w-full pl-[2px]'>
|
||||||
|
{message.isStreaming && !message.content ? (
|
||||||
|
<span className='text-[13px] text-[var(--text-tertiary)]'>
|
||||||
|
Thinking…
|
||||||
|
<StreamingCursor />
|
||||||
|
</span>
|
||||||
|
) : message.content ? (
|
||||||
|
<div className={PROSE_CLASSES}>
|
||||||
|
<ReactMarkdown remarkPlugins={REMARK_PLUGINS}>{message.content}</ReactMarkdown>
|
||||||
|
{message.isStreaming && <StreamingCursor />}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AgentTestPanel({ agentId }: AgentTestPanelProps) {
|
||||||
|
const [messages, setMessages] = useState<ChatMessage[]>([])
|
||||||
|
const [input, setInput] = useState('')
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
const [attachedFiles, setAttachedFiles] = useState<AttachedFile[]>([])
|
||||||
|
const conversationIdRef = useRef(`test-${Date.now()}`)
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null)
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const abortRef = useRef<AbortController | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
abortRef.current?.abort()
|
||||||
|
// Revoke any object URLs created for image previews
|
||||||
|
for (const f of attachedFiles) {
|
||||||
|
if (f.previewUrl?.startsWith('blob:')) URL.revokeObjectURL(f.previewUrl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' })
|
||||||
|
}, [messages])
|
||||||
|
|
||||||
|
const syncHeight = useCallback(() => {
|
||||||
|
const el = textareaRef.current
|
||||||
|
if (!el) return
|
||||||
|
el.style.height = 'auto'
|
||||||
|
el.style.height = `${Math.min(el.scrollHeight, 180)}px`
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const addFiles = useCallback((files: File[]) => {
|
||||||
|
setAttachedFiles((current) => {
|
||||||
|
const slots = Math.max(0, MAX_FILES - current.length)
|
||||||
|
const next: AttachedFile[] = []
|
||||||
|
|
||||||
|
for (const file of files.slice(0, slots)) {
|
||||||
|
if (file.size > MAX_FILE_SIZE) continue
|
||||||
|
if ([...current, ...next].some((f) => f.name === file.name && f.size === file.size))
|
||||||
|
continue
|
||||||
|
|
||||||
|
const attached: AttachedFile = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
name: file.name,
|
||||||
|
size: file.size,
|
||||||
|
type: file.type,
|
||||||
|
file,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.type.startsWith('image/')) {
|
||||||
|
attached.previewUrl = URL.createObjectURL(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
next.push(attached)
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...current, ...next]
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleFileChange = useCallback(
|
||||||
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (e.target.files) addFiles(Array.from(e.target.files))
|
||||||
|
e.target.value = ''
|
||||||
|
},
|
||||||
|
[addFiles]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleStop = useCallback(() => {
|
||||||
|
abortRef.current?.abort()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleSend = useCallback(async () => {
|
||||||
|
const trimmed = input.trim()
|
||||||
|
if ((!trimmed && attachedFiles.length === 0) || isLoading) return
|
||||||
|
|
||||||
|
const files = [...attachedFiles]
|
||||||
|
setInput('')
|
||||||
|
setAttachedFiles([])
|
||||||
|
setIsLoading(true)
|
||||||
|
if (textareaRef.current) textareaRef.current.style.height = 'auto'
|
||||||
|
|
||||||
|
// Inject text file contents into the message
|
||||||
|
let messageText = trimmed
|
||||||
|
const textFiles = files.filter(isTextFile)
|
||||||
|
if (textFiles.length > 0) {
|
||||||
|
const contents = await Promise.all(
|
||||||
|
textFiles.map(
|
||||||
|
(f) =>
|
||||||
|
new Promise<string>((resolve) => {
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = (ev) => resolve(`\n\n--- ${f.name} ---\n${ev.target?.result ?? ''}`)
|
||||||
|
reader.onerror = () => resolve('')
|
||||||
|
reader.readAsText(f.file)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
messageText = (trimmed + contents.join('')).trim()
|
||||||
|
} else if (!trimmed && files.length > 0) {
|
||||||
|
messageText = files.map((f) => f.name).join(', ')
|
||||||
|
}
|
||||||
|
|
||||||
|
const attachments = files.map(({ id, name, type, previewUrl }) => ({
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
type,
|
||||||
|
previewUrl,
|
||||||
|
}))
|
||||||
|
setMessages((prev) => [
|
||||||
|
...prev,
|
||||||
|
{ role: 'user', content: trimmed, attachments },
|
||||||
|
{ role: 'assistant', content: '', isStreaming: true },
|
||||||
|
])
|
||||||
|
|
||||||
|
const controller = new AbortController()
|
||||||
|
abortRef.current = controller
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/agents/${agentId}/execute`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Accept: 'text/event-stream' },
|
||||||
|
body: JSON.stringify({ message: messageText, conversationId: conversationIdRef.current }),
|
||||||
|
signal: controller.signal,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({ error: 'Unknown error' }))
|
||||||
|
throw new Error(err.error || 'Execution failed')
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = res.headers.get('content-type') ?? ''
|
||||||
|
if (contentType.includes('text/event-stream') && res.body) {
|
||||||
|
const reader = res.body.getReader()
|
||||||
|
const decoder = new TextDecoder()
|
||||||
|
let buf = ''
|
||||||
|
let accumulated = ''
|
||||||
|
|
||||||
|
outer: while (true) {
|
||||||
|
const { done, value } = await reader.read()
|
||||||
|
if (done) break
|
||||||
|
buf += decoder.decode(value, { stream: true })
|
||||||
|
const lines = buf.split('\n')
|
||||||
|
buf = lines.pop() ?? ''
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!line.startsWith('data: ')) continue
|
||||||
|
const data = line.slice(6).trim()
|
||||||
|
if (data === '[DONE]') break outer
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(data)
|
||||||
|
if (parsed.content) {
|
||||||
|
accumulated += parsed.content
|
||||||
|
setMessages((prev) => {
|
||||||
|
const next = [...prev]
|
||||||
|
next[next.length - 1] = {
|
||||||
|
role: 'assistant',
|
||||||
|
content: accumulated,
|
||||||
|
isStreaming: true,
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setMessages((prev) => {
|
||||||
|
const next = [...prev]
|
||||||
|
next[next.length - 1] = { role: 'assistant', content: accumulated, isStreaming: false }
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
const json = await res.json()
|
||||||
|
setMessages((prev) => {
|
||||||
|
const next = [...prev]
|
||||||
|
next[next.length - 1] = {
|
||||||
|
role: 'assistant',
|
||||||
|
content: json.data?.content ?? '',
|
||||||
|
isStreaming: false,
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.name === 'AbortError') {
|
||||||
|
setMessages((prev) => {
|
||||||
|
const next = [...prev]
|
||||||
|
const last = next[next.length - 1]
|
||||||
|
if (last?.role === 'assistant') next[next.length - 1] = { ...last, isStreaming: false }
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logger.error('Agent execution failed', { error })
|
||||||
|
const msg = error instanceof Error ? error.message : 'Something went wrong'
|
||||||
|
setMessages((prev) => {
|
||||||
|
const next = [...prev]
|
||||||
|
next[next.length - 1] = { role: 'assistant', content: `Error: ${msg}`, isStreaming: false }
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
abortRef.current = null
|
||||||
|
setTimeout(() => textareaRef.current?.focus(), 0)
|
||||||
|
}
|
||||||
|
}, [agentId, input, attachedFiles, isLoading])
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
handleSend()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[handleSend]
|
||||||
|
)
|
||||||
|
|
||||||
|
const canSend = (input.trim().length > 0 || attachedFiles.length > 0) && !isLoading
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className='flex h-full flex-col bg-[var(--surface-1)]'
|
||||||
|
onDragOver={(e) => e.preventDefault()}
|
||||||
|
onDrop={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
addFiles(Array.from(e.dataTransfer.files))
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className='flex flex-shrink-0 items-center justify-between border-[var(--border-1)] border-b px-[16px] py-[10px]'>
|
||||||
|
<span className='font-medium text-[13px] text-[var(--text-primary)]'>Test</span>
|
||||||
|
{messages.length > 0 && (
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
onClick={() => {
|
||||||
|
setMessages([])
|
||||||
|
setAttachedFiles([])
|
||||||
|
conversationIdRef.current = `test-${Date.now()}`
|
||||||
|
}}
|
||||||
|
className='text-[12px] text-[var(--text-muted)] transition-colors hover:text-[var(--text-primary)]'
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div ref={scrollRef} className='flex-1 overflow-y-auto px-[20px] py-[20px]'>
|
||||||
|
{messages.length === 0 ? (
|
||||||
|
<div className='flex h-full flex-col items-center justify-center gap-[6px]'>
|
||||||
|
<p className='text-[13px] text-[var(--text-muted)]'>
|
||||||
|
Send a message to test your agent.
|
||||||
|
</p>
|
||||||
|
<p className='text-[11px] text-[var(--text-tertiary)]'>
|
||||||
|
↵ to send · Shift+↵ for new line
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className='flex flex-col gap-[20px]'>
|
||||||
|
{messages.map((msg, idx) =>
|
||||||
|
msg.role === 'user' ? (
|
||||||
|
<UserMessage key={idx} message={msg} />
|
||||||
|
) : (
|
||||||
|
<AssistantMessage key={idx} message={msg} />
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='flex-shrink-0 p-[12px]'>
|
||||||
|
<div className='rounded-[8px] border border-[var(--border-1)] bg-[var(--surface-5)]'>
|
||||||
|
{attachedFiles.length > 0 && (
|
||||||
|
<div className='flex flex-wrap gap-[5px] px-[10px] pt-[8px]'>
|
||||||
|
{attachedFiles.map((file) => (
|
||||||
|
<FilePill
|
||||||
|
key={file.id}
|
||||||
|
name={file.name}
|
||||||
|
type={file.type}
|
||||||
|
previewUrl={file.previewUrl}
|
||||||
|
onRemove={() => {
|
||||||
|
if (file.previewUrl?.startsWith('blob:')) URL.revokeObjectURL(file.previewUrl)
|
||||||
|
setAttachedFiles((prev) => prev.filter((f) => f.id !== file.id))
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className='flex items-end px-[10px] py-[6px]'>
|
||||||
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => {
|
||||||
|
setInput(e.target.value)
|
||||||
|
syncHeight()
|
||||||
|
}}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder='Send a message…'
|
||||||
|
rows={1}
|
||||||
|
disabled={isLoading}
|
||||||
|
className={cn(
|
||||||
|
'max-h-[180px] min-h-[24px] flex-1 resize-none bg-transparent py-[2px] text-[13px] text-[var(--text-primary)] leading-[1.5]',
|
||||||
|
'placeholder:text-[var(--text-tertiary)] focus:outline-none disabled:opacity-50',
|
||||||
|
'overflow-hidden'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='flex items-center justify-between px-[8px] pb-[6px]'>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
disabled={isLoading}
|
||||||
|
className='flex h-[26px] w-[26px] items-center justify-center rounded-[4px] text-[var(--text-tertiary)] transition-colors hover:bg-[var(--surface-4)] hover:text-[var(--text-primary)] disabled:opacity-40'
|
||||||
|
aria-label='Attach file'
|
||||||
|
>
|
||||||
|
<Paperclip className='h-[13px] w-[13px]' />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
onClick={isLoading ? handleStop : handleSend}
|
||||||
|
disabled={!isLoading && !canSend}
|
||||||
|
className={cn(
|
||||||
|
'flex h-[26px] w-[26px] items-center justify-center rounded-[4px] transition-colors',
|
||||||
|
isLoading
|
||||||
|
? 'bg-[var(--surface-4)] text-[var(--text-primary)] hover:bg-[var(--border-1)]'
|
||||||
|
: canSend
|
||||||
|
? 'bg-[var(--text-primary)] text-[var(--surface-1)] hover:opacity-85'
|
||||||
|
: 'cursor-not-allowed text-[var(--text-tertiary)]'
|
||||||
|
)}
|
||||||
|
aria-label={isLoading ? 'Stop' : 'Send'}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<Square className='h-[10px] w-[10px]' />
|
||||||
|
) : (
|
||||||
|
<Send className='h-[12px] w-[12px]' />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type='file'
|
||||||
|
multiple
|
||||||
|
className='hidden'
|
||||||
|
onChange={handleFileChange}
|
||||||
|
accept={CHAT_ACCEPT_ATTRIBUTE}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import {
|
||||||
|
Modal,
|
||||||
|
ModalBody,
|
||||||
|
ModalContent,
|
||||||
|
ModalHeader,
|
||||||
|
ModalTabs,
|
||||||
|
ModalTabsContent,
|
||||||
|
ModalTabsList,
|
||||||
|
ModalTabsTrigger,
|
||||||
|
} from '@/components/emcn'
|
||||||
|
import { ApiDeploy } from '@/app/workspace/[workspaceId]/agents/[agentId]/components/agent-deploy/api-deploy'
|
||||||
|
import { SlackDeploy } from '@/app/workspace/[workspaceId]/agents/[agentId]/components/agent-deploy/slack-deploy'
|
||||||
|
|
||||||
|
interface DeployModalProps {
|
||||||
|
agentId: string
|
||||||
|
workspaceId: string
|
||||||
|
isDeployed: boolean
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeployModal({
|
||||||
|
agentId,
|
||||||
|
workspaceId,
|
||||||
|
isDeployed,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: DeployModalProps) {
|
||||||
|
return (
|
||||||
|
<Modal open={open} onOpenChange={onOpenChange}>
|
||||||
|
<ModalContent size='md'>
|
||||||
|
<ModalHeader>Deploy agent</ModalHeader>
|
||||||
|
<ModalTabs defaultValue='slack'>
|
||||||
|
<ModalTabsList>
|
||||||
|
<ModalTabsTrigger value='slack'>Slack</ModalTabsTrigger>
|
||||||
|
<ModalTabsTrigger value='api'>API</ModalTabsTrigger>
|
||||||
|
</ModalTabsList>
|
||||||
|
<ModalBody>
|
||||||
|
<ModalTabsContent value='slack'>
|
||||||
|
<SlackDeploy agentId={agentId} workspaceId={workspaceId} />
|
||||||
|
</ModalTabsContent>
|
||||||
|
<ModalTabsContent value='api'>
|
||||||
|
<ApiDeploy agentId={agentId} workspaceId={workspaceId} isDeployed={isDeployed} />
|
||||||
|
</ModalTabsContent>
|
||||||
|
</ModalBody>
|
||||||
|
</ModalTabs>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useCallback, useRef, useState } from 'react'
|
||||||
|
import { createLogger } from '@sim/logger'
|
||||||
|
|
||||||
|
const logger = createLogger('AgentWand')
|
||||||
|
|
||||||
|
interface ChatMessage {
|
||||||
|
role: 'user' | 'assistant'
|
||||||
|
content: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseAgentWandOptions {
|
||||||
|
/** System prompt passed verbatim to /api/wand. Use {context} as a placeholder for the current value. */
|
||||||
|
systemPrompt: string
|
||||||
|
generationType?: string
|
||||||
|
maintainHistory?: boolean
|
||||||
|
currentValue: string
|
||||||
|
onGeneratedContent: (content: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentWandState {
|
||||||
|
isSearchActive: boolean
|
||||||
|
searchQuery: string
|
||||||
|
isStreaming: boolean
|
||||||
|
searchInputRef: React.RefObject<HTMLInputElement | null>
|
||||||
|
onSearchClick: () => void
|
||||||
|
onSearchBlur: () => void
|
||||||
|
onSearchChange: (value: string) => void
|
||||||
|
onSearchSubmit: () => void
|
||||||
|
onSearchCancel: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAgentWand({
|
||||||
|
systemPrompt,
|
||||||
|
generationType,
|
||||||
|
maintainHistory = false,
|
||||||
|
currentValue,
|
||||||
|
onGeneratedContent,
|
||||||
|
}: UseAgentWandOptions): AgentWandState {
|
||||||
|
const [isSearchActive, setIsSearchActive] = useState(false)
|
||||||
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
const [isStreaming, setIsStreaming] = useState(false)
|
||||||
|
const [history, setHistory] = useState<ChatMessage[]>([])
|
||||||
|
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const abortRef = useRef<AbortController | null>(null)
|
||||||
|
const searchQueryRef = useRef(searchQuery)
|
||||||
|
searchQueryRef.current = searchQuery
|
||||||
|
|
||||||
|
const onSearchClick = useCallback(() => {
|
||||||
|
setIsSearchActive(true)
|
||||||
|
setTimeout(() => searchInputRef.current?.focus(), 50)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const onSearchBlur = useCallback(() => {
|
||||||
|
if (!searchQueryRef.current.trim() && !abortRef.current) {
|
||||||
|
setIsSearchActive(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const onSearchChange = useCallback((value: string) => {
|
||||||
|
setSearchQuery(value)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const onSearchCancel = useCallback(() => {
|
||||||
|
abortRef.current?.abort()
|
||||||
|
abortRef.current = null
|
||||||
|
setIsSearchActive(false)
|
||||||
|
setSearchQuery('')
|
||||||
|
setIsStreaming(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const onSearchSubmit = useCallback(async () => {
|
||||||
|
const prompt = searchQueryRef.current
|
||||||
|
if (!prompt.trim()) return
|
||||||
|
|
||||||
|
const resolvedSystemPrompt = systemPrompt.replace('{context}', currentValue)
|
||||||
|
|
||||||
|
setSearchQuery('')
|
||||||
|
setIsSearchActive(false)
|
||||||
|
setIsStreaming(true)
|
||||||
|
|
||||||
|
const controller = new AbortController()
|
||||||
|
abortRef.current = controller
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/wand', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
prompt,
|
||||||
|
systemPrompt: resolvedSystemPrompt,
|
||||||
|
stream: true,
|
||||||
|
generationType,
|
||||||
|
history: maintainHistory ? history : [],
|
||||||
|
}),
|
||||||
|
signal: controller.signal,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok || !res.body) throw new Error('Wand generation failed')
|
||||||
|
|
||||||
|
const reader = res.body.getReader()
|
||||||
|
const decoder = new TextDecoder()
|
||||||
|
let buf = ''
|
||||||
|
let accumulated = ''
|
||||||
|
|
||||||
|
outer: while (true) {
|
||||||
|
const { done, value } = await reader.read()
|
||||||
|
if (done) break
|
||||||
|
buf += decoder.decode(value, { stream: true })
|
||||||
|
const lines = buf.split('\n')
|
||||||
|
buf = lines.pop() ?? ''
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!line.startsWith('data: ')) continue
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(line.slice(6))
|
||||||
|
if (parsed.done) break outer
|
||||||
|
if (parsed.chunk) {
|
||||||
|
accumulated += parsed.chunk
|
||||||
|
onGeneratedContent(accumulated)
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (maintainHistory) {
|
||||||
|
setHistory((prev) => [
|
||||||
|
...prev,
|
||||||
|
{ role: 'user', content: prompt },
|
||||||
|
{ role: 'assistant', content: accumulated },
|
||||||
|
])
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof Error && error.name === 'AbortError')) {
|
||||||
|
logger.error('Wand generation failed', { error })
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsStreaming(false)
|
||||||
|
abortRef.current = null
|
||||||
|
}
|
||||||
|
}, [systemPrompt, generationType, maintainHistory, currentValue, history, onGeneratedContent])
|
||||||
|
|
||||||
|
return {
|
||||||
|
isSearchActive,
|
||||||
|
searchQuery,
|
||||||
|
isStreaming,
|
||||||
|
searchInputRef,
|
||||||
|
onSearchClick,
|
||||||
|
onSearchBlur,
|
||||||
|
onSearchChange,
|
||||||
|
onSearchSubmit,
|
||||||
|
onSearchCancel,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export default function AgentDetailLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return <div className='flex h-full flex-1 flex-col overflow-hidden'>{children}</div>
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import type { Metadata } from 'next'
|
||||||
|
import { redirect } from 'next/navigation'
|
||||||
|
import { getSession } from '@/lib/auth'
|
||||||
|
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
|
||||||
|
import { AgentDetail } from '@/app/workspace/[workspaceId]/agents/[agentId]/agent-detail'
|
||||||
|
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check'
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Agent',
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AgentDetailPageProps {
|
||||||
|
params: Promise<{
|
||||||
|
workspaceId: string
|
||||||
|
agentId: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AgentDetailPage({ params }: AgentDetailPageProps) {
|
||||||
|
const { workspaceId, agentId } = await params
|
||||||
|
const session = await getSession()
|
||||||
|
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
redirect('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasPermission = await verifyWorkspaceMembership(session.user.id, workspaceId)
|
||||||
|
if (!hasPermission) {
|
||||||
|
redirect('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
const permissionConfig = await getUserPermissionConfig(session.user.id)
|
||||||
|
if (permissionConfig?.hideAgentsTab) {
|
||||||
|
redirect(`/workspace/${workspaceId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return <AgentDetail agentId={agentId} workspaceId={workspaceId} />
|
||||||
|
}
|
||||||
224
apps/sim/app/workspace/[workspaceId]/agents/agents.tsx
Normal file
224
apps/sim/app/workspace/[workspaceId]/agents/agents.tsx
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useCallback, useDeferredValue, useMemo, useState } from 'react'
|
||||||
|
import { createLogger } from '@sim/logger'
|
||||||
|
import { useParams, useRouter } from 'next/navigation'
|
||||||
|
import { AgentIcon } from '@/components/icons'
|
||||||
|
import { AgentContextMenu } from '@/app/workspace/[workspaceId]/agents/components/agent-context-menu'
|
||||||
|
import { AgentListContextMenu } from '@/app/workspace/[workspaceId]/agents/components/agent-list-context-menu'
|
||||||
|
import type { ResourceColumn, ResourceRow } from '@/app/workspace/[workspaceId]/components'
|
||||||
|
import {
|
||||||
|
InlineRenameInput,
|
||||||
|
ownerCell,
|
||||||
|
Resource,
|
||||||
|
timeCell,
|
||||||
|
} from '@/app/workspace/[workspaceId]/components'
|
||||||
|
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||||
|
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
|
||||||
|
import {
|
||||||
|
useAgentsList,
|
||||||
|
useCreateAgent,
|
||||||
|
useDeleteAgent,
|
||||||
|
useUpdateAgent,
|
||||||
|
} from '@/hooks/queries/agents'
|
||||||
|
import { useWorkspaceMembersQuery } from '@/hooks/queries/workspace'
|
||||||
|
import { useInlineRename } from '@/hooks/use-inline-rename'
|
||||||
|
|
||||||
|
const logger = createLogger('Agents')
|
||||||
|
|
||||||
|
const COLUMNS: ResourceColumn[] = [
|
||||||
|
{ id: 'name', header: 'Name' },
|
||||||
|
{ id: 'model', header: 'Model' },
|
||||||
|
{ id: 'status', header: 'Status' },
|
||||||
|
{ id: 'created', header: 'Created' },
|
||||||
|
{ id: 'owner', header: 'Owner' },
|
||||||
|
{ id: 'updated', header: 'Last Updated' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function Agents() {
|
||||||
|
const params = useParams()
|
||||||
|
const router = useRouter()
|
||||||
|
const workspaceId = params.workspaceId as string
|
||||||
|
|
||||||
|
const { data: agents = [], isLoading } = useAgentsList(workspaceId)
|
||||||
|
const { data: members } = useWorkspaceMembersQuery(workspaceId)
|
||||||
|
const { mutateAsync: createAgent, isPending: isCreating } = useCreateAgent()
|
||||||
|
const { mutateAsync: deleteAgent } = useDeleteAgent()
|
||||||
|
const { mutateAsync: updateAgent } = useUpdateAgent()
|
||||||
|
const userPermissions = useUserPermissionsContext()
|
||||||
|
|
||||||
|
const [activeAgentId, setActiveAgentId] = useState<string | null>(null)
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false)
|
||||||
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
const deferredSearch = useDeferredValue(searchQuery)
|
||||||
|
|
||||||
|
const listRename = useInlineRename({
|
||||||
|
onSave: (agentId, name) => updateAgent({ agentId, name }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const {
|
||||||
|
isOpen: isListContextMenuOpen,
|
||||||
|
position: listContextMenuPosition,
|
||||||
|
handleContextMenu: handleListContextMenu,
|
||||||
|
closeMenu: closeListContextMenu,
|
||||||
|
} = useContextMenu()
|
||||||
|
|
||||||
|
const {
|
||||||
|
isOpen: isRowContextMenuOpen,
|
||||||
|
position: rowContextMenuPosition,
|
||||||
|
handleContextMenu: handleRowCtxMenu,
|
||||||
|
closeMenu: closeRowContextMenu,
|
||||||
|
} = useContextMenu()
|
||||||
|
|
||||||
|
const handleContentContextMenu = useCallback(
|
||||||
|
(e: React.MouseEvent) => {
|
||||||
|
const target = e.target as HTMLElement
|
||||||
|
if (
|
||||||
|
target.closest('[data-resource-row]') ||
|
||||||
|
target.closest('button, input, a, [role="button"]')
|
||||||
|
)
|
||||||
|
return
|
||||||
|
handleListContextMenu(e)
|
||||||
|
},
|
||||||
|
[handleListContextMenu]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleRowClick = useCallback(
|
||||||
|
(rowId: string) => {
|
||||||
|
if (isRowContextMenuOpen || listRename.editingId === rowId) return
|
||||||
|
router.push(`/workspace/${workspaceId}/agents/${rowId}`)
|
||||||
|
},
|
||||||
|
[isRowContextMenuOpen, listRename.editingId, router, workspaceId]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleRowContextMenu = useCallback(
|
||||||
|
(e: React.MouseEvent, rowId: string) => {
|
||||||
|
setActiveAgentId(rowId)
|
||||||
|
handleRowCtxMenu(e)
|
||||||
|
},
|
||||||
|
[handleRowCtxMenu]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleDelete = useCallback(async () => {
|
||||||
|
if (!activeAgentId) return
|
||||||
|
setIsDeleting(true)
|
||||||
|
try {
|
||||||
|
await deleteAgent({ agentId: activeAgentId })
|
||||||
|
closeRowContextMenu()
|
||||||
|
setActiveAgentId(null)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to delete agent', { error })
|
||||||
|
} finally {
|
||||||
|
setIsDeleting(false)
|
||||||
|
}
|
||||||
|
}, [activeAgentId, deleteAgent, closeRowContextMenu])
|
||||||
|
|
||||||
|
const handleCreateAgent = useCallback(async () => {
|
||||||
|
const existingNames = new Set(agents.map((a) => a.name))
|
||||||
|
let counter = 1
|
||||||
|
while (existingNames.has(`Agent ${counter}`)) counter++
|
||||||
|
const name = `Agent ${counter}`
|
||||||
|
try {
|
||||||
|
const agent = await createAgent({ workspaceId, name })
|
||||||
|
router.push(`/workspace/${workspaceId}/agents/${agent.id}`)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to create agent', { error })
|
||||||
|
}
|
||||||
|
}, [agents, createAgent, router, workspaceId])
|
||||||
|
|
||||||
|
const handleRename = useCallback(() => {
|
||||||
|
if (!activeAgentId) return
|
||||||
|
const agent = agents.find((a) => a.id === activeAgentId)
|
||||||
|
if (agent) listRename.startRename(activeAgentId, agent.name)
|
||||||
|
closeRowContextMenu()
|
||||||
|
}, [activeAgentId, agents, listRename, closeRowContextMenu])
|
||||||
|
|
||||||
|
const rows: ResourceRow[] = useMemo(() => {
|
||||||
|
const filtered = deferredSearch
|
||||||
|
? agents.filter((a) => a.name.toLowerCase().includes(deferredSearch.toLowerCase()))
|
||||||
|
: agents
|
||||||
|
|
||||||
|
return filtered.map((agent) => ({
|
||||||
|
id: agent.id,
|
||||||
|
cells: {
|
||||||
|
name: {
|
||||||
|
icon: <AgentIcon className='h-[14px] w-[14px]' />,
|
||||||
|
label: agent.name,
|
||||||
|
content:
|
||||||
|
listRename.editingId === agent.id ? (
|
||||||
|
<span className='flex min-w-0 items-center gap-[12px] font-medium text-[14px] text-[var(--text-body)]'>
|
||||||
|
<span className='flex-shrink-0 text-[var(--text-icon)]'>
|
||||||
|
<AgentIcon className='h-[14px] w-[14px]' />
|
||||||
|
</span>
|
||||||
|
<InlineRenameInput
|
||||||
|
value={listRename.editValue}
|
||||||
|
onChange={listRename.setEditValue}
|
||||||
|
onSubmit={listRename.submitRename}
|
||||||
|
onCancel={listRename.cancelRename}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
) : undefined,
|
||||||
|
},
|
||||||
|
model: { label: agent.config.model ?? '—' },
|
||||||
|
status: { label: agent.isDeployed ? 'Deployed' : 'Draft' },
|
||||||
|
created: timeCell(agent.createdAt),
|
||||||
|
owner: ownerCell(agent.createdBy, members),
|
||||||
|
updated: timeCell(agent.updatedAt),
|
||||||
|
},
|
||||||
|
sortValues: {
|
||||||
|
created: -new Date(agent.createdAt).getTime(),
|
||||||
|
updated: -new Date(agent.updatedAt).getTime(),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}, [agents, deferredSearch, members, listRename.editingId, listRename.editValue])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Resource
|
||||||
|
icon={AgentIcon}
|
||||||
|
title='Agents'
|
||||||
|
create={{
|
||||||
|
label: 'New agent',
|
||||||
|
onClick: handleCreateAgent,
|
||||||
|
disabled: userPermissions.canEdit !== true || isCreating,
|
||||||
|
}}
|
||||||
|
search={{
|
||||||
|
value: searchQuery,
|
||||||
|
onChange: setSearchQuery,
|
||||||
|
placeholder: 'Search agents…',
|
||||||
|
}}
|
||||||
|
defaultSort='created'
|
||||||
|
columns={COLUMNS}
|
||||||
|
rows={rows}
|
||||||
|
onRowClick={handleRowClick}
|
||||||
|
onRowContextMenu={handleRowContextMenu}
|
||||||
|
isLoading={isLoading}
|
||||||
|
onContextMenu={handleContentContextMenu}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AgentListContextMenu
|
||||||
|
isOpen={isListContextMenuOpen}
|
||||||
|
position={listContextMenuPosition}
|
||||||
|
onClose={closeListContextMenu}
|
||||||
|
onAddAgent={handleCreateAgent}
|
||||||
|
disableAdd={userPermissions.canEdit !== true || isCreating}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{activeAgentId && (
|
||||||
|
<AgentContextMenu
|
||||||
|
isOpen={isRowContextMenuOpen}
|
||||||
|
position={rowContextMenuPosition}
|
||||||
|
onClose={closeRowContextMenu}
|
||||||
|
onOpen={() => {
|
||||||
|
router.push(`/workspace/${workspaceId}/agents/${activeAgentId}`)
|
||||||
|
closeRowContextMenu()
|
||||||
|
}}
|
||||||
|
onRename={handleRename}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
isDeleting={isDeleting}
|
||||||
|
disableEdit={userPermissions.canEdit !== true}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/emcn'
|
||||||
|
import { Loader, Pencil, SquareArrowUpRight, Trash } from '@/components/emcn/icons'
|
||||||
|
|
||||||
|
interface AgentContextMenuProps {
|
||||||
|
isOpen: boolean
|
||||||
|
position: { x: number; y: number }
|
||||||
|
onClose: () => void
|
||||||
|
onOpen?: () => void
|
||||||
|
onRename?: () => void
|
||||||
|
onDelete?: () => void
|
||||||
|
isDeleting?: boolean
|
||||||
|
disableEdit?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AgentContextMenu({
|
||||||
|
isOpen,
|
||||||
|
position,
|
||||||
|
onClose,
|
||||||
|
onOpen,
|
||||||
|
onRename,
|
||||||
|
onDelete,
|
||||||
|
isDeleting = false,
|
||||||
|
disableEdit = false,
|
||||||
|
}: AgentContextMenuProps) {
|
||||||
|
return (
|
||||||
|
<DropdownMenu open={isOpen} onOpenChange={(open) => !open && onClose()} modal={false}>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
left: `${position.x}px`,
|
||||||
|
top: `${position.y}px`,
|
||||||
|
width: '1px',
|
||||||
|
height: '1px',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}
|
||||||
|
tabIndex={-1}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent
|
||||||
|
align='start'
|
||||||
|
side='bottom'
|
||||||
|
sideOffset={4}
|
||||||
|
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
{onOpen && (
|
||||||
|
<DropdownMenuItem onSelect={onOpen}>
|
||||||
|
<SquareArrowUpRight />
|
||||||
|
Open
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{onRename && (
|
||||||
|
<DropdownMenuItem disabled={disableEdit} onSelect={onRename}>
|
||||||
|
<Pencil />
|
||||||
|
Rename
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{onDelete && (onOpen || onRename) && <DropdownMenuSeparator />}
|
||||||
|
{onDelete && (
|
||||||
|
<DropdownMenuItem disabled={disableEdit || isDeleting} onSelect={onDelete}>
|
||||||
|
{isDeleting ? <Loader className='animate-spin' /> : <Trash />}
|
||||||
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/emcn'
|
||||||
|
import { Plus } from '@/components/emcn/icons'
|
||||||
|
|
||||||
|
interface AgentListContextMenuProps {
|
||||||
|
isOpen: boolean
|
||||||
|
position: { x: number; y: number }
|
||||||
|
onClose: () => void
|
||||||
|
onAddAgent?: () => void
|
||||||
|
disableAdd?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AgentListContextMenu({
|
||||||
|
isOpen,
|
||||||
|
position,
|
||||||
|
onClose,
|
||||||
|
onAddAgent,
|
||||||
|
disableAdd = false,
|
||||||
|
}: AgentListContextMenuProps) {
|
||||||
|
return (
|
||||||
|
<DropdownMenu open={isOpen} onOpenChange={(open) => !open && onClose()} modal={false}>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
left: `${position.x}px`,
|
||||||
|
top: `${position.y}px`,
|
||||||
|
width: '1px',
|
||||||
|
height: '1px',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}
|
||||||
|
tabIndex={-1}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent
|
||||||
|
align='start'
|
||||||
|
side='bottom'
|
||||||
|
sideOffset={4}
|
||||||
|
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
{onAddAgent && (
|
||||||
|
<DropdownMenuItem disabled={disableAdd} onSelect={onAddAgent}>
|
||||||
|
<Plus />
|
||||||
|
New agent
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
3
apps/sim/app/workspace/[workspaceId]/agents/layout.tsx
Normal file
3
apps/sim/app/workspace/[workspaceId]/agents/layout.tsx
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export default function AgentsLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return <div className='flex h-full flex-1 flex-col overflow-hidden'>{children}</div>
|
||||||
|
}
|
||||||
63
apps/sim/app/workspace/[workspaceId]/agents/loading.tsx
Normal file
63
apps/sim/app/workspace/[workspaceId]/agents/loading.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { Skeleton } from '@/components/emcn'
|
||||||
|
|
||||||
|
const SKELETON_ROW_COUNT = 5
|
||||||
|
const COLUMN_COUNT = 6
|
||||||
|
|
||||||
|
export default function AgentsLoading() {
|
||||||
|
return (
|
||||||
|
<div className='flex h-full flex-1 flex-col overflow-hidden bg-[var(--bg)]'>
|
||||||
|
<div className='border-[var(--border)] border-b px-[24px] py-[10px]'>
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<div className='flex items-center gap-[12px]'>
|
||||||
|
<Skeleton className='h-[14px] w-[14px] rounded-[2px]' />
|
||||||
|
<Skeleton className='h-[14px] w-[96px] rounded-[4px]' />
|
||||||
|
</div>
|
||||||
|
<div className='flex items-center gap-[6px]'>
|
||||||
|
<Skeleton className='h-[28px] w-[80px] rounded-[6px]' />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='border-[var(--border)] border-b px-[24px] py-[10px]'>
|
||||||
|
<div className='flex items-center'>
|
||||||
|
<Skeleton className='h-[14px] w-[14px] rounded-[2px]' />
|
||||||
|
<Skeleton className='ml-[10px] h-[14px] w-[160px] rounded-[4px]' />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='min-h-0 flex-1 overflow-auto'>
|
||||||
|
<table className='w-full'>
|
||||||
|
<thead>
|
||||||
|
<tr className='border-[var(--border)] border-b'>
|
||||||
|
<th className='w-[40px] px-[12px] py-[8px]'>
|
||||||
|
<Skeleton className='h-[14px] w-[14px] rounded-[2px]' />
|
||||||
|
</th>
|
||||||
|
{Array.from({ length: COLUMN_COUNT }).map((_, i) => (
|
||||||
|
<th key={i} className='px-[12px] py-[8px] text-left'>
|
||||||
|
<Skeleton className='h-[12px] w-[56px] rounded-[4px]' />
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{Array.from({ length: SKELETON_ROW_COUNT }).map((_, rowIndex) => (
|
||||||
|
<tr key={rowIndex} className='border-[var(--border)] border-b'>
|
||||||
|
<td className='w-[40px] px-[12px] py-[10px]'>
|
||||||
|
<Skeleton className='h-[14px] w-[14px] rounded-[2px]' />
|
||||||
|
</td>
|
||||||
|
{Array.from({ length: COLUMN_COUNT }).map((_, colIndex) => (
|
||||||
|
<td key={colIndex} className='px-[12px] py-[10px]'>
|
||||||
|
<Skeleton
|
||||||
|
className='h-[14px] rounded-[4px]'
|
||||||
|
style={{ width: colIndex === 0 ? '128px' : '80px' }}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
37
apps/sim/app/workspace/[workspaceId]/agents/page.tsx
Normal file
37
apps/sim/app/workspace/[workspaceId]/agents/page.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import type { Metadata } from 'next'
|
||||||
|
import { redirect } from 'next/navigation'
|
||||||
|
import { getSession } from '@/lib/auth'
|
||||||
|
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
|
||||||
|
import { Agents } from '@/app/workspace/[workspaceId]/agents/agents'
|
||||||
|
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check'
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Agents',
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AgentsPageProps {
|
||||||
|
params: Promise<{
|
||||||
|
workspaceId: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AgentsPage({ params }: AgentsPageProps) {
|
||||||
|
const { workspaceId } = await params
|
||||||
|
const session = await getSession()
|
||||||
|
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
redirect('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasPermission = await verifyWorkspaceMembership(session.user.id, workspaceId)
|
||||||
|
if (!hasPermission) {
|
||||||
|
redirect('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
const permissionConfig = await getUserPermissionConfig(session.user.id)
|
||||||
|
if (permissionConfig?.hideAgentsTab) {
|
||||||
|
redirect(`/workspace/${workspaceId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Agents />
|
||||||
|
}
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import type { ChangeEvent } from 'react'
|
||||||
|
import { useCallback, useRef, useState } from 'react'
|
||||||
|
import { Loader2 } from 'lucide-react'
|
||||||
|
import { Button, Input, Label, Textarea } from '@/components/emcn'
|
||||||
|
import { Upload } from '@/components/emcn/icons'
|
||||||
|
import { cn } from '@/lib/core/utils/cn'
|
||||||
|
import { extractSkillFromZip, parseSkillMarkdown } from './utils'
|
||||||
|
|
||||||
|
interface ImportedSkill {
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
content: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SkillImportProps {
|
||||||
|
onImport: (data: ImportedSkill) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type ImportState = 'idle' | 'loading' | 'error'
|
||||||
|
|
||||||
|
const ACCEPTED_EXTENSIONS = ['.md', '.zip']
|
||||||
|
|
||||||
|
function isAcceptedFile(file: File): boolean {
|
||||||
|
const name = file.name.toLowerCase()
|
||||||
|
return ACCEPTED_EXTENSIONS.some((ext) => name.endsWith(ext))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SkillImport({ onImport }: SkillImportProps) {
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
const [isDragging, setIsDragging] = useState(false)
|
||||||
|
const [dragCounter, setDragCounter] = useState(0)
|
||||||
|
const [fileState, setFileState] = useState<ImportState>('idle')
|
||||||
|
const [fileError, setFileError] = useState('')
|
||||||
|
|
||||||
|
const [githubUrl, setGithubUrl] = useState('')
|
||||||
|
const [githubState, setGithubState] = useState<ImportState>('idle')
|
||||||
|
const [githubError, setGithubError] = useState('')
|
||||||
|
|
||||||
|
const [pasteContent, setPasteContent] = useState('')
|
||||||
|
const [pasteError, setPasteError] = useState('')
|
||||||
|
|
||||||
|
const processFile = useCallback(
|
||||||
|
async (file: File) => {
|
||||||
|
if (!isAcceptedFile(file)) {
|
||||||
|
setFileError('Unsupported file type. Use .md or .zip files.')
|
||||||
|
setFileState('error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setFileState('loading')
|
||||||
|
setFileError('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
let rawContent: string
|
||||||
|
|
||||||
|
if (file.name.toLowerCase().endsWith('.zip')) {
|
||||||
|
rawContent = await extractSkillFromZip(file)
|
||||||
|
} else {
|
||||||
|
rawContent = await file.text()
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = parseSkillMarkdown(rawContent)
|
||||||
|
setFileState('idle')
|
||||||
|
onImport(parsed)
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Failed to process file'
|
||||||
|
setFileError(message)
|
||||||
|
setFileState('error')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onImport]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleFileChange = useCallback(
|
||||||
|
(e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (file) processFile(file)
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||||
|
},
|
||||||
|
[processFile]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleDragEnter = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
setDragCounter((prev) => {
|
||||||
|
const next = prev + 1
|
||||||
|
if (next === 1) setIsDragging(true)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
setDragCounter((prev) => {
|
||||||
|
const next = prev - 1
|
||||||
|
if (next === 0) setIsDragging(false)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
e.dataTransfer.dropEffect = 'copy'
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleDrop = useCallback(
|
||||||
|
(e: React.DragEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
setIsDragging(false)
|
||||||
|
setDragCounter(0)
|
||||||
|
|
||||||
|
const file = e.dataTransfer.files?.[0]
|
||||||
|
if (file) processFile(file)
|
||||||
|
},
|
||||||
|
[processFile]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleGithubImport = useCallback(async () => {
|
||||||
|
const trimmed = githubUrl.trim()
|
||||||
|
if (!trimmed) {
|
||||||
|
setGithubError('Please enter a GitHub URL')
|
||||||
|
setGithubState('error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setGithubState('loading')
|
||||||
|
setGithubError('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/skills/import', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ url: trimmed }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = await res.json()
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(data.error || `Import failed (HTTP ${res.status})`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = parseSkillMarkdown(data.content)
|
||||||
|
setGithubState('idle')
|
||||||
|
onImport(parsed)
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Failed to import from GitHub'
|
||||||
|
setGithubError(message)
|
||||||
|
setGithubState('error')
|
||||||
|
}
|
||||||
|
}, [githubUrl, onImport])
|
||||||
|
|
||||||
|
const handlePasteImport = useCallback(() => {
|
||||||
|
const trimmed = pasteContent.trim()
|
||||||
|
if (!trimmed) {
|
||||||
|
setPasteError('Please paste some content first')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setPasteError('')
|
||||||
|
const parsed = parseSkillMarkdown(trimmed)
|
||||||
|
onImport(parsed)
|
||||||
|
}, [pasteContent, onImport])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='flex flex-col gap-[18px]'>
|
||||||
|
{/* File drop zone */}
|
||||||
|
<div className='flex flex-col gap-[4px]'>
|
||||||
|
<Label className='font-medium text-[14px]'>Upload File</Label>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
onDragEnter={handleDragEnter}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
disabled={fileState === 'loading'}
|
||||||
|
className={cn(
|
||||||
|
'flex w-full cursor-pointer flex-col items-center justify-center gap-[8px] rounded-[8px] border border-dashed px-[16px] py-[32px] transition-colors',
|
||||||
|
'border-[var(--border-1)] bg-[var(--surface-1)] hover:bg-[var(--surface-4)]',
|
||||||
|
isDragging && 'border-[var(--surface-7)] bg-[var(--surface-4)]',
|
||||||
|
fileState === 'loading' && 'pointer-events-none opacity-60'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type='file'
|
||||||
|
accept='.md,.zip'
|
||||||
|
onChange={handleFileChange}
|
||||||
|
className='hidden'
|
||||||
|
/>
|
||||||
|
{fileState === 'loading' ? (
|
||||||
|
<Loader2 className='h-[20px] w-[20px] animate-spin text-[var(--text-tertiary)]' />
|
||||||
|
) : (
|
||||||
|
<Upload className='h-[20px] w-[20px] text-[var(--text-tertiary)]' />
|
||||||
|
)}
|
||||||
|
<div className='flex flex-col gap-[2px] text-center'>
|
||||||
|
<span className='text-[14px] text-[var(--text-primary)]'>
|
||||||
|
{isDragging ? 'Drop file here' : 'Drop file here or click to browse'}
|
||||||
|
</span>
|
||||||
|
<span className='text-[11px] text-[var(--text-tertiary)]'>
|
||||||
|
.md file with YAML frontmatter, or .zip containing a SKILL.md
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{fileError && <p className='text-[13px] text-[var(--text-error)]'>{fileError}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
{/* GitHub URL */}
|
||||||
|
<div className='flex flex-col gap-[4px]'>
|
||||||
|
<Label htmlFor='skill-github-url' className='font-medium text-[14px]'>
|
||||||
|
Import from GitHub
|
||||||
|
</Label>
|
||||||
|
<div className='flex gap-[8px]'>
|
||||||
|
<Input
|
||||||
|
id='skill-github-url'
|
||||||
|
placeholder='https://github.com/owner/repo/blob/main/SKILL.md'
|
||||||
|
value={githubUrl}
|
||||||
|
onChange={(e) => {
|
||||||
|
setGithubUrl(e.target.value)
|
||||||
|
if (githubError) setGithubError('')
|
||||||
|
}}
|
||||||
|
className='flex-1'
|
||||||
|
disabled={githubState === 'loading'}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant='default'
|
||||||
|
onClick={handleGithubImport}
|
||||||
|
disabled={githubState === 'loading' || !githubUrl.trim()}
|
||||||
|
>
|
||||||
|
{githubState === 'loading' ? (
|
||||||
|
<Loader2 className='h-[14px] w-[14px] animate-spin' />
|
||||||
|
) : (
|
||||||
|
'Fetch'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{githubError && <p className='text-[13px] text-[var(--text-error)]'>{githubError}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
{/* Paste content */}
|
||||||
|
<div className='flex flex-col gap-[4px]'>
|
||||||
|
<Label htmlFor='skill-paste' className='font-medium text-[14px]'>
|
||||||
|
Paste SKILL.md Content
|
||||||
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
id='skill-paste'
|
||||||
|
placeholder={
|
||||||
|
'---\nname: my-skill\ndescription: What this skill does\n---\n\n# Instructions...'
|
||||||
|
}
|
||||||
|
value={pasteContent}
|
||||||
|
onChange={(e: ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
|
setPasteContent(e.target.value)
|
||||||
|
if (pasteError) setPasteError('')
|
||||||
|
}}
|
||||||
|
className='min-h-[120px] resize-y font-mono text-[14px]'
|
||||||
|
/>
|
||||||
|
{pasteError && <p className='text-[13px] text-[var(--text-error)]'>{pasteError}</p>}
|
||||||
|
<div className='flex justify-end'>
|
||||||
|
<Button variant='default' onClick={handlePasteImport} disabled={!pasteContent.trim()}>
|
||||||
|
Import
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Divider() {
|
||||||
|
return (
|
||||||
|
<div className='flex items-center gap-[12px]'>
|
||||||
|
<div className='h-px flex-1 bg-[var(--border-1)]' />
|
||||||
|
<span className='text-[12px] text-[var(--text-tertiary)]'>or</span>
|
||||||
|
<div className='h-px flex-1 bg-[var(--border-1)]' />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import type { ChangeEvent } from 'react'
|
import type { ChangeEvent } from 'react'
|
||||||
import { useMemo, useState } from 'react'
|
import { useCallback, useMemo, useState } from 'react'
|
||||||
import { useParams } from 'next/navigation'
|
import { useParams } from 'next/navigation'
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
@@ -12,10 +12,15 @@ import {
|
|||||||
ModalContent,
|
ModalContent,
|
||||||
ModalFooter,
|
ModalFooter,
|
||||||
ModalHeader,
|
ModalHeader,
|
||||||
|
ModalTabs,
|
||||||
|
ModalTabsContent,
|
||||||
|
ModalTabsList,
|
||||||
|
ModalTabsTrigger,
|
||||||
Textarea,
|
Textarea,
|
||||||
} from '@/components/emcn'
|
} from '@/components/emcn'
|
||||||
import type { SkillDefinition } from '@/hooks/queries/skills'
|
import type { SkillDefinition } from '@/hooks/queries/skills'
|
||||||
import { useCreateSkill, useUpdateSkill } from '@/hooks/queries/skills'
|
import { useCreateSkill, useUpdateSkill } from '@/hooks/queries/skills'
|
||||||
|
import { SkillImport } from './skill-import'
|
||||||
|
|
||||||
interface SkillModalProps {
|
interface SkillModalProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
@@ -34,6 +39,8 @@ interface FieldErrors {
|
|||||||
general?: string
|
general?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TabValue = 'create' | 'import'
|
||||||
|
|
||||||
export function SkillModal({
|
export function SkillModal({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
@@ -52,6 +59,7 @@ export function SkillModal({
|
|||||||
const [content, setContent] = useState('')
|
const [content, setContent] = useState('')
|
||||||
const [errors, setErrors] = useState<FieldErrors>({})
|
const [errors, setErrors] = useState<FieldErrors>({})
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [activeTab, setActiveTab] = useState<TabValue>('create')
|
||||||
const [prevOpen, setPrevOpen] = useState(false)
|
const [prevOpen, setPrevOpen] = useState(false)
|
||||||
const [prevInitialValues, setPrevInitialValues] = useState(initialValues)
|
const [prevInitialValues, setPrevInitialValues] = useState(initialValues)
|
||||||
|
|
||||||
@@ -60,6 +68,7 @@ export function SkillModal({
|
|||||||
setDescription(initialValues?.description ?? '')
|
setDescription(initialValues?.description ?? '')
|
||||||
setContent(initialValues?.content ?? '')
|
setContent(initialValues?.content ?? '')
|
||||||
setErrors({})
|
setErrors({})
|
||||||
|
setActiveTab('create')
|
||||||
}
|
}
|
||||||
if (open !== prevOpen) setPrevOpen(open)
|
if (open !== prevOpen) setPrevOpen(open)
|
||||||
if (initialValues !== prevInitialValues) setPrevInitialValues(initialValues)
|
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 (
|
return (
|
||||||
<Modal open={open} onOpenChange={onOpenChange}>
|
<Modal open={open} onOpenChange={onOpenChange}>
|
||||||
<ModalContent size='lg'>
|
<ModalContent size='lg'>
|
||||||
<ModalHeader>{initialValues ? 'Edit Skill' : 'Create Skill'}</ModalHeader>
|
{isEditing ? (
|
||||||
<ModalBody>
|
<>
|
||||||
<div className='flex flex-col gap-[18px]'>
|
<ModalHeader>Edit Skill</ModalHeader>
|
||||||
<div className='flex flex-col gap-[4px]'>
|
<ModalBody>{createForm}</ModalBody>
|
||||||
<Label htmlFor='skill-name' className='font-medium text-[14px]'>
|
{footer}
|
||||||
Name
|
</>
|
||||||
</Label>
|
) : (
|
||||||
<Input
|
<>
|
||||||
id='skill-name'
|
<ModalHeader>Add Skill</ModalHeader>
|
||||||
placeholder='my-skill-name'
|
<ModalTabs
|
||||||
value={name}
|
value={activeTab}
|
||||||
onChange={(e) => {
|
onValueChange={(v) => setActiveTab(v as TabValue)}
|
||||||
setName(e.target.value)
|
className='flex min-h-0 flex-1 flex-col'
|
||||||
if (errors.name || errors.general)
|
>
|
||||||
setErrors((prev) => ({ ...prev, name: undefined, general: undefined }))
|
<ModalTabsList activeValue={activeTab}>
|
||||||
}}
|
<ModalTabsTrigger value='create'>Create</ModalTabsTrigger>
|
||||||
/>
|
<ModalTabsTrigger value='import'>Import</ModalTabsTrigger>
|
||||||
{errors.name ? (
|
</ModalTabsList>
|
||||||
<p className='text-[13px] text-[var(--text-error)]'>{errors.name}</p>
|
<ModalBody>
|
||||||
) : (
|
<ModalTabsContent value='create'>{createForm}</ModalTabsContent>
|
||||||
<span className='text-[11px] text-[var(--text-muted)]'>
|
<ModalTabsContent value='import'>
|
||||||
Lowercase letters, numbers, and hyphens (e.g. my-skill)
|
<SkillImport onImport={handleImport} />
|
||||||
</span>
|
</ModalTabsContent>
|
||||||
)}
|
</ModalBody>
|
||||||
</div>
|
</ModalTabs>
|
||||||
|
{activeTab === 'create' && footer}
|
||||||
<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>
|
|
||||||
</ModalContent>
|
</ModalContent>
|
||||||
</Modal>
|
</Modal>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
/**
|
||||||
|
* @vitest-environment node
|
||||||
|
*/
|
||||||
|
import JSZip from 'jszip'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { extractSkillFromZip, parseSkillMarkdown } from './utils'
|
||||||
|
|
||||||
|
describe('parseSkillMarkdown', () => {
|
||||||
|
it('parses standard SKILL.md with name, description, and body', () => {
|
||||||
|
const input = [
|
||||||
|
'---',
|
||||||
|
'name: my-skill',
|
||||||
|
'description: Does something useful',
|
||||||
|
'---',
|
||||||
|
'',
|
||||||
|
'# Instructions',
|
||||||
|
'Use this skill to do things.',
|
||||||
|
].join('\n')
|
||||||
|
|
||||||
|
expect(parseSkillMarkdown(input)).toEqual({
|
||||||
|
name: 'my-skill',
|
||||||
|
description: 'Does something useful',
|
||||||
|
content: '# Instructions\nUse this skill to do things.',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('strips single and double quotes from frontmatter values', () => {
|
||||||
|
const input = '---\nname: \'my-skill\'\ndescription: "A quoted description"\n---\nBody'
|
||||||
|
|
||||||
|
expect(parseSkillMarkdown(input)).toEqual({
|
||||||
|
name: 'my-skill',
|
||||||
|
description: 'A quoted description',
|
||||||
|
content: 'Body',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves colons inside description values', () => {
|
||||||
|
const input = '---\nname: api-tool\ndescription: API key: required for auth\n---\nBody'
|
||||||
|
|
||||||
|
expect(parseSkillMarkdown(input)).toEqual({
|
||||||
|
name: 'api-tool',
|
||||||
|
description: 'API key: required for auth',
|
||||||
|
content: 'Body',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores unknown frontmatter fields', () => {
|
||||||
|
const input = '---\nname: x\ndescription: y\nauthor: someone\nversion: 2\n---\nBody'
|
||||||
|
|
||||||
|
const result = parseSkillMarkdown(input)
|
||||||
|
expect(result.name).toBe('x')
|
||||||
|
expect(result.description).toBe('y')
|
||||||
|
expect(result.content).toBe('Body')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('infers name from heading when frontmatter has no name field', () => {
|
||||||
|
const input =
|
||||||
|
'---\ndescription: A tool for blocks\nargument-hint: <name>\n---\n\n# Add Block Skill\n\nContent here.'
|
||||||
|
|
||||||
|
expect(parseSkillMarkdown(input)).toEqual({
|
||||||
|
name: 'add-block-skill',
|
||||||
|
description: 'A tool for blocks',
|
||||||
|
content: '# Add Block Skill\n\nContent here.',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('infers name from heading when there is no frontmatter at all', () => {
|
||||||
|
const input = '# My Cool Tool\n\nSome instructions.'
|
||||||
|
|
||||||
|
expect(parseSkillMarkdown(input)).toEqual({
|
||||||
|
name: 'my-cool-tool',
|
||||||
|
description: '',
|
||||||
|
content: '# My Cool Tool\n\nSome instructions.',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns empty name when there is no frontmatter and no heading', () => {
|
||||||
|
const input = 'Just some plain text without any structure.'
|
||||||
|
|
||||||
|
expect(parseSkillMarkdown(input)).toEqual({
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
content: 'Just some plain text without any structure.',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles empty input', () => {
|
||||||
|
expect(parseSkillMarkdown('')).toEqual({
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
content: '',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles frontmatter with empty name value', () => {
|
||||||
|
const input = '---\nname:\ndescription: Has a description\n---\n\n# Fallback Heading\nBody'
|
||||||
|
|
||||||
|
const result = parseSkillMarkdown(input)
|
||||||
|
expect(result.name).toBe('fallback-heading')
|
||||||
|
expect(result.description).toBe('Has a description')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles frontmatter with no body', () => {
|
||||||
|
const input = '---\nname: solo\ndescription: Just frontmatter\n---'
|
||||||
|
|
||||||
|
expect(parseSkillMarkdown(input)).toEqual({
|
||||||
|
name: 'solo',
|
||||||
|
description: 'Just frontmatter',
|
||||||
|
content: '',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles unclosed frontmatter as plain content', () => {
|
||||||
|
const input = '---\nname: broken\nno closing delimiter'
|
||||||
|
|
||||||
|
const result = parseSkillMarkdown(input)
|
||||||
|
expect(result.name).toBe('')
|
||||||
|
expect(result.content).toBe(input)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('trims whitespace from input', () => {
|
||||||
|
const input = '\n\n ---\nname: trimmed\ndescription: yes\n---\nBody \n\n'
|
||||||
|
|
||||||
|
const result = parseSkillMarkdown(input)
|
||||||
|
expect(result.name).toBe('trimmed')
|
||||||
|
expect(result.content).toBe('Body')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('truncates inferred heading names to 64 characters', () => {
|
||||||
|
const longHeading = `# ${'A'.repeat(100)}`
|
||||||
|
const result = parseSkillMarkdown(longHeading)
|
||||||
|
expect(result.name.length).toBeLessThanOrEqual(64)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sanitizes special characters in inferred heading names', () => {
|
||||||
|
const input = '# Hello, World! (v2) — Updated'
|
||||||
|
const result = parseSkillMarkdown(input)
|
||||||
|
expect(result.name).toBe('hello-world-v2-updated')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles h2 and h3 headings for name inference', () => {
|
||||||
|
expect(parseSkillMarkdown('## Sub Heading').name).toBe('sub-heading')
|
||||||
|
expect(parseSkillMarkdown('### Third Level').name).toBe('third-level')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not match h4+ headings for name inference', () => {
|
||||||
|
expect(parseSkillMarkdown('#### Too Deep').name).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses first heading even when multiple exist', () => {
|
||||||
|
const input = '# First\n\n## Second\n\n### Third'
|
||||||
|
expect(parseSkillMarkdown(input).name).toBe('first')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('extractSkillFromZip', () => {
|
||||||
|
async function makeZipBuffer(files: Record<string, string>): Promise<Uint8Array> {
|
||||||
|
const zip = new JSZip()
|
||||||
|
for (const [path, content] of Object.entries(files)) {
|
||||||
|
zip.file(path, content)
|
||||||
|
}
|
||||||
|
return zip.generateAsync({ type: 'uint8array' })
|
||||||
|
}
|
||||||
|
|
||||||
|
it('extracts SKILL.md at root level', async () => {
|
||||||
|
const data = await makeZipBuffer({ 'SKILL.md': '---\nname: root\n---\nContent' })
|
||||||
|
const content = await extractSkillFromZip(data)
|
||||||
|
expect(content).toBe('---\nname: root\n---\nContent')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('extracts SKILL.md from a nested directory', async () => {
|
||||||
|
const data = await makeZipBuffer({ 'my-skill/SKILL.md': '---\nname: nested\n---\nBody' })
|
||||||
|
const content = await extractSkillFromZip(data)
|
||||||
|
expect(content).toBe('---\nname: nested\n---\nBody')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('prefers the shallowest SKILL.md when multiple exist', async () => {
|
||||||
|
const data = await makeZipBuffer({
|
||||||
|
'deep/nested/SKILL.md': 'deep',
|
||||||
|
'SKILL.md': 'root',
|
||||||
|
'other/SKILL.md': 'other',
|
||||||
|
})
|
||||||
|
const content = await extractSkillFromZip(data)
|
||||||
|
expect(content).toBe('root')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws when no SKILL.md is found', async () => {
|
||||||
|
const data = await makeZipBuffer({ 'README.md': 'No skill here' })
|
||||||
|
await expect(extractSkillFromZip(data)).rejects.toThrow('No SKILL.md file found')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import JSZip from 'jszip'
|
||||||
|
|
||||||
|
interface ParsedSkill {
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
content: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const FRONTMATTER_REGEX = /^---\s*\n([\s\S]*?)\n---\s*\n?([\s\S]*)$/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a SKILL.md string with optional YAML frontmatter into structured fields.
|
||||||
|
*
|
||||||
|
* Expected format:
|
||||||
|
* ```
|
||||||
|
* ---
|
||||||
|
* name: my-skill
|
||||||
|
* description: What this skill does
|
||||||
|
* ---
|
||||||
|
* # Markdown content here...
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* If no frontmatter is present, the entire text becomes the content field.
|
||||||
|
*/
|
||||||
|
export function parseSkillMarkdown(raw: string): ParsedSkill {
|
||||||
|
const trimmed = raw.trim()
|
||||||
|
const match = trimmed.match(FRONTMATTER_REGEX)
|
||||||
|
|
||||||
|
if (!match) {
|
||||||
|
return {
|
||||||
|
name: inferNameFromHeading(trimmed),
|
||||||
|
description: '',
|
||||||
|
content: trimmed,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const frontmatter = match[1]
|
||||||
|
const body = (match[2] ?? '').trim()
|
||||||
|
|
||||||
|
let name = ''
|
||||||
|
let description = ''
|
||||||
|
|
||||||
|
for (const line of frontmatter.split('\n')) {
|
||||||
|
const colonIdx = line.indexOf(':')
|
||||||
|
if (colonIdx === -1) continue
|
||||||
|
|
||||||
|
const key = line.slice(0, colonIdx).trim().toLowerCase()
|
||||||
|
const value = line
|
||||||
|
.slice(colonIdx + 1)
|
||||||
|
.trim()
|
||||||
|
.replace(/^['"]|['"]$/g, '')
|
||||||
|
|
||||||
|
if (key === 'name') {
|
||||||
|
name = value
|
||||||
|
} else if (key === 'description') {
|
||||||
|
description = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
name = inferNameFromHeading(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { name, description, content: body }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derives a kebab-case name from the first markdown heading (e.g. `# Add Block Skill` -> `add-block-skill`).
|
||||||
|
*/
|
||||||
|
function inferNameFromHeading(markdown: string): string {
|
||||||
|
const headingMatch = markdown.match(/^#{1,3}\s+(.+)$/m)
|
||||||
|
if (!headingMatch) return ''
|
||||||
|
|
||||||
|
return headingMatch[1]
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-|-$/g, '')
|
||||||
|
.slice(0, 64)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts the SKILL.md content from a ZIP archive.
|
||||||
|
* Searches for a file named SKILL.md at any depth within the archive.
|
||||||
|
* Accepts File, Blob, ArrayBuffer, or Uint8Array (anything JSZip supports).
|
||||||
|
*/
|
||||||
|
export async function extractSkillFromZip(
|
||||||
|
data: File | Blob | ArrayBuffer | Uint8Array
|
||||||
|
): Promise<string> {
|
||||||
|
const zip = await JSZip.loadAsync(data)
|
||||||
|
|
||||||
|
const candidates: string[] = []
|
||||||
|
zip.forEach((relativePath, entry) => {
|
||||||
|
if (!entry.dir && relativePath.endsWith('SKILL.md')) {
|
||||||
|
candidates.push(relativePath)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (candidates.length === 0) {
|
||||||
|
throw new Error('No SKILL.md file found in the ZIP archive')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefer the shallowest path (fewest slashes)
|
||||||
|
candidates.sort((a, b) => {
|
||||||
|
const depthA = a.split('/').length
|
||||||
|
const depthB = b.split('/').length
|
||||||
|
return depthA - depthB
|
||||||
|
})
|
||||||
|
|
||||||
|
const content = await zip.file(candidates[0])!.async('string')
|
||||||
|
return content
|
||||||
|
}
|
||||||
@@ -17,6 +17,8 @@ import { generateToolInputSchema, sanitizeToolName } from '@/lib/mcp/workflow-to
|
|||||||
import { normalizeInputFormatValue } from '@/lib/workflows/input-format'
|
import { normalizeInputFormatValue } from '@/lib/workflows/input-format'
|
||||||
import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers'
|
import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers'
|
||||||
import type { InputFormatField } from '@/lib/workflows/types'
|
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 {
|
import {
|
||||||
useAddWorkflowMcpTool,
|
useAddWorkflowMcpTool,
|
||||||
useDeleteWorkflowMcpTool,
|
useDeleteWorkflowMcpTool,
|
||||||
@@ -26,7 +28,7 @@ import {
|
|||||||
type WorkflowMcpServer,
|
type WorkflowMcpServer,
|
||||||
type WorkflowMcpTool,
|
type WorkflowMcpTool,
|
||||||
} from '@/hooks/queries/workflow-mcp-servers'
|
} 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 { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||||
|
|
||||||
@@ -100,7 +102,11 @@ export function McpDeploy({
|
|||||||
}: McpDeployProps) {
|
}: McpDeployProps) {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const workspaceId = params.workspaceId as string
|
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 { data: servers = [], isLoading: isLoadingServers } = useWorkflowMcpServers(workspaceId)
|
||||||
const addToolMutation = useAddWorkflowMcpTool()
|
const addToolMutation = useAddWorkflowMcpTool()
|
||||||
@@ -464,17 +470,27 @@ export function McpDeploy({
|
|||||||
|
|
||||||
if (servers.length === 0) {
|
if (servers.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className='flex h-full flex-col items-center justify-center gap-3'>
|
<>
|
||||||
<p className='text-[13px] text-[var(--text-muted)]'>
|
<div className='flex h-full flex-col items-center justify-center gap-3'>
|
||||||
Create an MCP Server in Settings → MCP Servers first.
|
<p className='text-[13px] text-[var(--text-muted)]'>
|
||||||
</p>
|
Create an MCP Server in Settings → MCP Servers first.
|
||||||
<Button
|
</p>
|
||||||
variant='tertiary'
|
<Button variant='tertiary' onClick={() => setShowMcpModal(true)}>
|
||||||
onClick={() => navigateToSettings({ section: 'workflow-mcp-servers' })}
|
Create MCP Server
|
||||||
>
|
</Button>
|
||||||
Create MCP Server
|
</div>
|
||||||
</Button>
|
<McpServerFormModal
|
||||||
</div>
|
open={showMcpModal}
|
||||||
|
onOpenChange={setShowMcpModal}
|
||||||
|
mode='add'
|
||||||
|
onSubmit={async (config) => {
|
||||||
|
await createMcpServer.mutateAsync({ workspaceId, config: { ...config, enabled: true } })
|
||||||
|
}}
|
||||||
|
workspaceId={workspaceId}
|
||||||
|
availableEnvVars={availableEnvVars}
|
||||||
|
allowedMcpDomains={allowedMcpDomains}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { Button, Combobox } from '@/components/emcn/components'
|
|||||||
import { getSubscriptionStatus } from '@/lib/billing/client'
|
import { getSubscriptionStatus } from '@/lib/billing/client'
|
||||||
import { getEnv, isTruthy } from '@/lib/core/config/env'
|
import { getEnv, isTruthy } from '@/lib/core/config/env'
|
||||||
import { getPollingProviderFromOAuth } from '@/lib/credential-sets/providers'
|
import { getPollingProviderFromOAuth } from '@/lib/credential-sets/providers'
|
||||||
import { writePendingCredentialCreateRequest } from '@/lib/credentials/client-state'
|
|
||||||
import {
|
import {
|
||||||
getCanonicalScopesForProvider,
|
getCanonicalScopesForProvider,
|
||||||
getProviderIdFromServiceId,
|
getProviderIdFromServiceId,
|
||||||
@@ -26,7 +25,6 @@ import { useOAuthCredentials } from '@/hooks/queries/oauth/oauth-credentials'
|
|||||||
import { useOrganizations } from '@/hooks/queries/organization'
|
import { useOrganizations } from '@/hooks/queries/organization'
|
||||||
import { useSubscriptionData } from '@/hooks/queries/subscription'
|
import { useSubscriptionData } from '@/hooks/queries/subscription'
|
||||||
import { useCredentialRefreshTriggers } from '@/hooks/use-credential-refresh-triggers'
|
import { useCredentialRefreshTriggers } from '@/hooks/use-credential-refresh-triggers'
|
||||||
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
|
|
||||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||||
|
|
||||||
const isBillingEnabled = isTruthy(getEnv('NEXT_PUBLIC_BILLING_ENABLED'))
|
const isBillingEnabled = isTruthy(getEnv('NEXT_PUBLIC_BILLING_ENABLED'))
|
||||||
@@ -53,7 +51,10 @@ export function CredentialSelector({
|
|||||||
const [showOAuthModal, setShowOAuthModal] = useState(false)
|
const [showOAuthModal, setShowOAuthModal] = useState(false)
|
||||||
const [editingValue, setEditingValue] = useState('')
|
const [editingValue, setEditingValue] = useState('')
|
||||||
const [isEditing, setIsEditing] = useState(false)
|
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 [storeValue, setStoreValue] = useSubBlockValue<string | null>(blockId, subBlock.id)
|
||||||
|
|
||||||
const requiredScopes = subBlock.requiredScopes || []
|
const requiredScopes = subBlock.requiredScopes || []
|
||||||
@@ -103,7 +104,7 @@ export function CredentialSelector({
|
|||||||
} = useOAuthCredentials(effectiveProviderId, {
|
} = useOAuthCredentials(effectiveProviderId, {
|
||||||
enabled: Boolean(effectiveProviderId),
|
enabled: Boolean(effectiveProviderId),
|
||||||
workspaceId,
|
workspaceId,
|
||||||
workflowId: activeWorkflowId || undefined,
|
workflowId: effectiveWorkflowId,
|
||||||
})
|
})
|
||||||
|
|
||||||
const selectedCredential = useMemo(
|
const selectedCredential = useMemo(
|
||||||
@@ -157,7 +158,6 @@ export function CredentialSelector({
|
|||||||
const displayValue = isEditing ? editingValue : resolvedLabel
|
const displayValue = isEditing ? editingValue : resolvedLabel
|
||||||
|
|
||||||
useCredentialRefreshTriggers(refetchCredentials, effectiveProviderId, workspaceId)
|
useCredentialRefreshTriggers(refetchCredentials, effectiveProviderId, workspaceId)
|
||||||
const { navigateToSettings } = useSettingsNavigation()
|
|
||||||
|
|
||||||
const handleOpenChange = useCallback(
|
const handleOpenChange = useCallback(
|
||||||
(isOpen: boolean) => {
|
(isOpen: boolean) => {
|
||||||
@@ -199,21 +199,8 @@ export function CredentialSelector({
|
|||||||
)
|
)
|
||||||
|
|
||||||
const handleAddCredential = useCallback(() => {
|
const handleAddCredential = useCallback(() => {
|
||||||
writePendingCredentialCreateRequest({
|
setShowOAuthModal(true)
|
||||||
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])
|
|
||||||
|
|
||||||
const getProviderIcon = useCallback((providerName: OAuthProvider) => {
|
const getProviderIcon = useCallback((providerName: OAuthProvider) => {
|
||||||
const { baseProvider } = parseProvider(providerName)
|
const { baseProvider } = parseProvider(providerName)
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { createElement, useCallback, useEffect, useMemo, useRef, useState } from
|
|||||||
import { ExternalLink } from 'lucide-react'
|
import { ExternalLink } from 'lucide-react'
|
||||||
import { useParams } from 'next/navigation'
|
import { useParams } from 'next/navigation'
|
||||||
import { Button, Combobox } from '@/components/emcn/components'
|
import { Button, Combobox } from '@/components/emcn/components'
|
||||||
import { writePendingCredentialCreateRequest } from '@/lib/credentials/client-state'
|
|
||||||
import {
|
import {
|
||||||
getCanonicalScopesForProvider,
|
getCanonicalScopesForProvider,
|
||||||
getProviderIdFromServiceId,
|
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 { 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 { useOAuthCredentials } from '@/hooks/queries/oauth/oauth-credentials'
|
||||||
import { useCredentialRefreshTriggers } from '@/hooks/use-credential-refresh-triggers'
|
import { useCredentialRefreshTriggers } from '@/hooks/use-credential-refresh-triggers'
|
||||||
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
|
|
||||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||||
|
|
||||||
const getProviderIcon = (providerName: OAuthProvider) => {
|
const getProviderIcon = (providerName: OAuthProvider) => {
|
||||||
@@ -74,8 +72,10 @@ export function ToolCredentialSelector({
|
|||||||
const [showOAuthModal, setShowOAuthModal] = useState(false)
|
const [showOAuthModal, setShowOAuthModal] = useState(false)
|
||||||
const [editingInputValue, setEditingInputValue] = useState('')
|
const [editingInputValue, setEditingInputValue] = useState('')
|
||||||
const [isEditing, setIsEditing] = useState(false)
|
const [isEditing, setIsEditing] = useState(false)
|
||||||
const { activeWorkflowId } = useWorkflowRegistry()
|
const { activeWorkflowId, workflows } = useWorkflowRegistry()
|
||||||
const { navigateToSettings } = useSettingsNavigation()
|
// 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 selectedId = value || ''
|
||||||
const effectiveLabel = label || `Select ${getProviderName(provider)} account`
|
const effectiveLabel = label || `Select ${getProviderName(provider)} account`
|
||||||
@@ -89,7 +89,7 @@ export function ToolCredentialSelector({
|
|||||||
} = useOAuthCredentials(effectiveProviderId, {
|
} = useOAuthCredentials(effectiveProviderId, {
|
||||||
enabled: Boolean(effectiveProviderId),
|
enabled: Boolean(effectiveProviderId),
|
||||||
workspaceId,
|
workspaceId,
|
||||||
workflowId: activeWorkflowId || undefined,
|
workflowId: effectiveWorkflowId,
|
||||||
})
|
})
|
||||||
|
|
||||||
const selectedCredential = useMemo(
|
const selectedCredential = useMemo(
|
||||||
@@ -164,18 +164,8 @@ export function ToolCredentialSelector({
|
|||||||
)
|
)
|
||||||
|
|
||||||
const handleAddCredential = useCallback(() => {
|
const handleAddCredential = useCallback(() => {
|
||||||
writePendingCredentialCreateRequest({
|
setShowOAuthModal(true)
|
||||||
workspaceId,
|
}, [])
|
||||||
type: 'oauth',
|
|
||||||
providerId: effectiveProviderId,
|
|
||||||
displayName: '',
|
|
||||||
serviceId,
|
|
||||||
requiredScopes: getCanonicalScopesForProvider(effectiveProviderId),
|
|
||||||
requestedAt: Date.now(),
|
|
||||||
})
|
|
||||||
|
|
||||||
navigateToSettings({ section: 'integrations' })
|
|
||||||
}, [workspaceId, effectiveProviderId, serviceId])
|
|
||||||
|
|
||||||
const comboboxOptions = useMemo(() => {
|
const comboboxOptions = useMemo(() => {
|
||||||
const options = credentials.map((cred) => ({
|
const options = credentials.map((cred) => ({
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import type { McpToolSchema } from '@/lib/mcp/types'
|
|||||||
import { getProviderIdFromServiceId, type OAuthProvider, type OAuthService } from '@/lib/oauth'
|
import { getProviderIdFromServiceId, type OAuthProvider, type OAuthService } from '@/lib/oauth'
|
||||||
import { extractInputFieldsFromBlocks } from '@/lib/workflows/input-format'
|
import { extractInputFieldsFromBlocks } from '@/lib/workflows/input-format'
|
||||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
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 {
|
import {
|
||||||
LongInput,
|
LongInput,
|
||||||
ShortInput,
|
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 type { WandControlHandlers } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block'
|
||||||
import { getAllBlocks } from '@/blocks'
|
import { getAllBlocks } from '@/blocks'
|
||||||
import type { SubBlockConfig as BlockSubBlockConfig } from '@/blocks/types'
|
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 { useMcpTools } from '@/hooks/mcp/use-mcp-tools'
|
||||||
import {
|
import {
|
||||||
type CustomTool as CustomToolDefinition,
|
type CustomTool as CustomToolDefinition,
|
||||||
@@ -55,12 +57,15 @@ import {
|
|||||||
} from '@/hooks/queries/custom-tools'
|
} from '@/hooks/queries/custom-tools'
|
||||||
import { useDeploymentInfo, useDeployWorkflow } from '@/hooks/queries/deployments'
|
import { useDeploymentInfo, useDeployWorkflow } from '@/hooks/queries/deployments'
|
||||||
import {
|
import {
|
||||||
|
useAllowedMcpDomains,
|
||||||
|
useCreateMcpServer,
|
||||||
useForceRefreshMcpTools,
|
useForceRefreshMcpTools,
|
||||||
useMcpServers,
|
useMcpServers,
|
||||||
useMcpToolsEvents,
|
useMcpToolsEvents,
|
||||||
useStoredMcpTools,
|
useStoredMcpTools,
|
||||||
} from '@/hooks/queries/mcp'
|
} from '@/hooks/queries/mcp'
|
||||||
import { useWorkflowState, useWorkflows } from '@/hooks/queries/workflows'
|
import { useWorkflowState, useWorkflows } from '@/hooks/queries/workflows'
|
||||||
|
import { useAvailableEnvVarKeys } from '@/hooks/use-available-env-vars'
|
||||||
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
||||||
import { usePermissionConfig } from '@/hooks/use-permission-config'
|
import { usePermissionConfig } from '@/hooks/use-permission-config'
|
||||||
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
|
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
|
||||||
@@ -330,24 +335,6 @@ function resolveCustomToolFromReference(
|
|||||||
* These are distinguished from third-party integrations for categorization
|
* These are distinguished from third-party integrations for categorization
|
||||||
* in the tool selection dropdown.
|
* 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.
|
* Checks if a block supports multiple operations.
|
||||||
@@ -469,6 +456,7 @@ export const ToolInput = memo(function ToolInput({
|
|||||||
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlockId)
|
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlockId)
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [customToolModalOpen, setCustomToolModalOpen] = useState(false)
|
const [customToolModalOpen, setCustomToolModalOpen] = useState(false)
|
||||||
|
const [mcpModalOpen, setMcpModalOpen] = useState(false)
|
||||||
const [editingToolIndex, setEditingToolIndex] = useState<number | null>(null)
|
const [editingToolIndex, setEditingToolIndex] = useState<number | null>(null)
|
||||||
const [draggedIndex, setDraggedIndex] = useState<number | null>(null)
|
const [draggedIndex, setDraggedIndex] = useState<number | null>(null)
|
||||||
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null)
|
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null)
|
||||||
@@ -507,6 +495,9 @@ export const ToolInput = memo(function ToolInput({
|
|||||||
const forceRefreshMcpTools = useForceRefreshMcpTools()
|
const forceRefreshMcpTools = useForceRefreshMcpTools()
|
||||||
useMcpToolsEvents(workspaceId)
|
useMcpToolsEvents(workspaceId)
|
||||||
const { navigateToSettings } = useSettingsNavigation()
|
const { navigateToSettings } = useSettingsNavigation()
|
||||||
|
const createMcpServer = useCreateMcpServer()
|
||||||
|
const { data: allowedMcpDomains = null } = useAllowedMcpDomains()
|
||||||
|
const availableEnvVars = useAvailableEnvVarKeys(workspaceId)
|
||||||
const mcpDataLoading = mcpLoading || mcpServersLoading
|
const mcpDataLoading = mcpLoading || mcpServersLoading
|
||||||
|
|
||||||
const { data: workflowsList = [] } = useWorkflows(workspaceId, { syncRegistry: false })
|
const { data: workflowsList = [] } = useWorkflows(workspaceId, { syncRegistry: false })
|
||||||
@@ -1379,7 +1370,7 @@ export const ToolInput = memo(function ToolInput({
|
|||||||
icon: McpIcon,
|
icon: McpIcon,
|
||||||
onSelect: () => {
|
onSelect: () => {
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
navigateToSettings({ section: 'mcp' })
|
setMcpModalOpen(true)
|
||||||
},
|
},
|
||||||
disabled: isPreview,
|
disabled: isPreview,
|
||||||
})
|
})
|
||||||
@@ -2095,6 +2086,18 @@ export const ToolInput = memo(function ToolInput({
|
|||||||
: undefined
|
: 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>
|
</div>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -32,7 +32,9 @@ import {
|
|||||||
Settings,
|
Settings,
|
||||||
Sim,
|
Sim,
|
||||||
Table,
|
Table,
|
||||||
|
Wordmark,
|
||||||
} from '@/components/emcn/icons'
|
} from '@/components/emcn/icons'
|
||||||
|
import { AgentIcon } from '@/components/icons'
|
||||||
import { useSession } from '@/lib/auth/auth-client'
|
import { useSession } from '@/lib/auth/auth-client'
|
||||||
import { cn } from '@/lib/core/utils/cn'
|
import { cn } from '@/lib/core/utils/cn'
|
||||||
import { ConversationListItem } from '@/app/workspace/[workspaceId]/components'
|
import { ConversationListItem } from '@/app/workspace/[workspaceId]/components'
|
||||||
@@ -197,7 +199,6 @@ const SidebarNavItem = memo(function SidebarNavItem({
|
|||||||
const Icon = item.icon
|
const Icon = item.icon
|
||||||
const baseClasses =
|
const baseClasses =
|
||||||
'group flex h-[30px] items-center gap-[8px] rounded-[8px] mx-[2px] px-[8px] text-[14px] hover:bg-[var(--surface-active)]'
|
'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 = (
|
const content = (
|
||||||
<>
|
<>
|
||||||
@@ -210,7 +211,7 @@ const SidebarNavItem = memo(function SidebarNavItem({
|
|||||||
<Link
|
<Link
|
||||||
href={item.href}
|
href={item.href}
|
||||||
data-item-id={item.id}
|
data-item-id={item.id}
|
||||||
className={`${baseClasses} ${activeClasses}`}
|
className={cn(baseClasses, active && 'bg-[var(--surface-active)]')}
|
||||||
onClick={
|
onClick={
|
||||||
item.onClick
|
item.onClick
|
||||||
? (e) => {
|
? (e) => {
|
||||||
@@ -228,7 +229,7 @@ const SidebarNavItem = memo(function SidebarNavItem({
|
|||||||
<button
|
<button
|
||||||
type='button'
|
type='button'
|
||||||
data-item-id={item.id}
|
data-item-id={item.id}
|
||||||
className={`${baseClasses} ${activeClasses}`}
|
className={cn(baseClasses, active && 'bg-[var(--surface-active)]')}
|
||||||
onClick={item.onClick}
|
onClick={item.onClick}
|
||||||
>
|
>
|
||||||
{content}
|
{content}
|
||||||
@@ -274,7 +275,7 @@ export const Sidebar = memo(function Sidebar() {
|
|||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
const scrollContainerRef = useRef<HTMLDivElement>(null)
|
const scrollContainerRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
const { data: sessionData, isPending: sessionLoading } = useSession()
|
const { data: sessionData } = useSession()
|
||||||
const { canEdit } = useUserPermissionsContext()
|
const { canEdit } = useUserPermissionsContext()
|
||||||
const { config: permissionConfig, filterBlocks } = usePermissionConfig()
|
const { config: permissionConfig, filterBlocks } = usePermissionConfig()
|
||||||
const { navigateToSettings, getSettingsHref } = useSettingsNavigation()
|
const { navigateToSettings, getSettingsHref } = useSettingsNavigation()
|
||||||
@@ -552,6 +553,13 @@ export const Sidebar = memo(function Sidebar() {
|
|||||||
const workspaceNavItems = useMemo(
|
const workspaceNavItems = useMemo(
|
||||||
() =>
|
() =>
|
||||||
[
|
[
|
||||||
|
{
|
||||||
|
id: 'agents',
|
||||||
|
label: 'Agents',
|
||||||
|
icon: AgentIcon,
|
||||||
|
href: `/workspace/${workspaceId}/agents`,
|
||||||
|
hidden: permissionConfig.hideAgentsTab,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'tables',
|
id: 'tables',
|
||||||
label: 'Tables',
|
label: 'Tables',
|
||||||
@@ -588,6 +596,7 @@ export const Sidebar = memo(function Sidebar() {
|
|||||||
].filter((item) => !item.hidden),
|
].filter((item) => !item.hidden),
|
||||||
[
|
[
|
||||||
workspaceId,
|
workspaceId,
|
||||||
|
permissionConfig.hideAgentsTab,
|
||||||
permissionConfig.hideKnowledgeBaseTab,
|
permissionConfig.hideKnowledgeBaseTab,
|
||||||
permissionConfig.hideTablesTab,
|
permissionConfig.hideTablesTab,
|
||||||
permissionConfig.hideFilesTab,
|
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)
|
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 isOnSettingsPage = pathname?.startsWith(`/workspace/${workspaceId}/settings`) ?? false
|
||||||
|
|
||||||
const isLoading = workflowsLoading || sessionLoading
|
|
||||||
const initialScrollDoneRef = useRef(false)
|
const initialScrollDoneRef = useRef(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -900,25 +908,11 @@ export const Sidebar = memo(function Sidebar() {
|
|||||||
|
|
||||||
const zipFile = files[0]
|
const zipFile = files[0]
|
||||||
await importWorkspace(zipFile)
|
await importWorkspace(zipFile)
|
||||||
|
event.target.value = ''
|
||||||
if (event.target) {
|
|
||||||
event.target.value = ''
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[importWorkspace]
|
[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(() =>
|
useRegisterGlobalCommands(() =>
|
||||||
createCommands([
|
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',
|
id: 'goto-logs',
|
||||||
handler: () => {
|
handler: () => {
|
||||||
try {
|
try {
|
||||||
const pathWorkspaceId = resolveWorkspaceIdFromPath()
|
if (workspaceId) {
|
||||||
if (pathWorkspaceId) {
|
navigateToPage(`/workspace/${workspaceId}/logs`)
|
||||||
navigateToPage(`/workspace/${pathWorkspaceId}/logs`)
|
logger.info('Navigated to logs', { workspaceId })
|
||||||
logger.info('Navigated to logs', { workspaceId: pathWorkspaceId })
|
|
||||||
} else {
|
} else {
|
||||||
logger.warn('No workspace ID found, cannot navigate to logs')
|
logger.warn('No workspace ID found, cannot navigate to logs')
|
||||||
}
|
}
|
||||||
@@ -1017,7 +994,7 @@ export const Sidebar = memo(function Sidebar() {
|
|||||||
) : (
|
) : (
|
||||||
<Link
|
<Link
|
||||||
href={`/workspace/${workspaceId}/home`}
|
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 ? (
|
{brand.logoUrl ? (
|
||||||
<Image
|
<Image
|
||||||
@@ -1029,7 +1006,7 @@ export const Sidebar = memo(function Sidebar() {
|
|||||||
unoptimized
|
unoptimized
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Sim className='h-[16px] w-[16px] text-[var(--text-icon)]' />
|
<Wordmark className='h-[16px] w-auto text-[var(--text-body)]' />
|
||||||
)}
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
@@ -1369,7 +1346,7 @@ export const Sidebar = memo(function Sidebar() {
|
|||||||
workspaceId={workspaceId}
|
workspaceId={workspaceId}
|
||||||
workflowId={workflowId}
|
workflowId={workflowId}
|
||||||
regularWorkflows={regularWorkflows}
|
regularWorkflows={regularWorkflows}
|
||||||
isLoading={isLoading}
|
isLoading={workflowsLoading}
|
||||||
canReorder={canEdit}
|
canReorder={canEdit}
|
||||||
handleFileChange={handleImportFileChange}
|
handleFileChange={handleImportFileChange}
|
||||||
fileInputRef={fileInputRef}
|
fileInputRef={fileInputRef}
|
||||||
|
|||||||
@@ -47,6 +47,30 @@ export function getDependsOnFields(dependsOn: SubBlockConfig['dependsOn']): stri
|
|||||||
return [...(dependsOn.all || []), ...(dependsOn.any || [])]
|
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(
|
export function resolveOutputType(
|
||||||
outputs: Record<string, OutputFieldDefinition>
|
outputs: Record<string, OutputFieldDefinition>
|
||||||
): Record<string, BlockOutput> {
|
): 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 }
|
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()
|
const normalizedModel = model.trim().toLowerCase()
|
||||||
if (!normalizedModel) return false
|
if (!normalizedModel) return false
|
||||||
|
|
||||||
|
|||||||
29
apps/sim/components/emcn/icons/bot.tsx
Normal file
29
apps/sim/components/emcn/icons/bot.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import type { SVGProps } from 'react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bot icon component - robot head with antenna and circuit details
|
||||||
|
* @param props - SVG properties including className, fill, etc.
|
||||||
|
*/
|
||||||
|
export function Bot(props: SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width='24'
|
||||||
|
height='24'
|
||||||
|
viewBox='0 0 24 24'
|
||||||
|
fill='none'
|
||||||
|
stroke='currentColor'
|
||||||
|
strokeWidth='1.75'
|
||||||
|
strokeLinecap='round'
|
||||||
|
strokeLinejoin='round'
|
||||||
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path d='M12 8V4h0' />
|
||||||
|
<rect width='16' height='12' x='4' y='8' rx='2' />
|
||||||
|
<path d='M2 14h2' />
|
||||||
|
<path d='M20 14h2' />
|
||||||
|
<path d='M15 13v2' />
|
||||||
|
<path d='M9 13v2' />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ export { Asterisk } from './asterisk'
|
|||||||
export { Bell } from './bell'
|
export { Bell } from './bell'
|
||||||
export { Blimp } from './blimp'
|
export { Blimp } from './blimp'
|
||||||
export { BookOpen } from './book-open'
|
export { BookOpen } from './book-open'
|
||||||
|
export { Bot } from './bot'
|
||||||
export { BubbleChatClose } from './bubble-chat-close'
|
export { BubbleChatClose } from './bubble-chat-close'
|
||||||
export { BubbleChatPreview } from './bubble-chat-preview'
|
export { BubbleChatPreview } from './bubble-chat-preview'
|
||||||
export { Bug } from './bug'
|
export { Bug } from './bug'
|
||||||
@@ -84,6 +85,7 @@ export { Upload } from './upload'
|
|||||||
export { User } from './user'
|
export { User } from './user'
|
||||||
export { UserPlus } from './user-plus'
|
export { UserPlus } from './user-plus'
|
||||||
export { Users } from './users'
|
export { Users } from './users'
|
||||||
|
export { Wordmark } from './wordmark'
|
||||||
export { WorkflowX } from './workflow-x'
|
export { WorkflowX } from './workflow-x'
|
||||||
export { Wrap } from './wrap'
|
export { Wrap } from './wrap'
|
||||||
export { Wrench } from './wrench'
|
export { Wrench } from './wrench'
|
||||||
|
|||||||
55
apps/sim/components/emcn/icons/wordmark.tsx
Normal file
55
apps/sim/components/emcn/icons/wordmark.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import type { SVGProps } from 'react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sim brand wordmark — icon (green) + "Sim" text as a single SVG.
|
||||||
|
* Use when expanded; use the plain `Sim` icon when collapsed.
|
||||||
|
* @param props - SVG properties including className, style, etc.
|
||||||
|
*/
|
||||||
|
export function Wordmark(props: SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
fill='none'
|
||||||
|
height='22'
|
||||||
|
viewBox='0 0 71 22'
|
||||||
|
width='71'
|
||||||
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<g transform='scale(.07483)'>
|
||||||
|
<path
|
||||||
|
clipRule='evenodd'
|
||||||
|
d='m142.793 124.175c0 4.75-1.88 9.312-5.216 12.671l-.478.481c-3.334 3.369-7.863 5.252-12.58 5.252h-106.7127c-9.82776 0-17.8063 8.026-17.8063 17.924v115.407c0 9.898 7.97854 17.924 17.8063 17.924h114.5767c9.828 0 17.796-8.026 17.796-17.924v-108.052c0-4.405 1.735-8.632 4.83-11.749 3.086-3.108 7.283-4.856 11.657-4.856h108.5c9.828 0 17.796-8.024 17.796-17.923v-115.4069c0-9.89798-7.968-17.9231-17.796-17.9231h-114.578c-9.827 0-17.795 8.02512-17.795 17.9231zm34.771-99.6079h80.617c5.744 0 10.389 4.6874 10.389 10.463v81.1939c0 5.774-4.645 10.463-10.389 10.463h-80.617c-5.734 0-10.389-4.689-10.389-10.463v-81.1939c0-5.7756 4.655-10.463 10.389-10.463z'
|
||||||
|
fill='#33c482'
|
||||||
|
fillRule='evenodd'
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d='m275.293 171.578h-85.187c-10.327 0-18.7 8.432-18.7 18.834v84.75c0 10.402 8.373 18.834 18.7 18.834h85.187c10.328 0 18.701-8.432 18.701-18.834v-84.75c0-10.402-8.373-18.834-18.701-18.834z'
|
||||||
|
fill='#33c482'
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d='m275.293 171.18h-85.187c-10.327 0-18.7 8.432-18.7 18.834v84.749c0 10.402 8.373 18.833 18.7 18.833h85.187c10.328 0 18.701-8.431 18.701-18.833v-84.749c0-10.402-8.373-18.834-18.701-18.834z'
|
||||||
|
fill='url(#sim-wordmark-gradient)'
|
||||||
|
fillOpacity='.2'
|
||||||
|
/>
|
||||||
|
<defs>
|
||||||
|
<linearGradient
|
||||||
|
id='sim-wordmark-gradient'
|
||||||
|
gradientUnits='userSpaceOnUse'
|
||||||
|
x1='171.406'
|
||||||
|
x2='245.831'
|
||||||
|
y1='171.18'
|
||||||
|
y2='245.428'
|
||||||
|
>
|
||||||
|
<stop offset='0' />
|
||||||
|
<stop offset='1' stopOpacity='0' />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</g>
|
||||||
|
<g fill='currentColor'>
|
||||||
|
<path d='m31.5718 15.845h2.5865c0 .7141.2586 1.2835.7759 1.7081.5173.4053 1.2166.608 2.0979.608.958 0 1.6956-.1834 2.2129-.5501.5173-.386.776-.8975.776-1.5344 0-.4632-.1437-.8492-.4311-1.158-.2682-.3088-.7664-.5597-1.4944-.7527l-2.4716-.579c-1.2453-.3088-2.1745-.7817-2.7876-1.4186-.594-.6369-.8909-1.4765-.8909-2.51873 0-.86852.2203-1.62124.661-2.25815.4598-.63692 1.0825-1.12908 1.868-1.47648.8047-.34741 1.7243-.52112 2.7589-.52112s1.9255.18336 2.6727.55007c.7664.3667 1.3603.87817 1.7818 1.53438.4407.65622.6706 1.43788.6898 2.345h-2.5865c-.0192-.73341-.2587-1.30278-.7185-1.70809-.4598-.4053-1.1017-.60796-1.9255-.60796-.843 0-1.4944.18336-1.9542.55006-.4599.36671-.6898.86852-.6898 1.50544 0 .94568.6898 1.59228 2.0692 1.93968l2.4716.608c1.1878.2702 2.0787.7141 2.6727 1.3317.5939.5983.8909 1.4186.8909 2.4608 0 .8878-.2395 1.6695-.7185 2.345-.479.6562-1.14 1.1677-1.983 1.5344-.8238.3474-1.8009.5211-2.9313.5211-1.6477 0-2.9601-.4053-3.9372-1.2159-.9772-.8106-1.4657-1.8915-1.4657-3.2425z' />
|
||||||
|
<path d='m44.5096 19.956v-14.15687c1.0772.39383 1.5521.39383 2.7014 0v14.15687zm1.322-15.09268c-.479 0-.9005-.1737-1.2645-.52111-.3449-.36671-.5173-.79132-.5173-1.27383 0-.50181.1724-.92642.5173-1.27383.364-.34741.7855-.52111 1.2645-.52111.4981 0 .9196.1737 1.2645.52111s.5173.77202.5173 1.27383c0 .48251-.1724.90712-.5173 1.27383-.3449.34741-.7664.52111-1.2645.52111z' />
|
||||||
|
<path d='m51.976 19.956h-2.7014v-14.15687h2.4141v2.38865c.2873-.79131.843-1.46223 1.6093-1.98334.7855-.54041 1.7339-.81062 2.8452-.81062 1.2453 0 2.2799.33776 3.1038 1.01328.8238.67551 1.3603 1.57298 1.6093 2.69241h-.4885c.1916-1.11943.7184-2.0169 1.5806-2.69241.8622-.67552 1.9255-1.01328 3.19-1.01328 1.6094 0 2.8739.47286 3.7935 1.41858.9197.94573 1.3795 2.23886 1.3795 3.8794v9.2642h-2.644v-8.5983c0-1.1195-.2874-1.97834-.8621-2.57665-.5557-.61761-1.3125-.92642-2.2704-.92642-.6706 0-1.2645.1544-1.7818.46321-.4982.28951-.8909.71412-1.1783 1.27383-.2874.55973-.4311 1.21593-.4311 1.96863v8.3957h-2.6727v-8.6273c0-1.1194-.2778-1.96864-.8334-2.54765-.5556-.59831-1.3124-.89747-2.2704-.89747-.6706 0-1.2645.1544-1.7818.46321-.4981.28951-.8909.71412-1.1783 1.27383-.2874.54038-.4311 1.18698-.4311 1.93968z' />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -61,6 +61,10 @@ export interface ToolInput {
|
|||||||
operation?: string
|
operation?: string
|
||||||
/** Database ID for custom tools (new reference format) */
|
/** Database ID for custom tools (new reference format) */
|
||||||
customToolId?: string
|
customToolId?: string
|
||||||
|
/** Direct tool ID for execution */
|
||||||
|
toolId?: string
|
||||||
|
/** Whether the tool details are expanded in the UI */
|
||||||
|
isExpanded?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Message {
|
export interface Message {
|
||||||
|
|||||||
249
apps/sim/hooks/queries/agents.ts
Normal file
249
apps/sim/hooks/queries/agents.ts
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import type {
|
||||||
|
AgentConfig,
|
||||||
|
AgentDeploymentRow,
|
||||||
|
AgentRow,
|
||||||
|
SlackDeploymentConfig,
|
||||||
|
} from '@/lib/agents/types'
|
||||||
|
|
||||||
|
export type AgentQueryScope = 'active' | 'archived' | 'all'
|
||||||
|
|
||||||
|
export interface Agent extends AgentRow {
|
||||||
|
deployments?: AgentDeploymentRow[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateAgentParams {
|
||||||
|
workspaceId: string
|
||||||
|
name: string
|
||||||
|
description?: string
|
||||||
|
config?: Partial<AgentConfig>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateAgentParams {
|
||||||
|
agentId: string
|
||||||
|
name?: string
|
||||||
|
description?: string
|
||||||
|
config?: Partial<AgentConfig>
|
||||||
|
isDeployed?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeployAgentToSlackParams {
|
||||||
|
agentId: string
|
||||||
|
credentialId: string
|
||||||
|
channelIds: string[]
|
||||||
|
respondTo: SlackDeploymentConfig['respondTo']
|
||||||
|
botName?: string
|
||||||
|
replyInThread?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const agentKeys = {
|
||||||
|
all: ['agents'] as const,
|
||||||
|
lists: () => [...agentKeys.all, 'list'] as const,
|
||||||
|
list: (workspaceId: string, scope: AgentQueryScope = 'active') =>
|
||||||
|
[...agentKeys.lists(), workspaceId, scope] as const,
|
||||||
|
details: () => [...agentKeys.all, 'detail'] as const,
|
||||||
|
detail: (agentId: string) => [...agentKeys.details(), agentId] as const,
|
||||||
|
slackChannels: (credentialId: string) =>
|
||||||
|
[...agentKeys.all, 'slack-channels', credentialId] as const,
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAgents(
|
||||||
|
workspaceId: string,
|
||||||
|
scope: AgentQueryScope,
|
||||||
|
signal?: AbortSignal
|
||||||
|
): Promise<Agent[]> {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/agents?workspaceId=${encodeURIComponent(workspaceId)}&scope=${scope}`,
|
||||||
|
{ signal }
|
||||||
|
)
|
||||||
|
if (!res.ok) {
|
||||||
|
const error = await res.json().catch(() => ({}))
|
||||||
|
throw new Error(error.error || 'Failed to fetch agents')
|
||||||
|
}
|
||||||
|
const json = await res.json()
|
||||||
|
return json.data
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAgent(agentId: string, signal?: AbortSignal): Promise<Agent> {
|
||||||
|
const res = await fetch(`/api/agents/${agentId}`, { signal })
|
||||||
|
if (!res.ok) {
|
||||||
|
const error = await res.json().catch(() => ({}))
|
||||||
|
throw new Error(error.error || 'Failed to fetch agent')
|
||||||
|
}
|
||||||
|
const json = await res.json()
|
||||||
|
return json.data
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all agents for a workspace.
|
||||||
|
*/
|
||||||
|
export function useAgentsList(workspaceId: string, scope: AgentQueryScope = 'active') {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: agentKeys.list(workspaceId, scope),
|
||||||
|
queryFn: ({ signal }) => fetchAgents(workspaceId, scope, signal),
|
||||||
|
enabled: Boolean(workspaceId),
|
||||||
|
staleTime: 60 * 1000,
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a single agent by ID (includes deployments).
|
||||||
|
*/
|
||||||
|
export function useAgent(agentId: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: agentKeys.detail(agentId),
|
||||||
|
queryFn: ({ signal }) => fetchAgent(agentId, signal),
|
||||||
|
enabled: Boolean(agentId),
|
||||||
|
staleTime: 30 * 1000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new agent.
|
||||||
|
*/
|
||||||
|
export function useCreateAgent() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (params: CreateAgentParams): Promise<Agent> => {
|
||||||
|
const res = await fetch('/api/agents', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(params),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const error = await res.json().catch(() => ({}))
|
||||||
|
throw new Error(error.error || 'Failed to create agent')
|
||||||
|
}
|
||||||
|
const json = await res.json()
|
||||||
|
return json.data
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: agentKeys.lists() })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an agent's name, description, or config.
|
||||||
|
*/
|
||||||
|
export function useUpdateAgent() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ agentId, ...body }: UpdateAgentParams): Promise<Agent> => {
|
||||||
|
const res = await fetch(`/api/agents/${agentId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const error = await res.json().catch(() => ({}))
|
||||||
|
throw new Error(error.error || 'Failed to update agent')
|
||||||
|
}
|
||||||
|
const json = await res.json()
|
||||||
|
return json.data
|
||||||
|
},
|
||||||
|
onSettled: (_data, _error, variables) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: agentKeys.lists() })
|
||||||
|
queryClient.invalidateQueries({ queryKey: agentKeys.detail(variables.agentId) })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Soft-delete an agent.
|
||||||
|
*/
|
||||||
|
export function useDeleteAgent() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ agentId }: { agentId: string }): Promise<void> => {
|
||||||
|
const res = await fetch(`/api/agents/${agentId}`, { method: 'DELETE' })
|
||||||
|
if (!res.ok) {
|
||||||
|
const error = await res.json().catch(() => ({}))
|
||||||
|
throw new Error(error.error || 'Failed to delete agent')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: agentKeys.lists() })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure a Slack deployment for an agent.
|
||||||
|
*/
|
||||||
|
export function useDeployAgentToSlack() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({
|
||||||
|
agentId,
|
||||||
|
...body
|
||||||
|
}: DeployAgentToSlackParams): Promise<AgentDeploymentRow> => {
|
||||||
|
const res = await fetch(`/api/agents/${agentId}/deployments/slack`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const error = await res.json().catch(() => ({}))
|
||||||
|
throw new Error(error.error || 'Failed to deploy agent to Slack')
|
||||||
|
}
|
||||||
|
const json = await res.json()
|
||||||
|
return json.data
|
||||||
|
},
|
||||||
|
onSettled: (_data, _error, variables) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: agentKeys.detail(variables.agentId) })
|
||||||
|
queryClient.invalidateQueries({ queryKey: agentKeys.lists() })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SlackChannel {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
isPrivate: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch accessible Slack channels for a given Slack OAuth credential.
|
||||||
|
*/
|
||||||
|
export function useSlackChannels(credentialId: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: agentKeys.slackChannels(credentialId),
|
||||||
|
queryFn: async ({ signal }) => {
|
||||||
|
const res = await fetch('/api/tools/slack/channels', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ credential: credentialId }),
|
||||||
|
signal,
|
||||||
|
})
|
||||||
|
if (!res.ok) return [] as SlackChannel[]
|
||||||
|
const data = (await res.json()) as { channels?: SlackChannel[] }
|
||||||
|
return data.channels ?? []
|
||||||
|
},
|
||||||
|
enabled: Boolean(credentialId),
|
||||||
|
staleTime: 60 * 1000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the Slack deployment from an agent.
|
||||||
|
*/
|
||||||
|
export function useUndeployAgentFromSlack() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ agentId }: { agentId: string }): Promise<void> => {
|
||||||
|
const res = await fetch(`/api/agents/${agentId}/deployments/slack`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const error = await res.json().catch(() => ({}))
|
||||||
|
throw new Error(error.error || 'Failed to undeploy agent from Slack')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSettled: (_data, _error, variables) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: agentKeys.detail(variables.agentId) })
|
||||||
|
queryClient.invalidateQueries({ queryKey: agentKeys.lists() })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
114
apps/sim/lib/agents/execute.ts
Normal file
114
apps/sim/lib/agents/execute.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { createLogger } from '@sim/logger'
|
||||||
|
import { v4 as uuidv4 } from 'uuid'
|
||||||
|
import type { AgentConfig } from '@/lib/agents/types'
|
||||||
|
import type { BlockOutput } from '@/blocks/types'
|
||||||
|
import { BlockType } from '@/executor/constants'
|
||||||
|
import { AgentBlockHandler } from '@/executor/handlers/agent/agent-handler'
|
||||||
|
import type { AgentInputs } from '@/executor/handlers/agent/types'
|
||||||
|
import type { ExecutionContext, StreamingExecution } from '@/executor/types'
|
||||||
|
import type { SerializedBlock } from '@/serializer/types'
|
||||||
|
|
||||||
|
const logger = createLogger('AgentExecutor')
|
||||||
|
|
||||||
|
const handler = new AgentBlockHandler()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal synthetic block that satisfies AgentBlockHandler.canHandle().
|
||||||
|
*/
|
||||||
|
const SYNTHETIC_AGENT_BLOCK: SerializedBlock = {
|
||||||
|
id: 'agent',
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
metadata: { id: BlockType.AGENT },
|
||||||
|
config: { tool: BlockType.AGENT, params: {} },
|
||||||
|
inputs: {},
|
||||||
|
outputs: {},
|
||||||
|
enabled: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExecuteAgentOptions {
|
||||||
|
/** The stored agent configuration */
|
||||||
|
config: AgentConfig
|
||||||
|
/** User message to inject */
|
||||||
|
message: string
|
||||||
|
/** Memory conversation ID (namespaced by caller) */
|
||||||
|
conversationId?: string
|
||||||
|
/** IDs for execution context */
|
||||||
|
agentId: string
|
||||||
|
workspaceId: string
|
||||||
|
userId?: string
|
||||||
|
/** Whether this is an authenticated deployment (API/Slack) vs. UI test */
|
||||||
|
isDeployedContext?: boolean
|
||||||
|
/** AbortSignal for cancellation */
|
||||||
|
abortSignal?: AbortSignal
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executes a standalone agent by constructing an ExecutionContext and
|
||||||
|
* AgentInputs from the stored config, then calling AgentBlockHandler directly.
|
||||||
|
*/
|
||||||
|
export async function executeAgent(
|
||||||
|
options: ExecuteAgentOptions
|
||||||
|
): Promise<BlockOutput | StreamingExecution> {
|
||||||
|
const {
|
||||||
|
config,
|
||||||
|
message,
|
||||||
|
conversationId,
|
||||||
|
agentId,
|
||||||
|
workspaceId,
|
||||||
|
userId,
|
||||||
|
isDeployedContext = false,
|
||||||
|
abortSignal,
|
||||||
|
} = options
|
||||||
|
|
||||||
|
const executionId = uuidv4()
|
||||||
|
|
||||||
|
const ctx: ExecutionContext = {
|
||||||
|
workflowId: agentId,
|
||||||
|
workspaceId,
|
||||||
|
executionId,
|
||||||
|
userId,
|
||||||
|
isDeployedContext,
|
||||||
|
blockStates: new Map(),
|
||||||
|
executedBlocks: new Set(),
|
||||||
|
blockLogs: [],
|
||||||
|
metadata: { duration: 0, startTime: new Date().toISOString() },
|
||||||
|
environmentVariables: {},
|
||||||
|
decisions: { router: new Map(), condition: new Map() },
|
||||||
|
completedLoops: new Set(),
|
||||||
|
activeExecutionPath: new Set(),
|
||||||
|
abortSignal,
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingMessages = config.messages ?? []
|
||||||
|
const messages = [...existingMessages, { role: 'user' as const, content: message }]
|
||||||
|
|
||||||
|
const inputs: AgentInputs = {
|
||||||
|
messages,
|
||||||
|
model: config.model,
|
||||||
|
tools: config.tools,
|
||||||
|
skills: config.skills,
|
||||||
|
memoryType: config.memoryType,
|
||||||
|
conversationId: conversationId ?? config.conversationId,
|
||||||
|
slidingWindowSize: config.slidingWindowSize,
|
||||||
|
slidingWindowTokens: config.slidingWindowTokens,
|
||||||
|
temperature: config.temperature?.toString(),
|
||||||
|
maxTokens: config.maxTokens?.toString(),
|
||||||
|
responseFormat: config.responseFormat,
|
||||||
|
apiKey: config.apiKey,
|
||||||
|
reasoningEffort: config.reasoningEffort,
|
||||||
|
verbosity: config.verbosity,
|
||||||
|
thinkingLevel: config.thinkingLevel,
|
||||||
|
azureEndpoint: config.azureEndpoint,
|
||||||
|
azureApiVersion: config.azureApiVersion,
|
||||||
|
vertexProject: config.vertexProject,
|
||||||
|
vertexLocation: config.vertexLocation,
|
||||||
|
vertexCredential: config.vertexCredential,
|
||||||
|
bedrockAccessKeyId: config.bedrockAccessKeyId,
|
||||||
|
bedrockSecretKey: config.bedrockSecretKey,
|
||||||
|
bedrockRegion: config.bedrockRegion,
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Executing agent ${agentId}`, { executionId, workspaceId, isDeployedContext })
|
||||||
|
|
||||||
|
return handler.execute(ctx, SYNTHETIC_AGENT_BLOCK, inputs)
|
||||||
|
}
|
||||||
100
apps/sim/lib/agents/types.ts
Normal file
100
apps/sim/lib/agents/types.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import type { Message, SkillInput, ToolInput } from '@/executor/handlers/agent/types'
|
||||||
|
|
||||||
|
export type { Message, SkillInput, ToolInput }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Core agent configuration, stored as JSONB in the `agent` table.
|
||||||
|
* Mirrors the subBlock fields of the AgentBlock definition.
|
||||||
|
*/
|
||||||
|
export interface AgentConfig {
|
||||||
|
/** System and user prompt messages */
|
||||||
|
messages?: Message[]
|
||||||
|
/** LLM model identifier */
|
||||||
|
model?: string
|
||||||
|
/** Tools available to the agent */
|
||||||
|
tools?: ToolInput[]
|
||||||
|
/** Skills available to the agent */
|
||||||
|
skills?: SkillInput[]
|
||||||
|
/** Memory strategy */
|
||||||
|
memoryType?: 'none' | 'conversation' | 'sliding_window' | 'sliding_window_tokens'
|
||||||
|
/** Conversation ID for grouping memory records (required for non-none memory types) */
|
||||||
|
conversationId?: string
|
||||||
|
/** Number of messages to retain for sliding_window memory */
|
||||||
|
slidingWindowSize?: string
|
||||||
|
/** Max tokens to retain for sliding_window_tokens memory */
|
||||||
|
slidingWindowTokens?: string
|
||||||
|
/** LLM temperature (0–2) */
|
||||||
|
temperature?: number
|
||||||
|
/** Maximum output tokens */
|
||||||
|
maxTokens?: number
|
||||||
|
/** JSON schema string for structured output */
|
||||||
|
responseFormat?: string
|
||||||
|
/** Per-agent provider API key override (encrypted at rest) */
|
||||||
|
apiKey?: string
|
||||||
|
/** Reasoning effort for supported models */
|
||||||
|
reasoningEffort?: string
|
||||||
|
/** Verbosity for supported models */
|
||||||
|
verbosity?: string
|
||||||
|
/** Thinking level for supported models */
|
||||||
|
thinkingLevel?: string
|
||||||
|
azureEndpoint?: string
|
||||||
|
azureApiVersion?: string
|
||||||
|
vertexProject?: string
|
||||||
|
vertexLocation?: string
|
||||||
|
vertexCredential?: string
|
||||||
|
bedrockAccessKeyId?: string
|
||||||
|
bedrockSecretKey?: string
|
||||||
|
bedrockRegion?: string
|
||||||
|
/** Previous interaction ID for multi-turn deep research follow-ups */
|
||||||
|
previousInteractionId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Slack-specific deployment configuration, stored as JSONB in
|
||||||
|
* the `agent_deployment.config` field when platform = 'slack'.
|
||||||
|
*/
|
||||||
|
export interface SlackDeploymentConfig {
|
||||||
|
/** Slack workspace (team) ID from OAuth */
|
||||||
|
teamId: string
|
||||||
|
/** The bot's Slack user ID (U...) from oauth.v2.access, used for mention stripping */
|
||||||
|
botUserId: string
|
||||||
|
/** Channel IDs the agent listens in (unused when respondTo is 'dm') */
|
||||||
|
channelIds: string[]
|
||||||
|
/** Which events trigger the agent */
|
||||||
|
respondTo: 'mentions' | 'all' | 'threads' | 'dm'
|
||||||
|
/** Optional display name override for the bot (requires chat:write.customize scope) */
|
||||||
|
botName?: string
|
||||||
|
/** Whether to reply inside the thread (true) or in the channel (false). Default true. */
|
||||||
|
replyInThread: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agent row as returned from the API.
|
||||||
|
*/
|
||||||
|
export interface AgentRow {
|
||||||
|
id: string
|
||||||
|
workspaceId: string
|
||||||
|
createdBy: string
|
||||||
|
name: string
|
||||||
|
description?: string | null
|
||||||
|
config: AgentConfig
|
||||||
|
isDeployed: boolean
|
||||||
|
deployedAt?: string | null
|
||||||
|
archivedAt?: string | null
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agent deployment row as returned from the API.
|
||||||
|
*/
|
||||||
|
export interface AgentDeploymentRow {
|
||||||
|
id: string
|
||||||
|
agentId: string
|
||||||
|
platform: 'slack'
|
||||||
|
credentialId?: string | null
|
||||||
|
config: SlackDeploymentConfig
|
||||||
|
isActive: boolean
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
@@ -285,6 +285,7 @@ export const env = createEnv({
|
|||||||
DROPBOX_CLIENT_SECRET: z.string().optional(), // Dropbox OAuth client secret
|
DROPBOX_CLIENT_SECRET: z.string().optional(), // Dropbox OAuth client secret
|
||||||
SLACK_CLIENT_ID: z.string().optional(), // Slack OAuth client ID
|
SLACK_CLIENT_ID: z.string().optional(), // Slack OAuth client ID
|
||||||
SLACK_CLIENT_SECRET: z.string().optional(), // Slack OAuth client secret
|
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_ID: z.string().optional(), // Reddit OAuth client ID
|
||||||
REDDIT_CLIENT_SECRET: z.string().optional(), // Reddit OAuth client secret
|
REDDIT_CLIENT_SECRET: z.string().optional(), // Reddit OAuth client secret
|
||||||
WEBFLOW_CLIENT_ID: z.string().optional(), // Webflow OAuth client ID
|
WEBFLOW_CLIENT_ID: z.string().optional(), // Webflow OAuth client ID
|
||||||
|
|||||||
@@ -669,6 +669,7 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
|
|||||||
'groups:history',
|
'groups:history',
|
||||||
'chat:write',
|
'chat:write',
|
||||||
'chat:write.public',
|
'chat:write.public',
|
||||||
|
'chat:write.customize',
|
||||||
'im:write',
|
'im:write',
|
||||||
'im:history',
|
'im:history',
|
||||||
'im:read',
|
'im:read',
|
||||||
@@ -678,6 +679,10 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
|
|||||||
'files:read',
|
'files:read',
|
||||||
'canvases:write',
|
'canvases:write',
|
||||||
'reactions:write',
|
'reactions:write',
|
||||||
|
// Required for agent event subscriptions (app_mention events, DMs)
|
||||||
|
'app_mentions:read',
|
||||||
|
// Required for conversations.list to return group DMs
|
||||||
|
'mpim:read',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export interface PermissionGroupConfig {
|
|||||||
allowedModelProviders: string[] | null
|
allowedModelProviders: string[] | null
|
||||||
// Platform Configuration
|
// Platform Configuration
|
||||||
hideTraceSpans: boolean
|
hideTraceSpans: boolean
|
||||||
|
hideAgentsTab: boolean
|
||||||
hideKnowledgeBaseTab: boolean
|
hideKnowledgeBaseTab: boolean
|
||||||
hideTablesTab: boolean
|
hideTablesTab: boolean
|
||||||
hideCopilot: boolean
|
hideCopilot: boolean
|
||||||
@@ -27,6 +28,7 @@ export const DEFAULT_PERMISSION_GROUP_CONFIG: PermissionGroupConfig = {
|
|||||||
allowedIntegrations: null,
|
allowedIntegrations: null,
|
||||||
allowedModelProviders: null,
|
allowedModelProviders: null,
|
||||||
hideTraceSpans: false,
|
hideTraceSpans: false,
|
||||||
|
hideAgentsTab: false,
|
||||||
hideKnowledgeBaseTab: false,
|
hideKnowledgeBaseTab: false,
|
||||||
hideTablesTab: false,
|
hideTablesTab: false,
|
||||||
hideCopilot: false,
|
hideCopilot: false,
|
||||||
@@ -57,6 +59,7 @@ export function parsePermissionGroupConfig(config: unknown): PermissionGroupConf
|
|||||||
allowedIntegrations: Array.isArray(c.allowedIntegrations) ? c.allowedIntegrations : null,
|
allowedIntegrations: Array.isArray(c.allowedIntegrations) ? c.allowedIntegrations : null,
|
||||||
allowedModelProviders: Array.isArray(c.allowedModelProviders) ? c.allowedModelProviders : null,
|
allowedModelProviders: Array.isArray(c.allowedModelProviders) ? c.allowedModelProviders : null,
|
||||||
hideTraceSpans: typeof c.hideTraceSpans === 'boolean' ? c.hideTraceSpans : false,
|
hideTraceSpans: typeof c.hideTraceSpans === 'boolean' ? c.hideTraceSpans : false,
|
||||||
|
hideAgentsTab: typeof c.hideAgentsTab === 'boolean' ? c.hideAgentsTab : false,
|
||||||
hideKnowledgeBaseTab:
|
hideKnowledgeBaseTab:
|
||||||
typeof c.hideKnowledgeBaseTab === 'boolean' ? c.hideKnowledgeBaseTab : false,
|
typeof c.hideKnowledgeBaseTab === 'boolean' ? c.hideKnowledgeBaseTab : false,
|
||||||
hideTablesTab: typeof c.hideTablesTab === 'boolean' ? c.hideTablesTab : false,
|
hideTablesTab: typeof c.hideTablesTab === 'boolean' ? c.hideTablesTab : false,
|
||||||
|
|||||||
37
apps/sim/public/logo/wordmark-dark.svg
Normal file
37
apps/sim/public/logo/wordmark-dark.svg
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<svg fill="none" height="22" viewBox="0 0 71 22" width="71" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g transform="scale(.07483)">
|
||||||
|
<path
|
||||||
|
clip-rule="evenodd"
|
||||||
|
d="m142.793 124.175c0 4.75-1.88 9.312-5.216 12.671l-.478.481c-3.334 3.369-7.863 5.252-12.58 5.252h-106.7127c-9.82776 0-17.8063 8.026-17.8063 17.924v115.407c0 9.898 7.97854 17.924 17.8063 17.924h114.5767c9.828 0 17.796-8.026 17.796-17.924v-108.052c0-4.405 1.735-8.632 4.83-11.749 3.086-3.108 7.283-4.856 11.657-4.856h108.5c9.828 0 17.796-8.024 17.796-17.923v-115.4069c0-9.89798-7.968-17.9231-17.796-17.9231h-114.578c-9.827 0-17.795 8.02512-17.795 17.9231zm34.771-99.6079h80.617c5.744 0 10.389 4.6874 10.389 10.463v81.1939c0 5.774-4.645 10.463-10.389 10.463h-80.617c-5.734 0-10.389-4.689-10.389-10.463v-81.1939c0-5.7756 4.655-10.463 10.389-10.463z"
|
||||||
|
fill="#33c482"
|
||||||
|
fill-rule="evenodd"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="m275.293 171.578h-85.187c-10.327 0-18.7 8.432-18.7 18.834v84.75c0 10.402 8.373 18.834 18.7 18.834h85.187c10.328 0 18.701-8.432 18.701-18.834v-84.75c0-10.402-8.373-18.834-18.701-18.834z"
|
||||||
|
fill="#33c482"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="m275.293 171.18h-85.187c-10.327 0-18.7 8.432-18.7 18.834v84.749c0 10.402 8.373 18.833 18.7 18.833h85.187c10.328 0 18.701-8.431 18.701-18.833v-84.749c0-10.402-8.373-18.834-18.701-18.834z"
|
||||||
|
fill="url(#sim-wordmark-gradient)"
|
||||||
|
fill-opacity=".2"
|
||||||
|
/>
|
||||||
|
<defs>
|
||||||
|
<linearGradient
|
||||||
|
id="sim-wordmark-gradient"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
x1="171.406"
|
||||||
|
x2="245.831"
|
||||||
|
y1="171.18"
|
||||||
|
y2="245.428"
|
||||||
|
>
|
||||||
|
<stop offset="0" />
|
||||||
|
<stop offset="1" stop-opacity="0" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</g>
|
||||||
|
<g fill="#111827">
|
||||||
|
<path d="m31.5718 15.845h2.5865c0 .7141.2586 1.2835.7759 1.7081.5173.4053 1.2166.608 2.0979.608.958 0 1.6956-.1834 2.2129-.5501.5173-.386.776-.8975.776-1.5344 0-.4632-.1437-.8492-.4311-1.158-.2682-.3088-.7664-.5597-1.4944-.7527l-2.4716-.579c-1.2453-.3088-2.1745-.7817-2.7876-1.4186-.594-.6369-.8909-1.4765-.8909-2.51873 0-.86852.2203-1.62124.661-2.25815.4598-.63692 1.0825-1.12908 1.868-1.47648.8047-.34741 1.7243-.52112 2.7589-.52112s1.9255.18336 2.6727.55007c.7664.3667 1.3603.87817 1.7818 1.53438.4407.65622.6706 1.43788.6898 2.345h-2.5865c-.0192-.73341-.2587-1.30278-.7185-1.70809-.4598-.4053-1.1017-.60796-1.9255-.60796-.843 0-1.4944.18336-1.9542.55006-.4599.36671-.6898.86852-.6898 1.50544 0 .94568.6898 1.59228 2.0692 1.93968l2.4716.608c1.1878.2702 2.0787.7141 2.6727 1.3317.5939.5983.8909 1.4186.8909 2.4608 0 .8878-.2395 1.6695-.7185 2.345-.479.6562-1.14 1.1677-1.983 1.5344-.8238.3474-1.8009.5211-2.9313.5211-1.6477 0-2.9601-.4053-3.9372-1.2159-.9772-.8106-1.4657-1.8915-1.4657-3.2425z" />
|
||||||
|
<path d="m44.5096 19.956v-14.15687c1.0772.39383 1.5521.39383 2.7014 0v14.15687zm1.322-15.09268c-.479 0-.9005-.1737-1.2645-.52111-.3449-.36671-.5173-.79132-.5173-1.27383 0-.50181.1724-.92642.5173-1.27383.364-.34741.7855-.52111 1.2645-.52111.4981 0 .9196.1737 1.2645.52111s.5173.77202.5173 1.27383c0 .48251-.1724.90712-.5173 1.27383-.3449.34741-.7664.52111-1.2645.52111z" />
|
||||||
|
<path d="m51.976 19.956h-2.7014v-14.15687h2.4141v2.38865c.2873-.79131.843-1.46223 1.6093-1.98334.7855-.54041 1.7339-.81062 2.8452-.81062 1.2453 0 2.2799.33776 3.1038 1.01328.8238.67551 1.3603 1.57298 1.6093 2.69241h-.4885c.1916-1.11943.7184-2.0169 1.5806-2.69241.8622-.67552 1.9255-1.01328 3.19-1.01328 1.6094 0 2.8739.47286 3.7935 1.41858.9197.94573 1.3795 2.23886 1.3795 3.8794v9.2642h-2.644v-8.5983c0-1.1195-.2874-1.97834-.8621-2.57665-.5557-.61761-1.3125-.92642-2.2704-.92642-.6706 0-1.2645.1544-1.7818.46321-.4982.28951-.8909.71412-1.1783 1.27383-.2874.55973-.4311 1.21593-.4311 1.96863v8.3957h-2.6727v-8.6273c0-1.1194-.2778-1.96864-.8334-2.54765-.5556-.59831-1.3124-.89747-2.2704-.89747-.6706 0-1.2645.1544-1.7818.46321-.4981.28951-.8909.71412-1.1783 1.27383-.2874.54038-.4311 1.18698-.4311 1.93968z" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.8 KiB |
37
apps/sim/public/logo/wordmark.svg
Normal file
37
apps/sim/public/logo/wordmark.svg
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<svg fill="none" height="22" viewBox="0 0 71 22" width="71" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g transform="scale(.07483)">
|
||||||
|
<path
|
||||||
|
clip-rule="evenodd"
|
||||||
|
d="m142.793 124.175c0 4.75-1.88 9.312-5.216 12.671l-.478.481c-3.334 3.369-7.863 5.252-12.58 5.252h-106.7127c-9.82776 0-17.8063 8.026-17.8063 17.924v115.407c0 9.898 7.97854 17.924 17.8063 17.924h114.5767c9.828 0 17.796-8.026 17.796-17.924v-108.052c0-4.405 1.735-8.632 4.83-11.749 3.086-3.108 7.283-4.856 11.657-4.856h108.5c9.828 0 17.796-8.024 17.796-17.923v-115.4069c0-9.89798-7.968-17.9231-17.796-17.9231h-114.578c-9.827 0-17.795 8.02512-17.795 17.9231zm34.771-99.6079h80.617c5.744 0 10.389 4.6874 10.389 10.463v81.1939c0 5.774-4.645 10.463-10.389 10.463h-80.617c-5.734 0-10.389-4.689-10.389-10.463v-81.1939c0-5.7756 4.655-10.463 10.389-10.463z"
|
||||||
|
fill="#33c482"
|
||||||
|
fill-rule="evenodd"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="m275.293 171.578h-85.187c-10.327 0-18.7 8.432-18.7 18.834v84.75c0 10.402 8.373 18.834 18.7 18.834h85.187c10.328 0 18.701-8.432 18.701-18.834v-84.75c0-10.402-8.373-18.834-18.701-18.834z"
|
||||||
|
fill="#33c482"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="m275.293 171.18h-85.187c-10.327 0-18.7 8.432-18.7 18.834v84.749c0 10.402 8.373 18.833 18.7 18.833h85.187c10.328 0 18.701-8.431 18.701-18.833v-84.749c0-10.402-8.373-18.834-18.701-18.834z"
|
||||||
|
fill="url(#sim-wordmark-gradient)"
|
||||||
|
fill-opacity=".2"
|
||||||
|
/>
|
||||||
|
<defs>
|
||||||
|
<linearGradient
|
||||||
|
id="sim-wordmark-gradient"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
x1="171.406"
|
||||||
|
x2="245.831"
|
||||||
|
y1="171.18"
|
||||||
|
y2="245.428"
|
||||||
|
>
|
||||||
|
<stop offset="0" />
|
||||||
|
<stop offset="1" stop-opacity="0" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</g>
|
||||||
|
<g fill="#ffffff">
|
||||||
|
<path d="m31.5718 15.845h2.5865c0 .7141.2586 1.2835.7759 1.7081.5173.4053 1.2166.608 2.0979.608.958 0 1.6956-.1834 2.2129-.5501.5173-.386.776-.8975.776-1.5344 0-.4632-.1437-.8492-.4311-1.158-.2682-.3088-.7664-.5597-1.4944-.7527l-2.4716-.579c-1.2453-.3088-2.1745-.7817-2.7876-1.4186-.594-.6369-.8909-1.4765-.8909-2.51873 0-.86852.2203-1.62124.661-2.25815.4598-.63692 1.0825-1.12908 1.868-1.47648.8047-.34741 1.7243-.52112 2.7589-.52112s1.9255.18336 2.6727.55007c.7664.3667 1.3603.87817 1.7818 1.53438.4407.65622.6706 1.43788.6898 2.345h-2.5865c-.0192-.73341-.2587-1.30278-.7185-1.70809-.4598-.4053-1.1017-.60796-1.9255-.60796-.843 0-1.4944.18336-1.9542.55006-.4599.36671-.6898.86852-.6898 1.50544 0 .94568.6898 1.59228 2.0692 1.93968l2.4716.608c1.1878.2702 2.0787.7141 2.6727 1.3317.5939.5983.8909 1.4186.8909 2.4608 0 .8878-.2395 1.6695-.7185 2.345-.479.6562-1.14 1.1677-1.983 1.5344-.8238.3474-1.8009.5211-2.9313.5211-1.6477 0-2.9601-.4053-3.9372-1.2159-.9772-.8106-1.4657-1.8915-1.4657-3.2425z" />
|
||||||
|
<path d="m44.5096 19.956v-14.15687c1.0772.39383 1.5521.39383 2.7014 0v14.15687zm1.322-15.09268c-.479 0-.9005-.1737-1.2645-.52111-.3449-.36671-.5173-.79132-.5173-1.27383 0-.50181.1724-.92642.5173-1.27383.364-.34741.7855-.52111 1.2645-.52111.4981 0 .9196.1737 1.2645.52111s.5173.77202.5173 1.27383c0 .48251-.1724.90712-.5173 1.27383-.3449.34741-.7664.52111-1.2645.52111z" />
|
||||||
|
<path d="m51.976 19.956h-2.7014v-14.15687h2.4141v2.38865c.2873-.79131.843-1.46223 1.6093-1.98334.7855-.54041 1.7339-.81062 2.8452-.81062 1.2453 0 2.2799.33776 3.1038 1.01328.8238.67551 1.3603 1.57298 1.6093 2.69241h-.4885c.1916-1.11943.7184-2.0169 1.5806-2.69241.8622-.67552 1.9255-1.01328 3.19-1.01328 1.6094 0 2.8739.47286 3.7935 1.41858.9197.94573 1.3795 2.23886 1.3795 3.8794v9.2642h-2.644v-8.5983c0-1.1195-.2874-1.97834-.8621-2.57665-.5557-.61761-1.3125-.92642-2.2704-.92642-.6706 0-1.2645.1544-1.7818.46321-.4982.28951-.8909.71412-1.1783 1.27383-.2874.55973-.4311 1.21593-.4311 1.96863v8.3957h-2.6727v-8.6273c0-1.1194-.2778-1.96864-.8334-2.54765-.5556-.59831-1.3124-.89747-2.2704-.89747-.6706 0-1.2645.1544-1.7818.46321-.4981.28951-.8909.71412-1.1783 1.27383-.2874.54038-.4311 1.18698-.4311 1.93968z" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.8 KiB |
51
packages/db/migrations/0181_wide_morlocks.sql
Normal file
51
packages/db/migrations/0181_wide_morlocks.sql
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
CREATE TYPE "public"."agent_platform" AS ENUM('slack');--> statement-breakpoint
|
||||||
|
CREATE TABLE "agent" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"workspace_id" text NOT NULL,
|
||||||
|
"created_by" text NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"description" text,
|
||||||
|
"config" jsonb DEFAULT '{}' NOT NULL,
|
||||||
|
"is_deployed" boolean DEFAULT false NOT NULL,
|
||||||
|
"deployed_at" timestamp,
|
||||||
|
"archived_at" timestamp,
|
||||||
|
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "agent_conversation" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"agent_id" text NOT NULL,
|
||||||
|
"platform" "agent_platform" NOT NULL,
|
||||||
|
"external_id" text NOT NULL,
|
||||||
|
"conversation_id" text NOT NULL,
|
||||||
|
"metadata" jsonb DEFAULT '{}' NOT NULL,
|
||||||
|
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "agent_deployment" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"agent_id" text NOT NULL,
|
||||||
|
"platform" "agent_platform" NOT NULL,
|
||||||
|
"credential_id" text,
|
||||||
|
"config" jsonb DEFAULT '{}' NOT NULL,
|
||||||
|
"is_active" boolean DEFAULT false NOT NULL,
|
||||||
|
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "agent" ADD CONSTRAINT "agent_workspace_id_workspace_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspace"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "agent" ADD CONSTRAINT "agent_created_by_user_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "agent_conversation" ADD CONSTRAINT "agent_conversation_agent_id_agent_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."agent"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "agent_deployment" ADD CONSTRAINT "agent_deployment_agent_id_agent_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."agent"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "agent_deployment" ADD CONSTRAINT "agent_deployment_credential_id_credential_id_fk" FOREIGN KEY ("credential_id") REFERENCES "public"."credential"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX "agent_workspace_id_idx" ON "agent" USING btree ("workspace_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "agent_created_by_idx" ON "agent" USING btree ("created_by");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "agent_workspace_name_unique" ON "agent" USING btree ("workspace_id","name") WHERE "agent"."archived_at" IS NULL;--> statement-breakpoint
|
||||||
|
CREATE INDEX "agent_conversation_agent_id_idx" ON "agent_conversation" USING btree ("agent_id");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "agent_conversation_unique" ON "agent_conversation" USING btree ("agent_id","platform","external_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "agent_deployment_agent_id_idx" ON "agent_deployment" USING btree ("agent_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "agent_deployment_platform_idx" ON "agent_deployment" USING btree ("platform");--> statement-breakpoint
|
||||||
|
CREATE INDEX "agent_deployment_credential_id_idx" ON "agent_deployment" USING btree ("credential_id");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "agent_deployment_agent_platform_unique" ON "agent_deployment" USING btree ("agent_id","platform");
|
||||||
14860
packages/db/migrations/meta/0181_snapshot.json
Normal file
14860
packages/db/migrations/meta/0181_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1261,6 +1261,13 @@
|
|||||||
"when": 1774305252055,
|
"when": 1774305252055,
|
||||||
"tag": "0180_amused_marvel_boy",
|
"tag": "0180_amused_marvel_boy",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 181,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1774336258575,
|
||||||
|
"tag": "0181_wide_morlocks",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2863,3 +2863,107 @@ export const mothershipInboxWebhook = pgTable('mothership_inbox_webhook', {
|
|||||||
secret: text('secret').notNull(),
|
secret: text('secret').notNull(),
|
||||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Agents - Standalone agent registry
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export const agentPlatformEnum = pgEnum('agent_platform', ['slack'])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agents - Standalone AI agents with their own configuration,
|
||||||
|
* separate from workflow blocks. Can be deployed to Slack and
|
||||||
|
* exposed as REST APIs.
|
||||||
|
*/
|
||||||
|
export const agent = pgTable(
|
||||||
|
'agent',
|
||||||
|
{
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
workspaceId: text('workspace_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => workspace.id, { onDelete: 'cascade' }),
|
||||||
|
createdBy: text('created_by')
|
||||||
|
.notNull()
|
||||||
|
.references(() => user.id, { onDelete: 'cascade' }),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
description: text('description'),
|
||||||
|
/** Agent configuration: model, tools, skills, memory, prompts, etc. */
|
||||||
|
config: jsonb('config').notNull().default('{}'),
|
||||||
|
/** Whether the agent is deployed to at least one platform or API */
|
||||||
|
isDeployed: boolean('is_deployed').notNull().default(false),
|
||||||
|
deployedAt: timestamp('deployed_at'),
|
||||||
|
archivedAt: timestamp('archived_at'),
|
||||||
|
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp('updated_at').notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
workspaceIdIdx: index('agent_workspace_id_idx').on(table.workspaceId),
|
||||||
|
createdByIdx: index('agent_created_by_idx').on(table.createdBy),
|
||||||
|
uniqueNamePerWorkspace: uniqueIndex('agent_workspace_name_unique')
|
||||||
|
.on(table.workspaceId, table.name)
|
||||||
|
.where(sql`${table.archivedAt} IS NULL`),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agent Deployments - Per-platform deployment configuration for an agent.
|
||||||
|
* One row per (agent, platform) pair.
|
||||||
|
*/
|
||||||
|
export const agentDeployment = pgTable(
|
||||||
|
'agent_deployment',
|
||||||
|
{
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
agentId: text('agent_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => agent.id, { onDelete: 'cascade' }),
|
||||||
|
platform: agentPlatformEnum('platform').notNull(),
|
||||||
|
/** OAuth credential used for this deployment (e.g. Slack bot token) */
|
||||||
|
credentialId: text('credential_id').references(() => credential.id, {
|
||||||
|
onDelete: 'set null',
|
||||||
|
}),
|
||||||
|
/** Platform-specific config (teamId, channelIds, respondTo, etc.) */
|
||||||
|
config: jsonb('config').notNull().default('{}'),
|
||||||
|
isActive: boolean('is_active').notNull().default(false),
|
||||||
|
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp('updated_at').notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
agentIdIdx: index('agent_deployment_agent_id_idx').on(table.agentId),
|
||||||
|
platformIdx: index('agent_deployment_platform_idx').on(table.platform),
|
||||||
|
credentialIdx: index('agent_deployment_credential_id_idx').on(table.credentialId),
|
||||||
|
uniqueAgentPlatform: uniqueIndex('agent_deployment_agent_platform_unique').on(
|
||||||
|
table.agentId,
|
||||||
|
table.platform
|
||||||
|
),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agent Conversations - Maps platform-specific conversation identifiers
|
||||||
|
* (Slack thread, DM, channel) to memory keys in the memory table.
|
||||||
|
*/
|
||||||
|
export const agentConversation = pgTable(
|
||||||
|
'agent_conversation',
|
||||||
|
{
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
agentId: text('agent_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => agent.id, { onDelete: 'cascade' }),
|
||||||
|
platform: agentPlatformEnum('platform').notNull(),
|
||||||
|
/** External identifier: channel_id, thread_ts, DM id, or API conversation ID */
|
||||||
|
externalId: text('external_id').notNull(),
|
||||||
|
/** Corresponds to memory.key in the memory table */
|
||||||
|
conversationId: text('conversation_id').notNull(),
|
||||||
|
metadata: jsonb('metadata').notNull().default('{}'),
|
||||||
|
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp('updated_at').notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
agentIdIdx: index('agent_conversation_agent_id_idx').on(table.agentId),
|
||||||
|
uniqueConversation: uniqueIndex('agent_conversation_unique').on(
|
||||||
|
table.agentId,
|
||||||
|
table.platform,
|
||||||
|
table.externalId
|
||||||
|
),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|||||||
97
scripts/ban-spam-accounts.ts
Normal file
97
scripts/ban-spam-accounts.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
/**
|
||||||
|
* Script to find and delete spam accounts matching a pattern.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* DATABASE_URL="postgres://..." bun run scripts/ban-spam-accounts.ts [--dry-run] [--pattern @sharebot.net]
|
||||||
|
*
|
||||||
|
* Options:
|
||||||
|
* --dry-run List matching accounts without deleting (default behavior)
|
||||||
|
* --execute Actually delete the accounts
|
||||||
|
* --pattern Email domain/pattern to match (default: @sharebot.net)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import postgres from 'postgres'
|
||||||
|
|
||||||
|
const args = process.argv.slice(2)
|
||||||
|
const dryRun = !args.includes('--execute')
|
||||||
|
const patternFlag = args.indexOf('--pattern')
|
||||||
|
const pattern = patternFlag !== -1 ? args[patternFlag + 1] : '@vapu.xyz'
|
||||||
|
|
||||||
|
const DATABASE_URL = process.env.DATABASE_URL
|
||||||
|
if (!DATABASE_URL) {
|
||||||
|
console.error('ERROR: DATABASE_URL environment variable is required')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const sql = postgres(DATABASE_URL, { ssl: 'require' })
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log(`\n🔍 Searching for spam accounts matching: *${pattern}`)
|
||||||
|
console.log(
|
||||||
|
` Mode: ${dryRun ? 'DRY RUN (use --execute to delete)' : '⚠️ EXECUTE MODE - accounts will be deleted'}\n`
|
||||||
|
)
|
||||||
|
|
||||||
|
// Find all matching users
|
||||||
|
const spamUsers = await sql`
|
||||||
|
SELECT u.id, u.name, u.email, u."created_at"
|
||||||
|
FROM "user" u
|
||||||
|
WHERE u.email LIKE ${'%' + pattern}
|
||||||
|
ORDER BY u."created_at" DESC
|
||||||
|
`
|
||||||
|
|
||||||
|
if (spamUsers.length === 0) {
|
||||||
|
console.log('No matching accounts found.')
|
||||||
|
await sql.end()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Found ${spamUsers.length} matching accounts:\n`)
|
||||||
|
|
||||||
|
// Show account details with their workflow/execution counts
|
||||||
|
for (const user of spamUsers) {
|
||||||
|
const [stats] = await sql`
|
||||||
|
SELECT
|
||||||
|
(SELECT COUNT(*) FROM workflow WHERE user_id = ${user.id}) as workflow_count,
|
||||||
|
(SELECT COUNT(*) FROM workspace WHERE owner_id = ${user.id}) as workspace_count
|
||||||
|
`
|
||||||
|
console.log(` ${user.email}`)
|
||||||
|
console.log(` ID: ${user.id} | Created: ${user.created_at}`)
|
||||||
|
console.log(` Workspaces: ${stats.workspace_count} | Workflows: ${stats.workflow_count}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dryRun) {
|
||||||
|
console.log(`\n📋 Dry run complete. ${spamUsers.length} accounts would be deleted.`)
|
||||||
|
console.log(' Run with --execute to delete these accounts.')
|
||||||
|
await sql.end()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute deletion
|
||||||
|
console.log(`\n⚠️ Deleting ${spamUsers.length} accounts...`)
|
||||||
|
|
||||||
|
const userIds = spamUsers.map((u: { id: string }) => u.id)
|
||||||
|
|
||||||
|
// Delete workspaces first to handle the billedAccountUserId no-action FK
|
||||||
|
const deletedWorkspaces = await sql`
|
||||||
|
DELETE FROM workspace WHERE owner_id = ANY(${userIds}::text[])
|
||||||
|
`
|
||||||
|
console.log(
|
||||||
|
` Deleted ${deletedWorkspaces.count} workspaces (cascades: workflows, execution logs, etc.)`
|
||||||
|
)
|
||||||
|
|
||||||
|
// Now delete the users (cascades: sessions, accounts, credentials, etc.)
|
||||||
|
const deletedUsers = await sql`
|
||||||
|
DELETE FROM "user" WHERE id = ANY(${userIds}::text[])
|
||||||
|
`
|
||||||
|
console.log(` Deleted ${deletedUsers.count} user accounts`)
|
||||||
|
|
||||||
|
console.log(`\n✅ Done. ${deletedUsers.count} spam accounts removed.`)
|
||||||
|
|
||||||
|
await sql.end()
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(async (err) => {
|
||||||
|
console.error('Error:', err)
|
||||||
|
await sql.end()
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user