mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
Compare commits
55 Commits
feat/agent
...
v0.6.10
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b572f1f61 | ||
|
|
b497033795 | ||
|
|
666dc67aa2 | ||
|
|
7af7a225f2 | ||
|
|
228578e282 | ||
|
|
be647469ac | ||
|
|
96b171cf74 | ||
|
|
cdea2404e3 | ||
|
|
ed9a71f0af | ||
|
|
f6975fc0a3 | ||
|
|
59182d5db2 | ||
|
|
b9926df8e0 | ||
|
|
77eafabb63 | ||
|
|
34ea99e99d | ||
|
|
a7f344bca1 | ||
|
|
7b6149dc23 | ||
|
|
b09a073c29 | ||
|
|
8d93c850ba | ||
|
|
83eb3ed211 | ||
|
|
a783b9d4ce | ||
|
|
c78c870fda | ||
|
|
0c80438ede | ||
|
|
41a7d247ea | ||
|
|
092525e8aa | ||
|
|
19442f19e2 | ||
|
|
1731a4d7f0 | ||
|
|
9fcd02fd3b | ||
|
|
ff7b5b528c | ||
|
|
30f2d1a0fc | ||
|
|
4bd0731871 | ||
|
|
4f3bc37fe4 | ||
|
|
84d6fdc423 | ||
|
|
4c12914d35 | ||
|
|
e9bdc57616 | ||
|
|
36612ae42a | ||
|
|
1c2c2c65d4 | ||
|
|
ecd3536a72 | ||
|
|
8c0a2e04b1 | ||
|
|
6586c5ce40 | ||
|
|
3ce947566d | ||
|
|
70c36cb7aa | ||
|
|
f1ec5fe824 | ||
|
|
e07e3c34cc | ||
|
|
0d2e6ff31d | ||
|
|
4fd0989264 | ||
|
|
67f8a687f6 | ||
|
|
af592349d3 | ||
|
|
0d86ea01f0 | ||
|
|
115f04e989 | ||
|
|
34d92fae89 | ||
|
|
67aa4bb332 | ||
|
|
15ace5e63f | ||
|
|
fdca73679d | ||
|
|
da46a387c9 | ||
|
|
b7e377ec4b |
63
README.md
63
README.md
@@ -1,20 +1,16 @@
|
||||
<p align="center">
|
||||
<a href="https://sim.ai" target="_blank" rel="noopener noreferrer">
|
||||
<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>
|
||||
<img src="apps/sim/public/logo/reverse/text/large.png" alt="Sim Logo" width="500"/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">The open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to orchestrate agentic workflows.</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://sim.ai" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/badge/sim.ai-33c482" alt="Sim.ai"></a>
|
||||
<a href="https://sim.ai" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/badge/sim.ai-6F3DFA" alt="Sim.ai"></a>
|
||||
<a href="https://discord.gg/Hr4UWYEcTT" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/badge/Discord-Join%20Server-5865F2?logo=discord&logoColor=white" alt="Discord"></a>
|
||||
<a href="https://x.com/simdotai" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/twitter/follow/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-33c482.svg" alt="Documentation"></a>
|
||||
<a href="https://docs.sim.ai" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/badge/Docs-6F3DFA.svg" alt="Documentation"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -46,7 +42,7 @@ Upload documents to a vector store and let agents answer questions grounded in y
|
||||
|
||||
### Cloud-hosted: [sim.ai](https://sim.ai)
|
||||
|
||||
<a href="https://sim.ai" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/badge/sim.ai-33c482?logo=data:image/svg%2bxml;base64,PHN2ZyB3aWR0aD0iNjE2IiBoZWlnaHQ9IjYxNiIgdmlld0JveD0iMCAwIDYxNiA2MTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMF8xMTU5XzMxMykiPgo8cGF0aCBkPSJNNjE2IDBIMFY2MTZINjE2VjBaIiBmaWxsPSIjMzNjNDgyIi8+CjxwYXRoIGQ9Ik04MyAzNjUuNTY3SDExM0MxMTMgMzczLjgwNSAxMTYgMzgwLjM3MyAxMjIgMzg1LjI3MkMxMjggMzg5Ljk0OCAxMzYuMTExIDM5Mi4yODUgMTQ2LjMzMyAzOTIuMjg1QzE1Ny40NDQgMzkyLjI4NSAxNjYgMzkwLjE3MSAxNzIgMzg1LjkzOUMxNzcuOTk5IDM4MS40ODcgMTgxIDM3NS41ODYgMTgxIDM2OC4yMzlDMTgxIDM2Mi44OTUgMTc5LjMzMyAzNTguNDQyIDE3NiAzNTQuODhDMTcyLjg4OSAzNTEuMzE4IDE2Ny4xMTEgMzQ4LjQyMiAxNTguNjY3IDM0Ni4xOTZMMTMwIDMzOS41MTdDMTE1LjU1NSAzMzUuOTU1IDEwNC43NzggMzMwLjQ5OSA5Ny42NjY1IDMyMy4xNTFDOTAuNzc3NSAzMTUuODA0IDg3LjMzMzQgMzA2LjExOSA4Ny4zMzM0IDI5NC4wOTZDODcuMzMzNCAyODQuMDc2IDg5Ljg4OSAyNzUuMzkyIDk0Ljk5OTYgMjY4LjA0NUMxMDAuMzMzIDI2MC42OTcgMTA3LjU1NSAyNTUuMDIgMTE2LjY2NiAyNTEuMDEyQzEyNiAyNDcuMDA0IDEzNi42NjcgMjQ1IDE0OC42NjYgMjQ1QzE2MC42NjcgMjQ1IDE3MSAyNDcuMTE2IDE3OS42NjcgMjUxLjM0NkMxODguNTU1IDI1NS41NzYgMTk1LjQ0NCAyNjEuNDc3IDIwMC4zMzMgMjY5LjA0N0MyMDUuNDQ0IDI3Ni42MTcgMjA4LjExMSAyODUuNjM0IDIwOC4zMzMgMjk2LjA5OUgxNzguMzMzQzE3OC4xMTEgMjg3LjYzOCAxNzUuMzMzIDI4MS4wNyAxNjkuOTk5IDI3Ni4zOTRDMTY0LjY2NiAyNzEuNzE5IDE1Ny4yMjIgMjY5LjM4MSAxNDcuNjY3IDI2OS4zODFDMTM3Ljg4OSAyNjkuMzgxIDEzMC4zMzMgMjcxLjQ5NiAxMjUgMjc1LjcyNkMxMTkuNjY2IDI3OS45NTcgMTE3IDI4NS43NDYgMTE3IDI5My4wOTNDMTE3IDMwNC4wMDMgMTI1IDMxMS40NjIgMTQxIDMxNS40N0wxNjkuNjY3IDMyMi40ODNDMTgzLjQ0NSAzMjUuNiAxOTMuNzc4IDMzMC43MjIgMjAwLjY2NyAzMzcuODQ3QzIwNy41NTUgMzQ0Ljc0OSAyMTEgMzU0LjIxMiAyMTEgMzY2LjIzNUMyMTEgMzc2LjQ3NyAyMDguMjIyIDM4NS40OTQgMjAyLjY2NiAzOTMuMjg3QzE5Ny4xMTEgNDAwLjg1NyAxODkuNDQ0IDQwNi43NTggMTc5LjY2NyA0MTAuOTg5QzE3MC4xMTEgNDE0Ljk5NiAxNTguNzc4IDQxNyAxNDUuNjY3IDQxN0MxMjYuNTU1IDQxNyAxMTEuMzMzIDQxMi4zMjUgOTkuOTk5NyA0MDIuOTczQzg4LjY2NjggMzkzLjYyMSA4MyAzODEuMTUzIDgzIDM2NS41NjdaIiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBkPSJNMjMyLjI5MSA0MTNWMjUwLjA4MkMyNDQuNjg0IDI1NC42MTQgMjUwLjE0OCAyNTQuNjE0IDI2My4zNzEgMjUwLjA4MlY0MTNIMjMyLjI5MVpNMjQ3LjUgMjM5LjMxM0MyNDEuOTkgMjM5LjMxMyAyMzcuMTQgMjM3LjMxMyAyMzIuOTUyIDIzMy4zMTZDMjI4Ljk4NCAyMjkuMDk1IDIyNyAyMjQuMjA5IDIyNyAyMTguNjU2QzIyNyAyMTIuODgyIDIyOC45ODQgMjA3Ljk5NSAyMzIuOTUyIDIwMy45OTdDMjM3LjE0IDE5OS45OTkgMjQxLjk5IDE5OCAyNDcuNSAxOThDMjUzLjIzMSAxOTggMjU4LjA4IDE5OS45OTkgMjYyLjA0OSAyMDMuOTk3QzI2Ni4wMTYgMjA3Ljk5NSAyNjggMjEyLjg4MiAyNjggMjE4LjY1NkMyNjggMjI0LjIwOSAyNjYuMDE2IDIyOS4wOTUgMjYyLjA0OSAyMzMuMzE2QzI1OC4wOCAyMzcuMzEzIDI1My4yMzEgMjM5LjMxMyAyNDcuNSAyMzkuMzEzWiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZD0iTTMxOS4zMzMgNDEzSDI4OFYyNDkuNjc2SDMxNlYyNzcuMjMzQzMxOS4zMzMgMjY4LjEwNCAzMjUuNzc4IDI2MC4zNjQgMzM0LjY2NyAyNTQuMzUyQzM0My43NzggMjQ4LjExNyAzNTQuNzc4IDI0NSAzNjcuNjY3IDI0NUMzODIuMTExIDI0NSAzOTQuMTEyIDI0OC44OTcgNDAzLjY2NyAyNTYuNjlDNDEzLjIyMiAyNjQuNDg0IDQxOS40NDQgMjc0LjgzNyA0MjIuMzM0IDI4Ny43NTJINDE2LjY2N0M0MTguODg5IDI3NC44MzcgNDI1IDI2NC40ODQgNDM1IDI1Ni42OUM0NDUgMjQ4Ljg5NyA0NTcuMzM0IDI0NSA0NzIgMjQ1QzQ5MC42NjYgMjQ1IDUwNS4zMzQgMjUwLjQ1NSA1MTYgMjYxLjM2NkM1MjYuNjY3IDI3Mi4yNzYgNTMyIDI4Ny4xOTUgNTMyIDMwNi4xMjFWNDEzSDUwMS4zMzNWMzEzLjgwNEM1MDEuMzMzIDMwMC44ODkgNDk4IDI5MC45ODEgNDkxLjMzMyAyODQuMDc4QzQ4NC44ODkgMjc2Ljk1MiA0NzYuMTExIDI3My4zOSA0NjUgMjczLjM5QzQ1Ny4yMjIgMjczLjM5IDQ1MC4zMzMgMjc1LjE3MSA0NDQuMzM0IDI3OC43MzRDNDM4LjU1NiAyODIuMDc0IDQzNCAyODYuOTcyIDQzMC42NjcgMjkzLjQzQzQyNy4zMzMgMjk5Ljg4NyA0MjUuNjY3IDMwNy40NTcgNDI1LjY2NyAzMTYuMTQxVjQxM0gzOTQuNjY3VjMxMy40NjlDMzk0LjY2NyAzMDAuNTU1IDM5MS40NDUgMjkwLjc1OCAzODUgMjg0LjA3OEMzNzguNTU2IDI3Ny4xNzUgMzY5Ljc3OCAyNzMuNzI0IDM1OC42NjcgMjczLjcyNEMzNTAuODg5IDI3My43MjQgMzQ0IDI3NS41MDUgMzM4IDI3OS4wNjhDMzMyLjIyMiAyODIuNDA4IDMyNy42NjcgMjg3LjMwNyAzMjQuMzMzIDI5My43NjNDMzIxIDI5OS45OTggMzE5LjMzMyAzMDcuNDU3IDMxOS4zMzMgMzE2LjE0MVY0MTNaIiBmaWxsPSJ3aGl0ZSIvPgo8L2c+CjxkZWZzPgo8Y2xpcFBhdGggaWQ9ImNsaXAwXzExNTlfMzEzIj4KPHJlY3Qgd2lkdGg9IjYxNiIgaGVpZ2h0PSI2MTYiIGZpbGw9IndoaXRlIi8+CjwvY2xpcFBhdGg+CjwvZGVmcz4KPC9zdmc+&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-6F3DFA?logo=data:image/svg%2bxml;base64,PHN2ZyB3aWR0aD0iNjE2IiBoZWlnaHQ9IjYxNiIgdmlld0JveD0iMCAwIDYxNiA2MTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMF8xMTU5XzMxMykiPgo8cGF0aCBkPSJNNjE2IDBIMFY2MTZINjE2VjBaIiBmaWxsPSIjNkYzREZBIi8+CjxwYXRoIGQ9Ik04MyAzNjUuNTY3SDExM0MxMTMgMzczLjgwNSAxMTYgMzgwLjM3MyAxMjIgMzg1LjI3MkMxMjggMzg5Ljk0OCAxMzYuMTExIDM5Mi4yODUgMTQ2LjMzMyAzOTIuMjg1QzE1Ny40NDQgMzkyLjI4NSAxNjYgMzkwLjE3MSAxNzIgMzg1LjkzOUMxNzcuOTk5IDM4MS40ODcgMTgxIDM3NS41ODYgMTgxIDM2OC4yMzlDMTgxIDM2Mi44OTUgMTc5LjMzMyAzNTguNDQyIDE3NiAzNTQuODhDMTcyLjg4OSAzNTEuMzE4IDE2Ny4xMTEgMzQ4LjQyMiAxNTguNjY3IDM0Ni4xOTZMMTMwIDMzOS41MTdDMTE1LjU1NSAzMzUuOTU1IDEwNC43NzggMzMwLjQ5OSA5Ny42NjY1IDMyMy4xNTFDOTAuNzc3NSAzMTUuODA0IDg3LjMzMzQgMzA2LjExOSA4Ny4zMzM0IDI5NC4wOTZDODcuMzMzNCAyODQuMDc2IDg5Ljg4OSAyNzUuMzkyIDk0Ljk5OTYgMjY4LjA0NUMxMDAuMzMzIDI2MC42OTcgMTA3LjU1NSAyNTUuMDIgMTE2LjY2NiAyNTEuMDEyQzEyNiAyNDcuMDA0IDEzNi42NjcgMjQ1IDE0OC42NjYgMjQ1QzE2MC42NjcgMjQ1IDE3MSAyNDcuMTE2IDE3OS42NjcgMjUxLjM0NkMxODguNTU1IDI1NS41NzYgMTk1LjQ0NCAyNjEuNDc3IDIwMC4zMzMgMjY5LjA0N0MyMDUuNDQ0IDI3Ni42MTcgMjA4LjExMSAyODUuNjM0IDIwOC4zMzMgMjk2LjA5OUgxNzguMzMzQzE3OC4xMTEgMjg3LjYzOCAxNzUuMzMzIDI4MS4wNyAxNjkuOTk5IDI3Ni4zOTRDMTY0LjY2NiAyNzEuNzE5IDE1Ny4yMjIgMjY5LjM4MSAxNDcuNjY3IDI2OS4zODFDMTM3Ljg4OSAyNjkuMzgxIDEzMC4zMzMgMjcxLjQ5NiAxMjUgMjc1LjcyNkMxMTkuNjY2IDI3OS45NTcgMTE3IDI4NS43NDYgMTE3IDI5My4wOTNDMTE3IDMwNC4wMDMgMTI1IDMxMS40NjIgMTQxIDMxNS40N0wxNjkuNjY3IDMyMi40ODNDMTgzLjQ0NSAzMjUuNiAxOTMuNzc4IDMzMC43MjIgMjAwLjY2NyAzMzcuODQ3QzIwNy41NTUgMzQ0Ljc0OSAyMTEgMzU0LjIxMiAyMTEgMzY2LjIzNUMyMTEgMzc2LjQ3NyAyMDguMjIyIDM4NS40OTQgMjAyLjY2NiAzOTMuMjg3QzE5Ny4xMTEgNDAwLjg1NyAxODkuNDQ0IDQwNi43NTggMTc5LjY2NyA0MTAuOTg5QzE3MC4xMTEgNDE0Ljk5NiAxNTguNzc4IDQxNyAxNDUuNjY3IDQxN0MxMjYuNTU1IDQxNyAxMTEuMzMzIDQxMi4zMjUgOTkuOTk5NyA0MDIuOTczQzg4LjY2NjggMzkzLjYyMSA4MyAzODEuMTUzIDgzIDM2NS41NjdaIiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBkPSJNMjMyLjI5MSA0MTNWMjUwLjA4MkMyNDQuNjg0IDI1NC42MTQgMjUwLjE0OCAyNTQuNjE0IDI2My4zNzEgMjUwLjA4MlY0MTNIMjMyLjI5MVpNMjQ3LjUgMjM5LjMxM0MyNDEuOTkgMjM5LjMxMyAyMzcuMTQgMjM3LjMxMyAyMzIuOTUyIDIzMy4zMTZDMjI4Ljk4NCAyMjkuMDk1IDIyNyAyMjQuMjA5IDIyNyAyMTguNjU2QzIyNyAyMTIuODgyIDIyOC45ODQgMjA3Ljk5NSAyMzIuOTUyIDIwMy45OTdDMjM3LjE0IDE5OS45OTkgMjQxLjk5IDE5OCAyNDcuNSAxOThDMjUzLjIzMSAxOTggMjU4LjA4IDE5OS45OTkgMjYyLjA0OSAyMDMuOTk3QzI2Ni4wMTYgMjA3Ljk5NSAyNjggMjEyLjg4MiAyNjggMjE4LjY1NkMyNjggMjI0LjIwOSAyNjYuMDE2IDIyOS4wOTUgMjYyLjA0OSAyMzMuMzE2QzI1OC4wOCAyMzcuMzEzIDI1My4yMzEgMjM5LjMxMyAyNDcuNSAyMzkuMzEzWiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZD0iTTMxOS4zMzMgNDEzSDI4OFYyNDkuNjc2SDMxNlYyNzcuMjMzQzMxOS4zMzMgMjY4LjEwNCAzMjUuNzc4IDI2MC4zNjQgMzM0LjY2NyAyNTQuMzUyQzM0My43NzggMjQ4LjExNyAzNTQuNzc4IDI0NSAzNjcuNjY3IDI0NUMzODIuMTExIDI0NSAzOTQuMTEyIDI0OC44OTcgNDAzLjY2NyAyNTYuNjlDNDEzLjIyMiAyNjQuNDg0IDQxOS40NDQgMjc0LjgzNyA0MjIuMzM0IDI4Ny43NTJINDE2LjY2N0M0MTguODg5IDI3NC44MzcgNDI1IDI2NC40ODQgNDM1IDI1Ni42OUM0NDUgMjQ4Ljg5NyA0NTcuMzM0IDI0NSA0NzIgMjQ1QzQ5MC42NjYgMjQ1IDUwNS4zMzQgMjUwLjQ1NSA1MTYgMjYxLjM2NkM1MjYuNjY3IDI3Mi4yNzYgNTMyIDI4Ny4xOTUgNTMyIDMwNi4xMjFWNDEzSDUwMS4zMzNWMzEzLjgwNEM1MDEuMzMzIDMwMC44ODkgNDk4IDI5MC45ODEgNDkxLjMzMyAyODQuMDc4QzQ4NC44ODkgMjc2Ljk1MiA0NzYuMTExIDI3My4zOSA0NjUgMjczLjM5QzQ1Ny4yMjIgMjczLjM5IDQ1MC4zMzMgMjc1LjE3MSA0NDQuMzM0IDI3OC43MzRDNDM4LjU1NiAyODIuMDc0IDQzNCAyODYuOTcyIDQzMC42NjcgMjkzLjQzQzQyNy4zMzMgMjk5Ljg4NyA0MjUuNjY3IDMwNy40NTcgNDI1LjY2NyAzMTYuMTQxVjQxM0gzOTQuNjY3VjMxMy40NjlDMzk0LjY2NyAzMDAuNTU1IDM5MS40NDUgMjkwLjc1OCAzODUgMjg0LjA3OEMzNzguNTU2IDI3Ny4xNzUgMzY5Ljc3OCAyNzMuNzI0IDM1OC42NjcgMjczLjcyNEMzNTAuODg5IDI3My43MjQgMzQ0IDI3NS41MDUgMzM4IDI3OS4wNjhDMzMyLjIyMiAyODIuNDA4IDMyNy42NjcgMjg3LjMwNyAzMjQuMzMzIDI5My43NjNDMzIxIDI5OS45OTggMzE5LjMzMyAzMDcuNDU3IDMxOS4zMzMgMzE2LjE0MVY0MTNaIiBmaWxsPSJ3aGl0ZSIvPgo8L2c+CjxkZWZzPgo8Y2xpcFBhdGggaWQ9ImNsaXAwXzExNTlfMzEzIj4KPHJlY3Qgd2lkdGg9IjYxNiIgaGVpZ2h0PSI2MTYiIGZpbGw9IndoaXRlIi8+CjwvY2xpcFBhdGg+CjwvZGVmcz4KPC9zdmc+Cg==&logoColor=white" alt="Sim.ai"></a>
|
||||
|
||||
### Self-hosted: NPM Package
|
||||
|
||||
@@ -74,7 +70,43 @@ docker compose -f docker-compose.prod.yml up -d
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000)
|
||||
|
||||
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.
|
||||
#### Using Local Models with Ollama
|
||||
|
||||
Run Sim with local AI models using [Ollama](https://ollama.ai) - no external APIs required:
|
||||
|
||||
```bash
|
||||
# Start with GPU support (automatically downloads gemma3:4b model)
|
||||
docker compose -f docker-compose.ollama.yml --profile setup up -d
|
||||
|
||||
# For CPU-only systems:
|
||||
docker compose -f docker-compose.ollama.yml --profile cpu --profile setup up -d
|
||||
```
|
||||
|
||||
Wait for the model to download, then visit [http://localhost:3000](http://localhost:3000). Add more models with:
|
||||
```bash
|
||||
docker compose -f docker-compose.ollama.yml exec ollama ollama pull llama3.1:8b
|
||||
```
|
||||
|
||||
#### Using an External Ollama Instance
|
||||
|
||||
If Ollama is running on your host machine, use `host.docker.internal` instead of `localhost`:
|
||||
|
||||
```bash
|
||||
OLLAMA_URL=http://host.docker.internal:11434 docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
On Linux, use your host's IP address or add `extra_hosts: ["host.docker.internal:host-gateway"]` to the compose file.
|
||||
|
||||
#### Using vLLM
|
||||
|
||||
Sim supports [vLLM](https://docs.vllm.ai/) for self-hosted models. Set `VLLM_BASE_URL` and optionally `VLLM_API_KEY` in your environment.
|
||||
|
||||
### Self-hosted: Dev Containers
|
||||
|
||||
1. Open VS Code with the [Remote - Containers extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers)
|
||||
2. Open the project and click "Reopen in Container" when prompted
|
||||
3. Run `bun run dev:full` in the terminal or use the `sim-start` alias
|
||||
- This starts both the main application and the realtime socket server
|
||||
|
||||
### Self-hosted: Manual Setup
|
||||
|
||||
@@ -127,7 +159,18 @@ Copilot is a Sim-managed service. To use Copilot on a self-hosted instance:
|
||||
|
||||
## Environment Variables
|
||||
|
||||
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.
|
||||
Key environment variables for self-hosted deployments. See [`.env.example`](apps/sim/.env.example) for defaults or [`env.ts`](apps/sim/lib/core/config/env.ts) for the full list.
|
||||
|
||||
| Variable | Required | Description |
|
||||
|----------|----------|-------------|
|
||||
| `DATABASE_URL` | Yes | PostgreSQL connection string with pgvector |
|
||||
| `BETTER_AUTH_SECRET` | Yes | Auth secret (`openssl rand -hex 32`) |
|
||||
| `BETTER_AUTH_URL` | Yes | Your app URL (e.g., `http://localhost:3000`) |
|
||||
| `NEXT_PUBLIC_APP_URL` | Yes | Public app URL (same as above) |
|
||||
| `ENCRYPTION_KEY` | Yes | Encrypts environment variables (`openssl rand -hex 32`) |
|
||||
| `INTERNAL_API_SECRET` | Yes | Encrypts internal API routes (`openssl rand -hex 32`) |
|
||||
| `API_ENCRYPTION_KEY` | Yes | Encrypts API keys (`openssl rand -hex 32`) |
|
||||
| `COPILOT_API_KEY` | No | API key from sim.ai for Copilot features |
|
||||
|
||||
## Tech Stack
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo, useRef, useState } from 'react'
|
||||
import { Turnstile, type TurnstileInstance } from '@marsidev/react-turnstile'
|
||||
import { useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { Eye, EyeOff } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
@@ -88,8 +87,6 @@ export default function LoginPage({
|
||||
const [passwordErrors, setPasswordErrors] = useState<string[]>([])
|
||||
const [showValidationError, setShowValidationError] = useState(false)
|
||||
const [formError, setFormError] = useState<string | null>(null)
|
||||
const turnstileRef = useRef<TurnstileInstance>(null)
|
||||
const turnstileSiteKey = useMemo(() => getEnv('NEXT_PUBLIC_TURNSTILE_SITE_KEY'), [])
|
||||
const buttonClass = useBrandedButtonClass()
|
||||
|
||||
const callbackUrlParam = searchParams?.get('callbackUrl')
|
||||
@@ -169,20 +166,6 @@ export default function LoginPage({
|
||||
const safeCallbackUrl = callbackUrl
|
||||
let errorHandled = false
|
||||
|
||||
// Execute Turnstile challenge on submit and get a fresh token
|
||||
let token: string | undefined
|
||||
if (turnstileSiteKey && turnstileRef.current) {
|
||||
try {
|
||||
turnstileRef.current.reset()
|
||||
turnstileRef.current.execute()
|
||||
token = await turnstileRef.current.getResponsePromise(15_000)
|
||||
} catch {
|
||||
setFormError('Captcha verification failed. Please try again.')
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
setFormError(null)
|
||||
const result = await client.signIn.email(
|
||||
{
|
||||
@@ -191,11 +174,6 @@ export default function LoginPage({
|
||||
callbackURL: safeCallbackUrl,
|
||||
},
|
||||
{
|
||||
fetchOptions: {
|
||||
headers: {
|
||||
...(token ? { 'x-captcha-response': token } : {}),
|
||||
},
|
||||
},
|
||||
onError: (ctx) => {
|
||||
logger.error('Login error:', ctx.error)
|
||||
|
||||
@@ -464,16 +442,6 @@ export default function LoginPage({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{turnstileSiteKey && (
|
||||
<div className='absolute'>
|
||||
<Turnstile
|
||||
ref={turnstileRef}
|
||||
siteKey={turnstileSiteKey}
|
||||
options={{ size: 'invisible', execution: 'execute' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{resetSuccessMessage && (
|
||||
<div className='text-[#4CAF50] text-xs'>
|
||||
<p>{resetSuccessMessage}</p>
|
||||
|
||||
@@ -93,6 +93,8 @@ function SignupFormContent({
|
||||
const [showEmailValidationError, setShowEmailValidationError] = useState(false)
|
||||
const [formError, setFormError] = useState<string | null>(null)
|
||||
const turnstileRef = useRef<TurnstileInstance>(null)
|
||||
const captchaResolveRef = useRef<((token: string) => void) | null>(null)
|
||||
const captchaRejectRef = useRef<((reason: Error) => void) | null>(null)
|
||||
const turnstileSiteKey = useMemo(() => getEnv('NEXT_PUBLIC_TURNSTILE_SITE_KEY'), [])
|
||||
const buttonClass = useBrandedButtonClass()
|
||||
|
||||
@@ -249,17 +251,30 @@ function SignupFormContent({
|
||||
|
||||
const sanitizedName = trimmedName
|
||||
|
||||
// Execute Turnstile challenge on submit and get a fresh token
|
||||
let token: string | undefined
|
||||
if (turnstileSiteKey && turnstileRef.current) {
|
||||
const widget = turnstileRef.current
|
||||
if (turnstileSiteKey && widget) {
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined
|
||||
try {
|
||||
turnstileRef.current.reset()
|
||||
turnstileRef.current.execute()
|
||||
token = await turnstileRef.current.getResponsePromise(15_000)
|
||||
widget.reset()
|
||||
token = await Promise.race([
|
||||
new Promise<string>((resolve, reject) => {
|
||||
captchaResolveRef.current = resolve
|
||||
captchaRejectRef.current = reject
|
||||
widget.execute()
|
||||
}),
|
||||
new Promise<string>((_, reject) => {
|
||||
timeoutId = setTimeout(() => reject(new Error('Captcha timed out')), 15_000)
|
||||
}),
|
||||
])
|
||||
} catch {
|
||||
setFormError('Captcha verification failed. Please try again.')
|
||||
setIsLoading(false)
|
||||
return
|
||||
} finally {
|
||||
clearTimeout(timeoutId)
|
||||
captchaResolveRef.current = null
|
||||
captchaRejectRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -478,13 +493,14 @@ function SignupFormContent({
|
||||
</div>
|
||||
|
||||
{turnstileSiteKey && (
|
||||
<div className='absolute'>
|
||||
<Turnstile
|
||||
ref={turnstileRef}
|
||||
siteKey={turnstileSiteKey}
|
||||
options={{ size: 'invisible', execution: 'execute' }}
|
||||
/>
|
||||
</div>
|
||||
<Turnstile
|
||||
ref={turnstileRef}
|
||||
siteKey={turnstileSiteKey}
|
||||
onSuccess={(token) => captchaResolveRef.current?.(token)}
|
||||
onError={() => captchaRejectRef.current?.(new Error('Captcha verification failed'))}
|
||||
onExpire={() => captchaRejectRef.current?.(new Error('Captcha token expired'))}
|
||||
options={{ execution: 'execute' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{formError && (
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { GithubOutlineIcon } from '@/components/icons'
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import {
|
||||
BlogDropdown,
|
||||
@@ -40,6 +42,12 @@ interface NavbarProps {
|
||||
|
||||
export default function Navbar({ logoOnly = false, blogPosts = [] }: NavbarProps) {
|
||||
const brand = getBrandConfig()
|
||||
const searchParams = useSearchParams()
|
||||
const { data: session, isPending: isSessionPending } = useSession()
|
||||
const isAuthenticated = Boolean(session?.user?.id)
|
||||
const isBrowsingHome = searchParams.has('home')
|
||||
const useHomeLinks = isAuthenticated || isBrowsingHome
|
||||
const logoHref = useHomeLinks ? '/?home' : '/'
|
||||
const [activeDropdown, setActiveDropdown] = useState<DropdownId>(null)
|
||||
const [hoveredLink, setHoveredLink] = useState<string | null>(null)
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||
@@ -92,7 +100,7 @@ export default function Navbar({ logoOnly = false, blogPosts = [] }: NavbarProps
|
||||
itemScope
|
||||
itemType='https://schema.org/SiteNavigationElement'
|
||||
>
|
||||
<Link href='/' className={LOGO_CELL} aria-label={`${brand.name} home`} itemProp='url'>
|
||||
<Link href={logoHref} className={LOGO_CELL} aria-label={`${brand.name} home`} itemProp='url'>
|
||||
<span itemProp='name' className='sr-only'>
|
||||
{brand.name}
|
||||
</span>
|
||||
@@ -121,7 +129,9 @@ export default function Navbar({ logoOnly = false, blogPosts = [] }: NavbarProps
|
||||
{!logoOnly && (
|
||||
<>
|
||||
<ul className='mt-[0.75px] hidden lg:flex'>
|
||||
{NAV_LINKS.map(({ label, href, external, icon, dropdown }) => {
|
||||
{NAV_LINKS.map(({ label, href: rawHref, external, icon, dropdown }) => {
|
||||
const href =
|
||||
useHomeLinks && rawHref.startsWith('/#') ? `/?home${rawHref.slice(1)}` : rawHref
|
||||
const hasDropdown = !!dropdown
|
||||
const isActive = hasDropdown && activeDropdown === dropdown
|
||||
const isThisHovered = hoveredLink === label
|
||||
@@ -206,21 +216,38 @@ export default function Navbar({ logoOnly = false, blogPosts = [] }: NavbarProps
|
||||
|
||||
<div className='hidden flex-1 lg:block' />
|
||||
|
||||
<div className='hidden items-center gap-[8px] pr-[80px] pl-[20px] lg:flex'>
|
||||
<Link
|
||||
href='/login'
|
||||
className='inline-flex h-[30px] items-center rounded-[5px] border border-[#3d3d3d] px-[9px] text-[#ECECEC] text-[13.5px] transition-colors hover:bg-[#2A2A2A]'
|
||||
aria-label='Log in'
|
||||
>
|
||||
Log in
|
||||
</Link>
|
||||
<Link
|
||||
href='/signup'
|
||||
className='inline-flex h-[30px] items-center gap-[7px] rounded-[5px] border border-[#FFFFFF] bg-[#FFFFFF] px-[9px] text-[13.5px] text-black transition-colors hover:border-[#E0E0E0] hover:bg-[#E0E0E0]'
|
||||
aria-label='Get started with Sim'
|
||||
>
|
||||
Get started
|
||||
</Link>
|
||||
<div
|
||||
className={cn(
|
||||
'hidden items-center gap-[8px] pr-[80px] pl-[20px] lg:flex',
|
||||
isSessionPending && 'invisible'
|
||||
)}
|
||||
>
|
||||
{isAuthenticated ? (
|
||||
<Link
|
||||
href='/workspace'
|
||||
className='inline-flex h-[30px] items-center gap-[7px] rounded-[5px] border border-[#FFFFFF] bg-[#FFFFFF] px-[9px] text-[13.5px] text-black transition-colors hover:border-[#E0E0E0] hover:bg-[#E0E0E0]'
|
||||
aria-label='Go to app'
|
||||
>
|
||||
Go to App
|
||||
</Link>
|
||||
) : (
|
||||
<>
|
||||
<Link
|
||||
href='/login'
|
||||
className='inline-flex h-[30px] items-center rounded-[5px] border border-[#3d3d3d] px-[9px] text-[#ECECEC] text-[13.5px] transition-colors hover:bg-[#2A2A2A]'
|
||||
aria-label='Log in'
|
||||
>
|
||||
Log in
|
||||
</Link>
|
||||
<Link
|
||||
href='/signup'
|
||||
className='inline-flex h-[30px] items-center gap-[7px] rounded-[5px] border border-[#FFFFFF] bg-[#FFFFFF] px-[9px] text-[13.5px] text-black transition-colors hover:border-[#E0E0E0] hover:bg-[#E0E0E0]'
|
||||
aria-label='Get started with Sim'
|
||||
>
|
||||
Get started
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='flex flex-1 items-center justify-end pr-[20px] lg:hidden'>
|
||||
@@ -242,30 +269,34 @@ export default function Navbar({ logoOnly = false, blogPosts = [] }: NavbarProps
|
||||
)}
|
||||
>
|
||||
<ul className='flex flex-col'>
|
||||
{NAV_LINKS.map(({ label, href, external }) => (
|
||||
<li key={label} className='border-[#2A2A2A] border-b'>
|
||||
{external ? (
|
||||
<a
|
||||
href={href}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='flex items-center justify-between px-[20px] py-[14px] text-[#ECECEC] transition-colors active:bg-[#2A2A2A]'
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
{label}
|
||||
<ExternalArrowIcon />
|
||||
</a>
|
||||
) : (
|
||||
<Link
|
||||
href={href}
|
||||
className='flex items-center px-[20px] py-[14px] text-[#ECECEC] transition-colors active:bg-[#2A2A2A]'
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
{NAV_LINKS.map(({ label, href: rawHref, external }) => {
|
||||
const href =
|
||||
useHomeLinks && rawHref.startsWith('/#') ? `/?home${rawHref.slice(1)}` : rawHref
|
||||
return (
|
||||
<li key={label} className='border-[#2A2A2A] border-b'>
|
||||
{external ? (
|
||||
<a
|
||||
href={href}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='flex items-center justify-between px-[20px] py-[14px] text-[#ECECEC] transition-colors active:bg-[#2A2A2A]'
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
{label}
|
||||
<ExternalArrowIcon />
|
||||
</a>
|
||||
) : (
|
||||
<Link
|
||||
href={href}
|
||||
className='flex items-center px-[20px] py-[14px] text-[#ECECEC] transition-colors active:bg-[#2A2A2A]'
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
<li className='border-[#2A2A2A] border-b'>
|
||||
<a
|
||||
href='https://github.com/simstudioai/sim'
|
||||
@@ -280,23 +311,41 @@ export default function Navbar({ logoOnly = false, blogPosts = [] }: NavbarProps
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div className='mt-auto flex flex-col gap-[10px] p-[20px]'>
|
||||
<Link
|
||||
href='/login'
|
||||
className='flex h-[32px] items-center justify-center rounded-[5px] border border-[#3d3d3d] text-[#ECECEC] text-[14px] transition-colors active:bg-[#2A2A2A]'
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
aria-label='Log in'
|
||||
>
|
||||
Log in
|
||||
</Link>
|
||||
<Link
|
||||
href='/signup'
|
||||
className='flex h-[32px] items-center justify-center rounded-[5px] border border-[#FFFFFF] bg-[#FFFFFF] text-[14px] text-black transition-colors active:bg-[#E0E0E0]'
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
aria-label='Get started with Sim'
|
||||
>
|
||||
Get started
|
||||
</Link>
|
||||
<div
|
||||
className={cn(
|
||||
'mt-auto flex flex-col gap-[10px] p-[20px]',
|
||||
isSessionPending && 'invisible'
|
||||
)}
|
||||
>
|
||||
{isAuthenticated ? (
|
||||
<Link
|
||||
href='/workspace'
|
||||
className='flex h-[32px] items-center justify-center rounded-[5px] border border-[#FFFFFF] bg-[#FFFFFF] text-[14px] text-black transition-colors active:bg-[#E0E0E0]'
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
aria-label='Go to app'
|
||||
>
|
||||
Go to App
|
||||
</Link>
|
||||
) : (
|
||||
<>
|
||||
<Link
|
||||
href='/login'
|
||||
className='flex h-[32px] items-center justify-center rounded-[5px] border border-[#3d3d3d] text-[#ECECEC] text-[14px] transition-colors active:bg-[#2A2A2A]'
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
aria-label='Log in'
|
||||
>
|
||||
Log in
|
||||
</Link>
|
||||
<Link
|
||||
href='/signup'
|
||||
className='flex h-[32px] items-center justify-center rounded-[5px] border border-[#FFFFFF] bg-[#FFFFFF] text-[14px] text-black transition-colors active:bg-[#E0E0E0]'
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
aria-label='Get started with Sim'
|
||||
>
|
||||
Get started
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -60,7 +60,6 @@ export default async function Page({ params }: { params: Promise<{ slug: string
|
||||
sizes='(max-width: 768px) 100vw, 450px'
|
||||
priority
|
||||
itemProp='image'
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -144,7 +143,6 @@ export default async function Page({ params }: { params: Promise<{ slug: string
|
||||
className='h-[160px] w-full object-cover'
|
||||
sizes='(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw'
|
||||
loading='lazy'
|
||||
unoptimized
|
||||
/>
|
||||
<div className='p-3'>
|
||||
<div className='mb-1 text-[#999] text-xs'>
|
||||
|
||||
@@ -64,7 +64,6 @@ export default async function AuthorPage({ params }: { params: Promise<{ id: str
|
||||
width={600}
|
||||
height={315}
|
||||
className='h-[160px] w-full object-cover transition-transform group-hover:scale-[1.02]'
|
||||
unoptimized
|
||||
/>
|
||||
<div className='p-3'>
|
||||
<div className='mb-1 text-[#999] text-xs'>
|
||||
|
||||
@@ -32,7 +32,6 @@ export function PostGrid({ posts }: { posts: Post[] }) {
|
||||
src={p.ogImage}
|
||||
alt={p.title}
|
||||
sizes='(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw'
|
||||
unoptimized
|
||||
priority={index < 6}
|
||||
loading={index < 6 ? undefined : 'lazy'}
|
||||
fill
|
||||
|
||||
@@ -5,8 +5,9 @@ import { createLogger } from '@sim/logger'
|
||||
import { ArrowRight, ChevronRight } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { GithubIcon } from '@/components/icons'
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
import { isHosted } from '@/lib/core/config/feature-flags'
|
||||
import { getFormattedGitHubStars } from '@/app/(landing)/actions/github'
|
||||
import { useBrandConfig } from '@/ee/whitelabeling'
|
||||
@@ -26,6 +27,12 @@ export default function Nav({ hideAuthButtons = false, variant = 'landing' }: Na
|
||||
const router = useRouter()
|
||||
const brand = useBrandConfig()
|
||||
const buttonClass = useBrandedButtonClass()
|
||||
const searchParams = useSearchParams()
|
||||
const { data: session, isPending: isSessionPending } = useSession()
|
||||
const isAuthenticated = Boolean(session?.user?.id)
|
||||
const isBrowsingHome = searchParams.has('home')
|
||||
const useHomeLinks = isAuthenticated || isBrowsingHome
|
||||
const logoHref = useHomeLinks ? '/?home' : '/'
|
||||
|
||||
useEffect(() => {
|
||||
if (variant !== 'landing') return
|
||||
@@ -72,7 +79,7 @@ export default function Nav({ hideAuthButtons = false, variant = 'landing' }: Na
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href='/?from=nav#pricing'
|
||||
href={useHomeLinks ? '/?home#pricing' : '/#pricing'}
|
||||
className='text-[16px] text-muted-foreground transition-colors hover:text-foreground'
|
||||
scroll={true}
|
||||
>
|
||||
@@ -124,7 +131,7 @@ export default function Nav({ hideAuthButtons = false, variant = 'landing' }: Na
|
||||
itemType='https://schema.org/SiteNavigationElement'
|
||||
>
|
||||
<div className='flex items-center gap-[34px]'>
|
||||
<Link href='/?from=nav' aria-label={`${brand.name} home`} itemProp='url'>
|
||||
<Link href={logoHref} aria-label={`${brand.name} home`} itemProp='url'>
|
||||
<span itemProp='name' className='sr-only'>
|
||||
{brand.name} Home
|
||||
</span>
|
||||
@@ -162,45 +169,70 @@ export default function Nav({ hideAuthButtons = false, variant = 'landing' }: Na
|
||||
|
||||
{/* Auth Buttons - show only when hosted, regardless of variant */}
|
||||
{!hideAuthButtons && isHosted && (
|
||||
<div className='flex items-center justify-center gap-[16px] pt-[1.5px]'>
|
||||
<button
|
||||
onClick={handleLoginClick}
|
||||
onMouseEnter={() => setIsLoginHovered(true)}
|
||||
onMouseLeave={() => setIsLoginHovered(false)}
|
||||
className='group hidden text-[#2E2E2E] text-[16px] transition-colors hover:text-foreground md:block'
|
||||
type='button'
|
||||
aria-label='Log in to your account'
|
||||
>
|
||||
<span className='flex items-center gap-1'>
|
||||
Log in
|
||||
<span className='inline-flex transition-transform duration-200 group-hover:translate-x-0.5'>
|
||||
{isLoginHovered ? (
|
||||
<ArrowRight className='h-4 w-4' aria-hidden='true' />
|
||||
) : (
|
||||
<ChevronRight className='h-4 w-4' aria-hidden='true' />
|
||||
)}
|
||||
<div
|
||||
className={`flex items-center justify-center gap-[16px] pt-[1.5px]${isSessionPending ? ' invisible' : ''}`}
|
||||
>
|
||||
{isAuthenticated ? (
|
||||
<Link
|
||||
href='/workspace'
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
className={`${buttonClass} group inline-flex items-center justify-center gap-2 rounded-[10px] py-[6px] pr-[10px] pl-[12px] text-[15px] text-white transition-all`}
|
||||
aria-label='Go to app'
|
||||
>
|
||||
<span className='flex items-center gap-1'>
|
||||
Go to App
|
||||
<span className='inline-flex transition-transform duration-200 group-hover:translate-x-0.5'>
|
||||
{isHovered ? (
|
||||
<ArrowRight className='h-4 w-4' aria-hidden='true' />
|
||||
) : (
|
||||
<ChevronRight className='h-4 w-4' aria-hidden='true' />
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<Link
|
||||
href='/signup'
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
className={`${buttonClass} group inline-flex items-center justify-center gap-2 rounded-[10px] py-[6px] pr-[10px] pl-[12px] text-[15px] text-white transition-all`}
|
||||
aria-label='Get started with Sim - Sign up for free'
|
||||
prefetch={true}
|
||||
>
|
||||
<span className='flex items-center gap-1'>
|
||||
Get started
|
||||
<span className='inline-flex transition-transform duration-200 group-hover:translate-x-0.5'>
|
||||
{isHovered ? (
|
||||
<ArrowRight className='h-4 w-4' aria-hidden='true' />
|
||||
) : (
|
||||
<ChevronRight className='h-4 w-4' aria-hidden='true' />
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
</Link>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={handleLoginClick}
|
||||
onMouseEnter={() => setIsLoginHovered(true)}
|
||||
onMouseLeave={() => setIsLoginHovered(false)}
|
||||
className='group hidden text-[#2E2E2E] text-[16px] transition-colors hover:text-foreground md:block'
|
||||
type='button'
|
||||
aria-label='Log in to your account'
|
||||
>
|
||||
<span className='flex items-center gap-1'>
|
||||
Log in
|
||||
<span className='inline-flex transition-transform duration-200 group-hover:translate-x-0.5'>
|
||||
{isLoginHovered ? (
|
||||
<ArrowRight className='h-4 w-4' aria-hidden='true' />
|
||||
) : (
|
||||
<ChevronRight className='h-4 w-4' aria-hidden='true' />
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<Link
|
||||
href='/signup'
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
className={`${buttonClass} group inline-flex items-center justify-center gap-2 rounded-[10px] py-[6px] pr-[10px] pl-[12px] text-[15px] text-white transition-all`}
|
||||
aria-label='Get started with Sim - Sign up for free'
|
||||
prefetch={true}
|
||||
>
|
||||
<span className='flex items-center gap-1'>
|
||||
Get started
|
||||
<span className='inline-flex transition-transform duration-200 group-hover:translate-x-0.5'>
|
||||
{isHovered ? (
|
||||
<ArrowRight className='h-4 w-4' aria-hidden='true' />
|
||||
) : (
|
||||
<ChevronRight className='h-4 w-4' aria-hidden='true' />
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
@@ -111,7 +111,7 @@ function buildFAQs(integration: Integration): FAQItem[] {
|
||||
? [
|
||||
{
|
||||
question: `How do I trigger a Sim workflow from ${name} automatically?`,
|
||||
answer: `In your Sim workflow, switch the ${name} block to Trigger mode and copy the generated webhook URL. Paste that URL into ${name}'s webhook settings and select the events you want to listen for (${triggers.map((t) => t.name).join(', ')}). From that point on, every matching event in ${name} instantly fires your workflow — no polling, no delay.`,
|
||||
answer: `Add a ${name} trigger block to your workflow and copy the generated webhook URL. Paste that URL into ${name}'s webhook settings and select the events you want to listen for (${triggers.map((t) => t.name).join(', ')}). From that point on, every matching event in ${name} instantly fires your workflow — no polling, no delay.`,
|
||||
},
|
||||
{
|
||||
question: `What data does Sim receive when a ${name} event triggers a workflow?`,
|
||||
|
||||
@@ -21,6 +21,7 @@ export type AppSession = {
|
||||
id?: string
|
||||
userId?: string
|
||||
activeOrganizationId?: string
|
||||
impersonatedBy?: string | null
|
||||
}
|
||||
} | null
|
||||
|
||||
|
||||
@@ -1,236 +0,0 @@
|
||||
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 })
|
||||
}
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
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 })
|
||||
}
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
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 })
|
||||
}
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
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 })
|
||||
}
|
||||
}
|
||||
@@ -1,489 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { abortActiveStream } from '@/lib/copilot/chat-streaming'
|
||||
import { getLatestRunForStream } from '@/lib/copilot/async-runs/repository'
|
||||
import { abortActiveStream, waitForPendingChatStream } from '@/lib/copilot/chat-streaming'
|
||||
import { SIM_AGENT_API_URL } from '@/lib/copilot/constants'
|
||||
import { authenticateCopilotRequestSessionOnly } from '@/lib/copilot/request-helpers'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
|
||||
const GO_EXPLICIT_ABORT_TIMEOUT_MS = 3000
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const { userId: authenticatedUserId, isAuthenticated } =
|
||||
@@ -12,11 +17,48 @@ export async function POST(request: Request) {
|
||||
|
||||
const body = await request.json().catch(() => ({}))
|
||||
const streamId = typeof body.streamId === 'string' ? body.streamId : ''
|
||||
let chatId = typeof body.chatId === 'string' ? body.chatId : ''
|
||||
|
||||
if (!streamId) {
|
||||
return NextResponse.json({ error: 'streamId is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const aborted = abortActiveStream(streamId)
|
||||
if (!chatId) {
|
||||
const run = await getLatestRunForStream(streamId, authenticatedUserId).catch(() => null)
|
||||
if (run?.chatId) {
|
||||
chatId = run.chatId
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
||||
if (env.COPILOT_API_KEY) {
|
||||
headers['x-api-key'] = env.COPILOT_API_KEY
|
||||
}
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), GO_EXPLICIT_ABORT_TIMEOUT_MS)
|
||||
const response = await fetch(`${SIM_AGENT_API_URL}/api/streams/explicit-abort`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
body: JSON.stringify({
|
||||
messageId: streamId,
|
||||
userId: authenticatedUserId,
|
||||
...(chatId ? { chatId } : {}),
|
||||
}),
|
||||
}).finally(() => clearTimeout(timeout))
|
||||
if (!response.ok) {
|
||||
throw new Error(`Explicit abort marker request failed: ${response.status}`)
|
||||
}
|
||||
} catch {
|
||||
// best effort: local abort should still proceed even if Go marker fails
|
||||
}
|
||||
|
||||
const aborted = await abortActiveStream(streamId)
|
||||
if (chatId) {
|
||||
await waitForPendingChatStream(chatId, GO_EXPLICIT_ABORT_TIMEOUT_MS + 1000, streamId).catch(
|
||||
() => false
|
||||
)
|
||||
}
|
||||
return NextResponse.json({ aborted })
|
||||
}
|
||||
|
||||
@@ -8,7 +8,9 @@ import { getSession } from '@/lib/auth'
|
||||
import { getAccessibleCopilotChat, resolveOrCreateChat } from '@/lib/copilot/chat-lifecycle'
|
||||
import { buildCopilotRequestPayload } from '@/lib/copilot/chat-payload'
|
||||
import {
|
||||
acquirePendingChatStream,
|
||||
createSSEStream,
|
||||
releasePendingChatStream,
|
||||
requestChatTitle,
|
||||
SSE_RESPONSE_HEADERS,
|
||||
} from '@/lib/copilot/chat-streaming'
|
||||
@@ -16,6 +18,7 @@ import { COPILOT_REQUEST_MODES } from '@/lib/copilot/models'
|
||||
import { orchestrateCopilotStream } from '@/lib/copilot/orchestrator'
|
||||
import { getStreamMeta, readStreamEvents } from '@/lib/copilot/orchestrator/stream/buffer'
|
||||
import type { OrchestratorResult } from '@/lib/copilot/orchestrator/types'
|
||||
import { resolveActiveResourceContext } from '@/lib/copilot/process-contents'
|
||||
import {
|
||||
authenticateCopilotRequestSessionOnly,
|
||||
createBadRequestResponse,
|
||||
@@ -44,6 +47,13 @@ const FileAttachmentSchema = z.object({
|
||||
size: z.number(),
|
||||
})
|
||||
|
||||
const ResourceAttachmentSchema = z.object({
|
||||
type: z.enum(['workflow', 'table', 'file', 'knowledgebase']),
|
||||
id: z.string().min(1),
|
||||
title: z.string().optional(),
|
||||
active: z.boolean().optional(),
|
||||
})
|
||||
|
||||
const ChatMessageSchema = z.object({
|
||||
message: z.string().min(1, 'Message is required'),
|
||||
userMessageId: z.string().optional(),
|
||||
@@ -58,6 +68,7 @@ const ChatMessageSchema = z.object({
|
||||
stream: z.boolean().optional().default(true),
|
||||
implicitFeedback: z.string().optional(),
|
||||
fileAttachments: z.array(FileAttachmentSchema).optional(),
|
||||
resourceAttachments: z.array(ResourceAttachmentSchema).optional(),
|
||||
provider: z.string().optional(),
|
||||
contexts: z
|
||||
.array(
|
||||
@@ -98,6 +109,10 @@ const ChatMessageSchema = z.object({
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
const tracker = createRequestTracker()
|
||||
let actualChatId: string | undefined
|
||||
let pendingChatStreamAcquired = false
|
||||
let pendingChatStreamHandedOff = false
|
||||
let pendingChatStreamID: string | undefined
|
||||
|
||||
try {
|
||||
// Get session to access user information including name
|
||||
@@ -124,6 +139,7 @@ export async function POST(req: NextRequest) {
|
||||
stream,
|
||||
implicitFeedback,
|
||||
fileAttachments,
|
||||
resourceAttachments,
|
||||
provider,
|
||||
contexts,
|
||||
commands,
|
||||
@@ -189,7 +205,7 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
let currentChat: any = null
|
||||
let conversationHistory: any[] = []
|
||||
let actualChatId = chatId
|
||||
actualChatId = chatId
|
||||
const selectedModel = model || 'claude-opus-4-6'
|
||||
|
||||
if (chatId || createNewChat) {
|
||||
@@ -241,6 +257,39 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
Array.isArray(resourceAttachments) &&
|
||||
resourceAttachments.length > 0 &&
|
||||
resolvedWorkspaceId
|
||||
) {
|
||||
const results = await Promise.allSettled(
|
||||
resourceAttachments.map(async (r) => {
|
||||
const ctx = await resolveActiveResourceContext(
|
||||
r.type,
|
||||
r.id,
|
||||
resolvedWorkspaceId!,
|
||||
authenticatedUserId,
|
||||
actualChatId
|
||||
)
|
||||
if (!ctx) return null
|
||||
return {
|
||||
...ctx,
|
||||
tag: r.active ? '@active_tab' : '@open_tab',
|
||||
}
|
||||
})
|
||||
)
|
||||
for (const result of results) {
|
||||
if (result.status === 'fulfilled' && result.value) {
|
||||
agentContexts.push(result.value)
|
||||
} else if (result.status === 'rejected') {
|
||||
logger.error(
|
||||
`[${tracker.requestId}] Failed to resolve resource attachment`,
|
||||
result.reason
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const effectiveMode = mode === 'agent' ? 'build' : mode
|
||||
|
||||
const userPermission = resolvedWorkspaceId
|
||||
@@ -291,6 +340,21 @@ export async function POST(req: NextRequest) {
|
||||
})
|
||||
} catch {}
|
||||
|
||||
if (stream && actualChatId) {
|
||||
const acquired = await acquirePendingChatStream(actualChatId, userMessageIdToUse)
|
||||
if (!acquired) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'A response is already in progress for this chat. Wait for it to finish or use Stop.',
|
||||
},
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
pendingChatStreamAcquired = true
|
||||
pendingChatStreamID = userMessageIdToUse
|
||||
}
|
||||
|
||||
if (actualChatId) {
|
||||
const userMsg = {
|
||||
id: userMessageIdToUse,
|
||||
@@ -337,6 +401,7 @@ export async function POST(req: NextRequest) {
|
||||
titleProvider: provider,
|
||||
requestId: tracker.requestId,
|
||||
workspaceId: resolvedWorkspaceId,
|
||||
pendingChatStreamAlreadyRegistered: Boolean(actualChatId && stream),
|
||||
orchestrateOptions: {
|
||||
userId: authenticatedUserId,
|
||||
workflowId,
|
||||
@@ -348,6 +413,7 @@ export async function POST(req: NextRequest) {
|
||||
interactive: true,
|
||||
onComplete: async (result: OrchestratorResult) => {
|
||||
if (!actualChatId) return
|
||||
if (!result.success) return
|
||||
|
||||
const assistantMessage: Record<string, unknown> = {
|
||||
id: crypto.randomUUID(),
|
||||
@@ -423,6 +489,7 @@ export async function POST(req: NextRequest) {
|
||||
},
|
||||
},
|
||||
})
|
||||
pendingChatStreamHandedOff = true
|
||||
|
||||
return new Response(sseStream, { headers: SSE_RESPONSE_HEADERS })
|
||||
}
|
||||
@@ -528,6 +595,14 @@ export async function POST(req: NextRequest) {
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
if (
|
||||
actualChatId &&
|
||||
pendingChatStreamAcquired &&
|
||||
!pendingChatStreamHandedOff &&
|
||||
pendingChatStreamID
|
||||
) {
|
||||
await releasePendingChatStream(actualChatId, pendingChatStreamID).catch(() => {})
|
||||
}
|
||||
const duration = tracker.getDuration()
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
|
||||
@@ -8,9 +8,9 @@ import { getSession } from '@/lib/auth'
|
||||
import { resolveOrCreateChat } from '@/lib/copilot/chat-lifecycle'
|
||||
import { buildCopilotRequestPayload } from '@/lib/copilot/chat-payload'
|
||||
import {
|
||||
acquirePendingChatStream,
|
||||
createSSEStream,
|
||||
SSE_RESPONSE_HEADERS,
|
||||
waitForPendingChatStream,
|
||||
} from '@/lib/copilot/chat-streaming'
|
||||
import type { OrchestratorResult } from '@/lib/copilot/orchestrator/types'
|
||||
import { processContextsServer, resolveActiveResourceContext } from '@/lib/copilot/process-contents'
|
||||
@@ -253,7 +253,16 @@ export async function POST(req: NextRequest) {
|
||||
)
|
||||
|
||||
if (actualChatId) {
|
||||
await waitForPendingChatStream(actualChatId)
|
||||
const acquired = await acquirePendingChatStream(actualChatId, userMessageId)
|
||||
if (!acquired) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'A response is already in progress for this chat. Wait for it to finish or use Stop.',
|
||||
},
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const executionId = crypto.randomUUID()
|
||||
@@ -271,6 +280,7 @@ export async function POST(req: NextRequest) {
|
||||
titleModel: 'claude-opus-4-6',
|
||||
requestId: tracker.requestId,
|
||||
workspaceId,
|
||||
pendingChatStreamAlreadyRegistered: Boolean(actualChatId),
|
||||
orchestrateOptions: {
|
||||
userId: authenticatedUserId,
|
||||
workspaceId,
|
||||
@@ -282,6 +292,7 @@ export async function POST(req: NextRequest) {
|
||||
interactive: true,
|
||||
onComplete: async (result: OrchestratorResult) => {
|
||||
if (!actualChatId) return
|
||||
if (!result.success) return
|
||||
|
||||
const assistantMessage: Record<string, unknown> = {
|
||||
id: crypto.randomUUID(),
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
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 })
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ const MetadataSchema = z.object({
|
||||
workspaceId: z.string().min(1, 'Workspace ID is required'),
|
||||
metadata: z.object({
|
||||
columnWidths: z.record(z.number().positive()).optional(),
|
||||
columnOrder: z.array(z.string()).optional(),
|
||||
}),
|
||||
})
|
||||
|
||||
|
||||
@@ -20,11 +20,15 @@
|
||||
* - durationInMonths: number (required when duration is 'repeating')
|
||||
* - maxRedemptions: number (optional) — Total redemption cap
|
||||
* - expiresAt: ISO 8601 string (optional) — Promotion code expiry
|
||||
* - appliesTo: ('pro' | 'team' | 'pro_6000' | 'pro_25000' | 'team_6000' | 'team_25000')[] (optional)
|
||||
* Restrict coupon to specific plans. Broad values ('pro', 'team') match all tiers.
|
||||
*/
|
||||
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { NextResponse } from 'next/server'
|
||||
import type Stripe from 'stripe'
|
||||
import { isPro, isTeam } from '@/lib/billing/plan-helpers'
|
||||
import { getPlans } from '@/lib/billing/plans'
|
||||
import { requireStripeClient } from '@/lib/billing/stripe-client'
|
||||
import { withAdminAuth } from '@/app/api/v1/admin/middleware'
|
||||
import {
|
||||
@@ -38,6 +42,17 @@ const logger = createLogger('AdminPromoCodes')
|
||||
const VALID_DURATIONS = ['once', 'repeating', 'forever'] as const
|
||||
type Duration = (typeof VALID_DURATIONS)[number]
|
||||
|
||||
/** Broad categories match all tiers; specific plan names match exactly. */
|
||||
const VALID_APPLIES_TO = [
|
||||
'pro',
|
||||
'team',
|
||||
'pro_6000',
|
||||
'pro_25000',
|
||||
'team_6000',
|
||||
'team_25000',
|
||||
] as const
|
||||
type AppliesTo = (typeof VALID_APPLIES_TO)[number]
|
||||
|
||||
interface PromoCodeResponse {
|
||||
id: string
|
||||
code: string
|
||||
@@ -46,6 +61,7 @@ interface PromoCodeResponse {
|
||||
percentOff: number
|
||||
duration: string
|
||||
durationInMonths: number | null
|
||||
appliesToProductIds: string[] | null
|
||||
maxRedemptions: number | null
|
||||
expiresAt: string | null
|
||||
active: boolean
|
||||
@@ -62,6 +78,7 @@ function formatPromoCode(promo: {
|
||||
percent_off: number | null
|
||||
duration: string
|
||||
duration_in_months: number | null
|
||||
applies_to?: { products: string[] }
|
||||
}
|
||||
max_redemptions: number | null
|
||||
expires_at: number | null
|
||||
@@ -77,6 +94,7 @@ function formatPromoCode(promo: {
|
||||
percentOff: promo.coupon.percent_off ?? 0,
|
||||
duration: promo.coupon.duration,
|
||||
durationInMonths: promo.coupon.duration_in_months,
|
||||
appliesToProductIds: promo.coupon.applies_to?.products ?? null,
|
||||
maxRedemptions: promo.max_redemptions,
|
||||
expiresAt: promo.expires_at ? new Date(promo.expires_at * 1000).toISOString() : null,
|
||||
active: promo.active,
|
||||
@@ -85,6 +103,54 @@ function formatPromoCode(promo: {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve appliesTo values to unique Stripe product IDs.
|
||||
* Broad categories ('pro', 'team') match all tiers via isPro/isTeam.
|
||||
* Specific plan names ('pro_6000', 'team_25000') match exactly.
|
||||
*/
|
||||
async function resolveProductIds(stripe: Stripe, targets: AppliesTo[]): Promise<string[]> {
|
||||
const plans = getPlans()
|
||||
const priceIds: string[] = []
|
||||
|
||||
const broadMatchers: Record<string, (name: string) => boolean> = {
|
||||
pro: isPro,
|
||||
team: isTeam,
|
||||
}
|
||||
|
||||
for (const plan of plans) {
|
||||
const matches = targets.some((target) => {
|
||||
const matcher = broadMatchers[target]
|
||||
return matcher ? matcher(plan.name) : plan.name === target
|
||||
})
|
||||
if (!matches) continue
|
||||
if (plan.priceId) priceIds.push(plan.priceId)
|
||||
if (plan.annualDiscountPriceId) priceIds.push(plan.annualDiscountPriceId)
|
||||
}
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
priceIds.map(async (priceId) => {
|
||||
const price = await stripe.prices.retrieve(priceId)
|
||||
return typeof price.product === 'string' ? price.product : price.product.id
|
||||
})
|
||||
)
|
||||
|
||||
const failures = results.filter((r) => r.status === 'rejected')
|
||||
if (failures.length > 0) {
|
||||
logger.error('Failed to resolve all Stripe products for appliesTo', {
|
||||
failed: failures.length,
|
||||
total: priceIds.length,
|
||||
})
|
||||
throw new Error('Could not resolve all Stripe products for the specified plan categories.')
|
||||
}
|
||||
|
||||
const productIds = new Set<string>()
|
||||
for (const r of results) {
|
||||
if (r.status === 'fulfilled') productIds.add(r.value)
|
||||
}
|
||||
|
||||
return [...productIds]
|
||||
}
|
||||
|
||||
export const GET = withAdminAuth(async (request) => {
|
||||
try {
|
||||
const stripe = requireStripeClient()
|
||||
@@ -125,7 +191,16 @@ export const POST = withAdminAuth(async (request) => {
|
||||
const stripe = requireStripeClient()
|
||||
const body = await request.json()
|
||||
|
||||
const { name, percentOff, code, duration, durationInMonths, maxRedemptions, expiresAt } = body
|
||||
const {
|
||||
name,
|
||||
percentOff,
|
||||
code,
|
||||
duration,
|
||||
durationInMonths,
|
||||
maxRedemptions,
|
||||
expiresAt,
|
||||
appliesTo,
|
||||
} = body
|
||||
|
||||
if (!name || typeof name !== 'string' || name.trim().length === 0) {
|
||||
return badRequestResponse('name is required and must be a non-empty string')
|
||||
@@ -186,11 +261,36 @@ export const POST = withAdminAuth(async (request) => {
|
||||
}
|
||||
}
|
||||
|
||||
if (appliesTo !== undefined && appliesTo !== null) {
|
||||
if (!Array.isArray(appliesTo) || appliesTo.length === 0) {
|
||||
return badRequestResponse('appliesTo must be a non-empty array')
|
||||
}
|
||||
const invalid = appliesTo.filter(
|
||||
(v: unknown) => typeof v !== 'string' || !VALID_APPLIES_TO.includes(v as AppliesTo)
|
||||
)
|
||||
if (invalid.length > 0) {
|
||||
return badRequestResponse(
|
||||
`appliesTo contains invalid values: ${invalid.join(', ')}. Valid values: ${VALID_APPLIES_TO.join(', ')}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
let appliesToProducts: string[] | undefined
|
||||
if (appliesTo?.length) {
|
||||
appliesToProducts = await resolveProductIds(stripe, appliesTo as AppliesTo[])
|
||||
if (appliesToProducts.length === 0) {
|
||||
return badRequestResponse(
|
||||
'Could not resolve any Stripe products for the specified plan categories. Ensure price IDs are configured.'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const coupon = await stripe.coupons.create({
|
||||
name: name.trim(),
|
||||
percent_off: percentOff,
|
||||
duration: effectiveDuration,
|
||||
...(effectiveDuration === 'repeating' ? { duration_in_months: durationInMonths } : {}),
|
||||
...(appliesToProducts ? { applies_to: { products: appliesToProducts } } : {}),
|
||||
})
|
||||
|
||||
let promoCode
|
||||
@@ -224,6 +324,7 @@ export const POST = withAdminAuth(async (request) => {
|
||||
couponId: coupon.id,
|
||||
percentOff,
|
||||
duration: effectiveDuration,
|
||||
...(appliesTo ? { appliesTo } : {}),
|
||||
})
|
||||
|
||||
return singleResponse(formatPromoCode(promoCode))
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
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,9 +37,7 @@ export async function checkRateLimit(
|
||||
| 'file-detail'
|
||||
| 'knowledge'
|
||||
| 'knowledge-detail'
|
||||
| 'knowledge-search'
|
||||
| 'agents'
|
||||
| 'agent-detail' = 'logs'
|
||||
| 'knowledge-search' = 'logs'
|
||||
): Promise<RateLimitResult> {
|
||||
try {
|
||||
const auth = await authenticateV1Request(request)
|
||||
|
||||
@@ -3,9 +3,17 @@
|
||||
import { type RefObject, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { Mic, MicOff, Phone } from 'lucide-react'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { ParticlesVisualization } from '@/app/chat/components/voice-interface/components/particles'
|
||||
|
||||
const ParticlesVisualization = dynamic(
|
||||
() =>
|
||||
import('@/app/chat/components/voice-interface/components/particles').then(
|
||||
(mod) => mod.ParticlesVisualization
|
||||
),
|
||||
{ ssr: false }
|
||||
)
|
||||
|
||||
const logger = createLogger('VoiceInterface')
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { Star, User } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { VerifiedBadge } from '@/components/ui/verified-badge'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
@@ -281,9 +282,14 @@ function TemplateCardInner({
|
||||
<div className='mt-[10px] flex items-center justify-between'>
|
||||
<div className='flex min-w-0 items-center gap-[8px]'>
|
||||
{authorImageUrl ? (
|
||||
<div className='h-[20px] w-[20px] flex-shrink-0 overflow-hidden rounded-full'>
|
||||
<img src={authorImageUrl} alt={author} className='h-full w-full object-cover' />
|
||||
</div>
|
||||
<Image
|
||||
src={authorImageUrl}
|
||||
alt={author}
|
||||
width={20}
|
||||
height={20}
|
||||
className='flex-shrink-0 rounded-full object-cover'
|
||||
unoptimized
|
||||
/>
|
||||
) : (
|
||||
<div className='flex h-[20px] w-[20px] flex-shrink-0 items-center justify-center rounded-full bg-[var(--surface-7)]'>
|
||||
<User className='h-[12px] w-[12px] text-[var(--text-muted)]' />
|
||||
|
||||
@@ -1,226 +0,0 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -1,789 +0,0 @@
|
||||
'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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,739 +0,0 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -1,362 +0,0 @@
|
||||
'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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,531 +0,0 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
'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,
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export default function AgentDetailLayout({ children }: { children: React.ReactNode }) {
|
||||
return <div className='flex h-full flex-1 flex-col overflow-hidden'>{children}</div>
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
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} />
|
||||
}
|
||||
@@ -1,224 +0,0 @@
|
||||
'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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export default function AgentsLayout({ children }: { children: React.ReactNode }) {
|
||||
return <div className='flex h-full flex-1 flex-col overflow-hidden'>{children}</div>
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
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,2 @@
|
||||
export { NavTour, START_NAV_TOUR_EVENT } from './product-tour'
|
||||
export { START_WORKFLOW_TOUR_EVENT, WorkflowTour } from './workflow-tour'
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { Step } from 'react-joyride'
|
||||
|
||||
export const navTourSteps: Step[] = [
|
||||
{
|
||||
target: '[data-tour="nav-home"]',
|
||||
title: 'Home',
|
||||
content:
|
||||
'Your starting point. Describe what you want to build in plain language or pick a template to get started.',
|
||||
placement: 'right',
|
||||
disableBeacon: true,
|
||||
spotlightPadding: 0,
|
||||
},
|
||||
{
|
||||
target: '[data-tour="nav-search"]',
|
||||
title: 'Search',
|
||||
content: 'Quickly find workflows, blocks, and tools. Use Cmd+K to open it from anywhere.',
|
||||
placement: 'right',
|
||||
disableBeacon: true,
|
||||
spotlightPadding: 0,
|
||||
},
|
||||
{
|
||||
target: '[data-tour="nav-tables"]',
|
||||
title: 'Tables',
|
||||
content:
|
||||
'Store and query structured data. Your workflows can read and write to tables directly.',
|
||||
placement: 'right',
|
||||
disableBeacon: true,
|
||||
},
|
||||
{
|
||||
target: '[data-tour="nav-files"]',
|
||||
title: 'Files',
|
||||
content: 'Upload and manage files that your workflows can process, transform, or reference.',
|
||||
placement: 'right',
|
||||
disableBeacon: true,
|
||||
},
|
||||
{
|
||||
target: '[data-tour="nav-knowledge-base"]',
|
||||
title: 'Knowledge Base',
|
||||
content:
|
||||
'Build knowledge bases from your documents. Set up connectors to give your agents realtime access to your data sources from sources like Notion, Drive, Slack, Confluence, and more.',
|
||||
placement: 'right',
|
||||
disableBeacon: true,
|
||||
},
|
||||
{
|
||||
target: '[data-tour="nav-scheduled-tasks"]',
|
||||
title: 'Scheduled Tasks',
|
||||
content:
|
||||
'View and manage background tasks. Set up new tasks, or view the tasks the Mothership is monitoring for upcoming or past executions.',
|
||||
placement: 'right',
|
||||
disableBeacon: true,
|
||||
},
|
||||
{
|
||||
target: '[data-tour="nav-logs"]',
|
||||
title: 'Logs',
|
||||
content:
|
||||
'Monitor every workflow execution. See inputs, outputs, errors, and timing for each run. View analytics on performance and costs, filter previous runs, and view snapshots of the workflow at the time of execution.',
|
||||
placement: 'right',
|
||||
disableBeacon: true,
|
||||
},
|
||||
{
|
||||
target: '[data-tour="nav-tasks"]',
|
||||
title: 'Tasks',
|
||||
content:
|
||||
'Tasks that work for you. Mothership can create, edit, and delete resource throughout the platform. It can also perform actions on your behalf, like sending emails, creating tasks, and more.',
|
||||
placement: 'right',
|
||||
disableBeacon: true,
|
||||
},
|
||||
{
|
||||
target: '[data-tour="nav-workflows"]',
|
||||
title: 'Workflows',
|
||||
content:
|
||||
'All your workflows live here. Create new ones with the + button and organize them into folders. Deploy your workflows as API, webhook, schedule, or chat widget. Then hit Run to test it out.',
|
||||
placement: 'right',
|
||||
disableBeacon: true,
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,63 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { navTourSteps } from '@/app/workspace/[workspaceId]/components/product-tour/nav-tour-steps'
|
||||
import type { TourState } from '@/app/workspace/[workspaceId]/components/product-tour/tour-shared'
|
||||
import {
|
||||
getSharedJoyrideProps,
|
||||
TourStateContext,
|
||||
TourTooltipAdapter,
|
||||
} from '@/app/workspace/[workspaceId]/components/product-tour/tour-shared'
|
||||
import { useTour } from '@/app/workspace/[workspaceId]/components/product-tour/use-tour'
|
||||
|
||||
const Joyride = dynamic(() => import('react-joyride'), {
|
||||
ssr: false,
|
||||
})
|
||||
|
||||
const NAV_TOUR_STORAGE_KEY = 'sim-nav-tour-completed-v1'
|
||||
export const START_NAV_TOUR_EVENT = 'start-nav-tour'
|
||||
|
||||
export function NavTour() {
|
||||
const pathname = usePathname()
|
||||
const isWorkflowPage = /\/w\/[^/]+/.test(pathname)
|
||||
|
||||
const { run, stepIndex, tourKey, isTooltipVisible, isEntrance, handleCallback } = useTour({
|
||||
steps: navTourSteps,
|
||||
storageKey: NAV_TOUR_STORAGE_KEY,
|
||||
autoStartDelay: 1200,
|
||||
resettable: true,
|
||||
triggerEvent: START_NAV_TOUR_EVENT,
|
||||
tourName: 'Navigation tour',
|
||||
disabled: isWorkflowPage,
|
||||
})
|
||||
|
||||
const tourState = useMemo<TourState>(
|
||||
() => ({
|
||||
isTooltipVisible,
|
||||
isEntrance,
|
||||
totalSteps: navTourSteps.length,
|
||||
}),
|
||||
[isTooltipVisible, isEntrance]
|
||||
)
|
||||
|
||||
return (
|
||||
<TourStateContext.Provider value={tourState}>
|
||||
<Joyride
|
||||
key={tourKey}
|
||||
steps={navTourSteps}
|
||||
run={run}
|
||||
stepIndex={stepIndex}
|
||||
callback={handleCallback}
|
||||
continuous
|
||||
disableScrolling
|
||||
disableScrollParentFix
|
||||
disableOverlayClose
|
||||
spotlightPadding={4}
|
||||
tooltipComponent={TourTooltipAdapter}
|
||||
{...getSharedJoyrideProps({ spotlightBorderRadius: 8 })}
|
||||
/>
|
||||
</TourStateContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
'use client'
|
||||
|
||||
import { createContext, useCallback, useContext, useEffect, useState } from 'react'
|
||||
import type { TooltipRenderProps } from 'react-joyride'
|
||||
import { TourTooltip } from '@/components/emcn'
|
||||
|
||||
/** Shared state passed from the tour component to the tooltip adapter via context */
|
||||
export interface TourState {
|
||||
isTooltipVisible: boolean
|
||||
isEntrance: boolean
|
||||
totalSteps: number
|
||||
}
|
||||
|
||||
export const TourStateContext = createContext<TourState>({
|
||||
isTooltipVisible: true,
|
||||
isEntrance: true,
|
||||
totalSteps: 0,
|
||||
})
|
||||
|
||||
/**
|
||||
* Maps Joyride placement strings to TourTooltip placement values.
|
||||
*/
|
||||
function mapPlacement(placement?: string): 'top' | 'right' | 'bottom' | 'left' | 'center' {
|
||||
switch (placement) {
|
||||
case 'top':
|
||||
case 'top-start':
|
||||
case 'top-end':
|
||||
return 'top'
|
||||
case 'right':
|
||||
case 'right-start':
|
||||
case 'right-end':
|
||||
return 'right'
|
||||
case 'bottom':
|
||||
case 'bottom-start':
|
||||
case 'bottom-end':
|
||||
return 'bottom'
|
||||
case 'left':
|
||||
case 'left-start':
|
||||
case 'left-end':
|
||||
return 'left'
|
||||
case 'center':
|
||||
return 'center'
|
||||
default:
|
||||
return 'bottom'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapter that bridges Joyride's tooltip render props to the EMCN TourTooltip component.
|
||||
* Reads transition state from TourStateContext to coordinate fade animations.
|
||||
*/
|
||||
export function TourTooltipAdapter({
|
||||
step,
|
||||
index,
|
||||
isLastStep,
|
||||
tooltipProps,
|
||||
primaryProps,
|
||||
backProps,
|
||||
closeProps,
|
||||
}: TooltipRenderProps) {
|
||||
const { isTooltipVisible, isEntrance, totalSteps } = useContext(TourStateContext)
|
||||
const [targetEl, setTargetEl] = useState<HTMLElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const { target } = step
|
||||
if (typeof target === 'string') {
|
||||
setTargetEl(document.querySelector<HTMLElement>(target))
|
||||
} else if (target instanceof HTMLElement) {
|
||||
setTargetEl(target)
|
||||
} else {
|
||||
setTargetEl(null)
|
||||
}
|
||||
}, [step])
|
||||
|
||||
/**
|
||||
* Forwards the Joyride tooltip ref safely, handling both
|
||||
* callback refs and RefObject refs from the library.
|
||||
* Memoized to prevent ref churn (null → node cycling) on re-renders.
|
||||
*/
|
||||
const setJoyrideRef = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
const { ref } = tooltipProps
|
||||
if (!ref) return
|
||||
if (typeof ref === 'function') {
|
||||
ref(node)
|
||||
} else {
|
||||
;(ref as React.MutableRefObject<HTMLDivElement | null>).current = node
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[tooltipProps.ref]
|
||||
)
|
||||
|
||||
const placement = mapPlacement(step.placement)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={setJoyrideRef}
|
||||
role={tooltipProps.role}
|
||||
aria-modal={tooltipProps['aria-modal']}
|
||||
style={{ position: 'absolute', opacity: 0, pointerEvents: 'none', width: 0, height: 0 }}
|
||||
/>
|
||||
<TourTooltip
|
||||
title={step.title as string}
|
||||
description={step.content}
|
||||
step={index + 1}
|
||||
totalSteps={totalSteps}
|
||||
placement={placement}
|
||||
targetEl={targetEl}
|
||||
isFirst={index === 0}
|
||||
isLast={isLastStep}
|
||||
isVisible={isTooltipVisible}
|
||||
isEntrance={isEntrance && index === 0}
|
||||
onNext={primaryProps.onClick as () => void}
|
||||
onBack={backProps.onClick as () => void}
|
||||
onClose={closeProps.onClick as () => void}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const SPOTLIGHT_TRANSITION =
|
||||
'top 200ms cubic-bezier(0.25, 0.46, 0.45, 0.94), left 200ms cubic-bezier(0.25, 0.46, 0.45, 0.94), width 200ms cubic-bezier(0.25, 0.46, 0.45, 0.94), height 200ms cubic-bezier(0.25, 0.46, 0.45, 0.94)'
|
||||
|
||||
/**
|
||||
* Returns the shared Joyride floaterProps and styles config used by both tours.
|
||||
* Only `spotlightPadding` and spotlight `borderRadius` differ between tours.
|
||||
*/
|
||||
export function getSharedJoyrideProps(overrides: { spotlightBorderRadius: number }) {
|
||||
return {
|
||||
floaterProps: {
|
||||
disableAnimation: true,
|
||||
hideArrow: true,
|
||||
styles: {
|
||||
floater: {
|
||||
filter: 'none',
|
||||
opacity: 0,
|
||||
pointerEvents: 'none' as React.CSSProperties['pointerEvents'],
|
||||
width: 0,
|
||||
height: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
styles: {
|
||||
options: {
|
||||
zIndex: 10000,
|
||||
},
|
||||
spotlight: {
|
||||
backgroundColor: 'transparent',
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
borderRadius: overrides.spotlightBorderRadius,
|
||||
boxShadow: '0 0 0 9999px rgba(0, 0, 0, 0.55)',
|
||||
position: 'fixed' as React.CSSProperties['position'],
|
||||
transition: SPOTLIGHT_TRANSITION,
|
||||
},
|
||||
overlay: {
|
||||
backgroundColor: 'transparent',
|
||||
mixBlendMode: 'unset' as React.CSSProperties['mixBlendMode'],
|
||||
position: 'fixed' as React.CSSProperties['position'],
|
||||
height: '100%',
|
||||
overflow: 'visible',
|
||||
pointerEvents: 'none' as React.CSSProperties['pointerEvents'],
|
||||
},
|
||||
},
|
||||
} as const
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { ACTIONS, type CallBackProps, EVENTS, STATUS, type Step } from 'react-joyride'
|
||||
|
||||
const logger = createLogger('useTour')
|
||||
|
||||
/** Transition delay before updating step index (ms) */
|
||||
const FADE_OUT_MS = 80
|
||||
|
||||
interface UseTourOptions {
|
||||
/** Tour step definitions */
|
||||
steps: Step[]
|
||||
/** localStorage key for completion persistence */
|
||||
storageKey: string
|
||||
/** Delay before auto-starting the tour (ms) */
|
||||
autoStartDelay?: number
|
||||
/** Whether this tour can be reset/retriggered */
|
||||
resettable?: boolean
|
||||
/** Custom event name to listen for manual triggers */
|
||||
triggerEvent?: string
|
||||
/** Identifier for logging */
|
||||
tourName?: string
|
||||
/** When true, suppresses auto-start (e.g. to avoid overlapping with another active tour) */
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
interface UseTourReturn {
|
||||
/** Whether the tour is currently running */
|
||||
run: boolean
|
||||
/** Current step index */
|
||||
stepIndex: number
|
||||
/** Key to force Joyride remount on retrigger */
|
||||
tourKey: number
|
||||
/** Whether the tooltip is visible (false during step transitions) */
|
||||
isTooltipVisible: boolean
|
||||
/** Whether this is the initial entrance animation */
|
||||
isEntrance: boolean
|
||||
/** Joyride callback handler */
|
||||
handleCallback: (data: CallBackProps) => void
|
||||
}
|
||||
|
||||
function isTourCompleted(storageKey: string): boolean {
|
||||
try {
|
||||
return localStorage.getItem(storageKey) === 'true'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function markTourCompleted(storageKey: string): void {
|
||||
try {
|
||||
localStorage.setItem(storageKey, 'true')
|
||||
} catch {
|
||||
logger.warn('Failed to persist tour completion', { storageKey })
|
||||
}
|
||||
}
|
||||
|
||||
function clearTourCompletion(storageKey: string): void {
|
||||
try {
|
||||
localStorage.removeItem(storageKey)
|
||||
} catch {
|
||||
logger.warn('Failed to clear tour completion', { storageKey })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks which tours have already attempted auto-start in this page session.
|
||||
* Module-level so it survives component remounts (e.g. navigating between
|
||||
* workflows remounts WorkflowTour), while still resetting on full page reload.
|
||||
*/
|
||||
const autoStartAttempted = new Set<string>()
|
||||
|
||||
/**
|
||||
* Shared hook for managing product tour state with smooth transitions.
|
||||
*
|
||||
* Handles auto-start on first visit, localStorage persistence,
|
||||
* manual triggering via custom events, and coordinated fade
|
||||
* transitions between steps to prevent layout shift.
|
||||
*/
|
||||
export function useTour({
|
||||
steps,
|
||||
storageKey,
|
||||
autoStartDelay = 1200,
|
||||
resettable = false,
|
||||
triggerEvent,
|
||||
tourName = 'tour',
|
||||
disabled = false,
|
||||
}: UseTourOptions): UseTourReturn {
|
||||
const [run, setRun] = useState(false)
|
||||
const [stepIndex, setStepIndex] = useState(0)
|
||||
const [tourKey, setTourKey] = useState(0)
|
||||
const [isTooltipVisible, setIsTooltipVisible] = useState(true)
|
||||
const [isEntrance, setIsEntrance] = useState(true)
|
||||
|
||||
const disabledRef = useRef(disabled)
|
||||
const retriggerTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const transitionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const rafRef = useRef<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
disabledRef.current = disabled
|
||||
}, [disabled])
|
||||
|
||||
/**
|
||||
* Schedules a two-frame rAF to reveal the tooltip after the browser
|
||||
* finishes repositioning. Stores the outer frame ID in `rafRef` so
|
||||
* it can be cancelled on unmount or when the tour is interrupted.
|
||||
*/
|
||||
const scheduleReveal = useCallback(() => {
|
||||
if (rafRef.current) {
|
||||
cancelAnimationFrame(rafRef.current)
|
||||
}
|
||||
rafRef.current = requestAnimationFrame(() => {
|
||||
rafRef.current = requestAnimationFrame(() => {
|
||||
rafRef.current = null
|
||||
setIsTooltipVisible(true)
|
||||
})
|
||||
})
|
||||
}, [])
|
||||
|
||||
/** Cancels any pending transition timer and rAF reveal */
|
||||
const cancelPendingTransitions = useCallback(() => {
|
||||
if (transitionTimerRef.current) {
|
||||
clearTimeout(transitionTimerRef.current)
|
||||
transitionTimerRef.current = null
|
||||
}
|
||||
if (rafRef.current) {
|
||||
cancelAnimationFrame(rafRef.current)
|
||||
rafRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const stopTour = useCallback(() => {
|
||||
cancelPendingTransitions()
|
||||
setRun(false)
|
||||
setIsTooltipVisible(true)
|
||||
setIsEntrance(true)
|
||||
markTourCompleted(storageKey)
|
||||
}, [storageKey, cancelPendingTransitions])
|
||||
|
||||
/** Transition to a new step with a coordinated fade-out/fade-in */
|
||||
const transitionToStep = useCallback(
|
||||
(newIndex: number) => {
|
||||
if (newIndex < 0 || newIndex >= steps.length) {
|
||||
stopTour()
|
||||
return
|
||||
}
|
||||
|
||||
setIsTooltipVisible(false)
|
||||
cancelPendingTransitions()
|
||||
|
||||
transitionTimerRef.current = setTimeout(() => {
|
||||
transitionTimerRef.current = null
|
||||
setStepIndex(newIndex)
|
||||
setIsEntrance(false)
|
||||
scheduleReveal()
|
||||
}, FADE_OUT_MS)
|
||||
},
|
||||
[steps.length, stopTour, cancelPendingTransitions, scheduleReveal]
|
||||
)
|
||||
|
||||
/** Stop the tour when disabled becomes true (e.g. navigating away from the relevant page) */
|
||||
useEffect(() => {
|
||||
if (disabled && run) {
|
||||
cancelPendingTransitions()
|
||||
setRun(false)
|
||||
setIsTooltipVisible(true)
|
||||
setIsEntrance(true)
|
||||
logger.info(`${tourName} paused — disabled became true`)
|
||||
}
|
||||
}, [disabled, run, tourName, cancelPendingTransitions])
|
||||
|
||||
/** Auto-start on first visit (once per page session per tour) */
|
||||
useEffect(() => {
|
||||
if (disabled || autoStartAttempted.has(storageKey) || isTourCompleted(storageKey)) return
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
if (disabledRef.current) return
|
||||
|
||||
autoStartAttempted.add(storageKey)
|
||||
setStepIndex(0)
|
||||
setIsEntrance(true)
|
||||
setIsTooltipVisible(false)
|
||||
setRun(true)
|
||||
logger.info(`Auto-starting ${tourName}`)
|
||||
scheduleReveal()
|
||||
}, autoStartDelay)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [disabled, storageKey, autoStartDelay, tourName, scheduleReveal])
|
||||
|
||||
/** Listen for manual trigger events */
|
||||
useEffect(() => {
|
||||
if (!triggerEvent || !resettable) return
|
||||
|
||||
const handleTrigger = () => {
|
||||
setRun(false)
|
||||
clearTourCompletion(storageKey)
|
||||
setTourKey((k) => k + 1)
|
||||
|
||||
if (retriggerTimerRef.current) {
|
||||
clearTimeout(retriggerTimerRef.current)
|
||||
}
|
||||
|
||||
retriggerTimerRef.current = setTimeout(() => {
|
||||
retriggerTimerRef.current = null
|
||||
setStepIndex(0)
|
||||
setIsEntrance(true)
|
||||
setIsTooltipVisible(false)
|
||||
setRun(true)
|
||||
logger.info(`${tourName} triggered via event`)
|
||||
scheduleReveal()
|
||||
}, 50)
|
||||
}
|
||||
|
||||
window.addEventListener(triggerEvent, handleTrigger)
|
||||
return () => {
|
||||
window.removeEventListener(triggerEvent, handleTrigger)
|
||||
if (retriggerTimerRef.current) {
|
||||
clearTimeout(retriggerTimerRef.current)
|
||||
}
|
||||
}
|
||||
}, [triggerEvent, resettable, storageKey, tourName, scheduleReveal])
|
||||
|
||||
/** Clean up all pending async work on unmount */
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cancelPendingTransitions()
|
||||
if (retriggerTimerRef.current) {
|
||||
clearTimeout(retriggerTimerRef.current)
|
||||
}
|
||||
}
|
||||
}, [cancelPendingTransitions])
|
||||
|
||||
const handleCallback = useCallback(
|
||||
(data: CallBackProps) => {
|
||||
const { action, index, status, type } = data
|
||||
|
||||
if (status === STATUS.FINISHED || status === STATUS.SKIPPED) {
|
||||
stopTour()
|
||||
logger.info(`${tourName} ended`, { status })
|
||||
return
|
||||
}
|
||||
|
||||
if (type === EVENTS.STEP_AFTER || type === EVENTS.TARGET_NOT_FOUND) {
|
||||
if (action === ACTIONS.CLOSE) {
|
||||
stopTour()
|
||||
logger.info(`${tourName} closed by user`)
|
||||
return
|
||||
}
|
||||
|
||||
const nextIndex = index + (action === ACTIONS.PREV ? -1 : 1)
|
||||
|
||||
if (type === EVENTS.TARGET_NOT_FOUND) {
|
||||
logger.info(`${tourName} step target not found, skipping`, {
|
||||
stepIndex: index,
|
||||
target: steps[index]?.target,
|
||||
})
|
||||
}
|
||||
|
||||
transitionToStep(nextIndex)
|
||||
}
|
||||
},
|
||||
[stopTour, transitionToStep, steps, tourName]
|
||||
)
|
||||
|
||||
return {
|
||||
run,
|
||||
stepIndex,
|
||||
tourKey,
|
||||
isTooltipVisible,
|
||||
isEntrance,
|
||||
handleCallback,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { Step } from 'react-joyride'
|
||||
|
||||
export const workflowTourSteps: Step[] = [
|
||||
{
|
||||
target: '[data-tour="canvas"]',
|
||||
title: 'The Canvas',
|
||||
content:
|
||||
'This is where you build visually. Drag blocks onto the canvas and connect them to create AI workflows.',
|
||||
placement: 'center',
|
||||
disableBeacon: true,
|
||||
},
|
||||
{
|
||||
target: '[data-tour="tab-copilot"]',
|
||||
title: 'AI Copilot',
|
||||
content:
|
||||
'Build and debug workflows using natural language. Describe what you want and Copilot creates the blocks for you.',
|
||||
placement: 'bottom',
|
||||
disableBeacon: true,
|
||||
spotlightPadding: 0,
|
||||
},
|
||||
{
|
||||
target: '[data-tour="tab-toolbar"]',
|
||||
title: 'Block Library',
|
||||
content:
|
||||
'Browse all available blocks and triggers. Drag them onto the canvas to build your workflow step by step.',
|
||||
placement: 'bottom',
|
||||
disableBeacon: true,
|
||||
spotlightPadding: 0,
|
||||
},
|
||||
{
|
||||
target: '[data-tour="tab-editor"]',
|
||||
title: 'Block Editor',
|
||||
content:
|
||||
'Click any block on the canvas to configure it here. Set inputs, credentials, and fine-tune behavior.',
|
||||
placement: 'bottom',
|
||||
disableBeacon: true,
|
||||
spotlightPadding: 0,
|
||||
},
|
||||
{
|
||||
target: '[data-tour="deploy-run"]',
|
||||
title: 'Deploy & Run',
|
||||
content:
|
||||
'Deploy your workflow as an API, webhook, schedule, or chat widget. Then hit Run to test it out.',
|
||||
placement: 'bottom',
|
||||
disableBeacon: true,
|
||||
},
|
||||
{
|
||||
target: '[data-tour="workflow-controls"]',
|
||||
title: 'Canvas Controls',
|
||||
content:
|
||||
'Switch between pointer and hand mode, undo or redo changes, and fit the canvas to your view.',
|
||||
placement: 'top',
|
||||
spotlightPadding: 0,
|
||||
disableBeacon: true,
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,62 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import dynamic from 'next/dynamic'
|
||||
import type { TourState } from '@/app/workspace/[workspaceId]/components/product-tour/tour-shared'
|
||||
import {
|
||||
getSharedJoyrideProps,
|
||||
TourStateContext,
|
||||
TourTooltipAdapter,
|
||||
} from '@/app/workspace/[workspaceId]/components/product-tour/tour-shared'
|
||||
import { useTour } from '@/app/workspace/[workspaceId]/components/product-tour/use-tour'
|
||||
import { workflowTourSteps } from '@/app/workspace/[workspaceId]/components/product-tour/workflow-tour-steps'
|
||||
|
||||
const Joyride = dynamic(() => import('react-joyride'), {
|
||||
ssr: false,
|
||||
})
|
||||
|
||||
const WORKFLOW_TOUR_STORAGE_KEY = 'sim-workflow-tour-completed-v1'
|
||||
export const START_WORKFLOW_TOUR_EVENT = 'start-workflow-tour'
|
||||
|
||||
/**
|
||||
* Workflow tour that covers the canvas, blocks, copilot, and deployment.
|
||||
* Runs on first workflow visit and can be retriggered via "Take a tour".
|
||||
*/
|
||||
export function WorkflowTour() {
|
||||
const { run, stepIndex, tourKey, isTooltipVisible, isEntrance, handleCallback } = useTour({
|
||||
steps: workflowTourSteps,
|
||||
storageKey: WORKFLOW_TOUR_STORAGE_KEY,
|
||||
autoStartDelay: 800,
|
||||
resettable: true,
|
||||
triggerEvent: START_WORKFLOW_TOUR_EVENT,
|
||||
tourName: 'Workflow tour',
|
||||
})
|
||||
|
||||
const tourState = useMemo<TourState>(
|
||||
() => ({
|
||||
isTooltipVisible,
|
||||
isEntrance,
|
||||
totalSteps: workflowTourSteps.length,
|
||||
}),
|
||||
[isTooltipVisible, isEntrance]
|
||||
)
|
||||
|
||||
return (
|
||||
<TourStateContext.Provider value={tourState}>
|
||||
<Joyride
|
||||
key={tourKey}
|
||||
steps={workflowTourSteps}
|
||||
run={run}
|
||||
stepIndex={stepIndex}
|
||||
callback={handleCallback}
|
||||
continuous
|
||||
disableScrolling
|
||||
disableScrollParentFix
|
||||
disableOverlayClose
|
||||
spotlightPadding={1}
|
||||
tooltipComponent={TourTooltipAdapter}
|
||||
{...getSharedJoyrideProps({ spotlightBorderRadius: 6 })}
|
||||
/>
|
||||
</TourStateContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -3,6 +3,7 @@ export {
|
||||
assistantMessageHasRenderableContent,
|
||||
MessageContent,
|
||||
} from './message-content'
|
||||
export { MothershipChat } from './mothership-chat/mothership-chat'
|
||||
export { MothershipView } from './mothership-view'
|
||||
export { QueuedMessages } from './queued-messages'
|
||||
export { TemplatePrompts } from './template-prompts'
|
||||
|
||||
@@ -44,6 +44,21 @@ export function AgentGroup({
|
||||
const [expanded, setExpanded] = useState(defaultExpanded || !allDone)
|
||||
const [mounted, setMounted] = useState(defaultExpanded || !allDone)
|
||||
const didAutoCollapseRef = useRef(allDone)
|
||||
const wasAutoExpandedRef = useRef(defaultExpanded)
|
||||
|
||||
useEffect(() => {
|
||||
if (defaultExpanded) {
|
||||
wasAutoExpandedRef.current = true
|
||||
setMounted(true)
|
||||
setExpanded(true)
|
||||
return
|
||||
}
|
||||
|
||||
if (wasAutoExpandedRef.current && allDone) {
|
||||
wasAutoExpandedRef.current = false
|
||||
setExpanded(false)
|
||||
}
|
||||
}, [defaultExpanded, allDone])
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoCollapse || didAutoCollapseRef.current) return
|
||||
@@ -65,7 +80,10 @@ export function AgentGroup({
|
||||
{hasItems ? (
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setExpanded((prev) => !prev)}
|
||||
onClick={() => {
|
||||
wasAutoExpandedRef.current = false
|
||||
setExpanded((prev) => !prev)
|
||||
}}
|
||||
className='flex cursor-pointer items-center gap-[8px]'
|
||||
>
|
||||
<div className='flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center'>
|
||||
|
||||
@@ -389,7 +389,7 @@ export function MessageContent({
|
||||
return (
|
||||
<div key={segment.id} className={isStreaming ? 'animate-stream-fade-in' : undefined}>
|
||||
<AgentGroup
|
||||
key={`${segment.id}-${segment.id === lastOpenSubagentGroupId ? 'expanded' : 'default'}`}
|
||||
key={segment.id}
|
||||
agentName={segment.agentName}
|
||||
agentLabel={segment.agentLabel}
|
||||
items={segment.items}
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
'use client'
|
||||
|
||||
import { useLayoutEffect, useRef } from 'react'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { MessageActions } from '@/app/workspace/[workspaceId]/components'
|
||||
import { ChatMessageAttachments } from '@/app/workspace/[workspaceId]/home/components/chat-message-attachments'
|
||||
import {
|
||||
assistantMessageHasRenderableContent,
|
||||
MessageContent,
|
||||
} from '@/app/workspace/[workspaceId]/home/components/message-content'
|
||||
import { PendingTagIndicator } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
|
||||
import { QueuedMessages } from '@/app/workspace/[workspaceId]/home/components/queued-messages'
|
||||
import { UserInput } from '@/app/workspace/[workspaceId]/home/components/user-input'
|
||||
import { UserMessageContent } from '@/app/workspace/[workspaceId]/home/components/user-message-content'
|
||||
import { useAutoScroll } from '@/app/workspace/[workspaceId]/home/hooks'
|
||||
import type {
|
||||
ChatMessage,
|
||||
FileAttachmentForApi,
|
||||
QueuedMessage,
|
||||
} from '@/app/workspace/[workspaceId]/home/types'
|
||||
import type { ChatContext } from '@/stores/panel'
|
||||
|
||||
interface MothershipChatProps {
|
||||
messages: ChatMessage[]
|
||||
isSending: boolean
|
||||
onSubmit: (
|
||||
text: string,
|
||||
fileAttachments?: FileAttachmentForApi[],
|
||||
contexts?: ChatContext[]
|
||||
) => void
|
||||
onStopGeneration: () => void
|
||||
messageQueue: QueuedMessage[]
|
||||
onRemoveQueuedMessage: (id: string) => void
|
||||
onSendQueuedMessage: (id: string) => Promise<void>
|
||||
onEditQueuedMessage: (id: string) => void
|
||||
userId?: string
|
||||
onContextAdd?: (context: ChatContext) => void
|
||||
editValue?: string
|
||||
onEditValueConsumed?: () => void
|
||||
layout?: 'mothership-view' | 'copilot-view'
|
||||
initialScrollBlocked?: boolean
|
||||
animateInput?: boolean
|
||||
onInputAnimationEnd?: () => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
const LAYOUT_STYLES = {
|
||||
'mothership-view': {
|
||||
scrollContainer:
|
||||
'min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-6 pt-4 pb-8 [scrollbar-gutter:stable]',
|
||||
content: 'mx-auto max-w-[42rem] space-y-6',
|
||||
userRow: 'flex flex-col items-end gap-[6px] pt-3',
|
||||
attachmentWidth: 'max-w-[70%]',
|
||||
userBubble: 'max-w-[70%] overflow-hidden rounded-[16px] bg-[var(--surface-5)] px-3.5 py-2',
|
||||
assistantRow: 'group/msg relative pb-5',
|
||||
footer: 'flex-shrink-0 px-[24px] pb-[16px]',
|
||||
footerInner: 'mx-auto max-w-[42rem]',
|
||||
},
|
||||
'copilot-view': {
|
||||
scrollContainer: 'min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-3 pt-2 pb-4',
|
||||
content: 'space-y-4',
|
||||
userRow: 'flex flex-col items-end gap-[6px] pt-2',
|
||||
attachmentWidth: 'max-w-[85%]',
|
||||
userBubble: 'max-w-[85%] overflow-hidden rounded-[16px] bg-[var(--surface-5)] px-3 py-2',
|
||||
assistantRow: 'group/msg relative pb-3',
|
||||
footer: 'flex-shrink-0 px-3 pb-3',
|
||||
footerInner: '',
|
||||
},
|
||||
} as const
|
||||
|
||||
export function MothershipChat({
|
||||
messages,
|
||||
isSending,
|
||||
onSubmit,
|
||||
onStopGeneration,
|
||||
messageQueue,
|
||||
onRemoveQueuedMessage,
|
||||
onSendQueuedMessage,
|
||||
onEditQueuedMessage,
|
||||
userId,
|
||||
onContextAdd,
|
||||
editValue,
|
||||
onEditValueConsumed,
|
||||
layout = 'mothership-view',
|
||||
initialScrollBlocked = false,
|
||||
animateInput = false,
|
||||
onInputAnimationEnd,
|
||||
className,
|
||||
}: MothershipChatProps) {
|
||||
const styles = LAYOUT_STYLES[layout]
|
||||
const { ref: scrollContainerRef, scrollToBottom } = useAutoScroll(isSending)
|
||||
const hasMessages = messages.length > 0
|
||||
const initialScrollDoneRef = useRef(false)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!hasMessages) {
|
||||
initialScrollDoneRef.current = false
|
||||
return
|
||||
}
|
||||
if (initialScrollDoneRef.current || initialScrollBlocked) return
|
||||
initialScrollDoneRef.current = true
|
||||
scrollToBottom()
|
||||
}, [hasMessages, initialScrollBlocked, scrollToBottom])
|
||||
|
||||
return (
|
||||
<div className={cn('flex h-full min-h-0 flex-col', className)}>
|
||||
<div ref={scrollContainerRef} className={styles.scrollContainer}>
|
||||
<div className={styles.content}>
|
||||
{messages.map((msg, index) => {
|
||||
if (msg.role === 'user') {
|
||||
const hasAttachments = Boolean(msg.attachments?.length)
|
||||
return (
|
||||
<div key={msg.id} className={styles.userRow}>
|
||||
{hasAttachments && (
|
||||
<ChatMessageAttachments
|
||||
attachments={msg.attachments ?? []}
|
||||
align='end'
|
||||
className={styles.attachmentWidth}
|
||||
/>
|
||||
)}
|
||||
<div className={styles.userBubble}>
|
||||
<UserMessageContent content={msg.content} contexts={msg.contexts} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const hasAnyBlocks = Boolean(msg.contentBlocks?.length)
|
||||
const hasRenderableAssistant = assistantMessageHasRenderableContent(
|
||||
msg.contentBlocks ?? [],
|
||||
msg.content ?? ''
|
||||
)
|
||||
const isLastAssistant = index === messages.length - 1
|
||||
const isThisStreaming = isSending && isLastAssistant
|
||||
|
||||
if (!hasAnyBlocks && !msg.content?.trim() && isThisStreaming) {
|
||||
return <PendingTagIndicator key={msg.id} />
|
||||
}
|
||||
|
||||
if (!hasRenderableAssistant && !msg.content?.trim() && !isThisStreaming) {
|
||||
return null
|
||||
}
|
||||
|
||||
const isLastMessage = index === messages.length - 1
|
||||
|
||||
return (
|
||||
<div key={msg.id} className={styles.assistantRow}>
|
||||
{!isThisStreaming && (msg.content || msg.contentBlocks?.length) && (
|
||||
<div className='absolute right-0 bottom-0 z-10'>
|
||||
<MessageActions content={msg.content} requestId={msg.requestId} />
|
||||
</div>
|
||||
)}
|
||||
<MessageContent
|
||||
blocks={msg.contentBlocks || []}
|
||||
fallbackContent={msg.content}
|
||||
isStreaming={isThisStreaming}
|
||||
onOptionSelect={isLastMessage ? onSubmit : undefined}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(styles.footer, animateInput && 'animate-slide-in-bottom')}
|
||||
onAnimationEnd={animateInput ? onInputAnimationEnd : undefined}
|
||||
>
|
||||
<div className={styles.footerInner}>
|
||||
<QueuedMessages
|
||||
messageQueue={messageQueue}
|
||||
onRemove={onRemoveQueuedMessage}
|
||||
onSendNow={onSendQueuedMessage}
|
||||
onEdit={onEditQueuedMessage}
|
||||
/>
|
||||
<UserInput
|
||||
onSubmit={onSubmit}
|
||||
isSending={isSending}
|
||||
onStopGeneration={onStopGeneration}
|
||||
isInitialView={false}
|
||||
userId={userId}
|
||||
onContextAdd={onContextAdd}
|
||||
editValue={editValue}
|
||||
onEditValueConsumed={onEditValueConsumed}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -106,6 +106,7 @@ const SEND_BUTTON_ACTIVE =
|
||||
const SEND_BUTTON_DISABLED = 'bg-[var(--c-808080)] dark:bg-[var(--c-808080)]'
|
||||
|
||||
const MAX_CHAT_TEXTAREA_HEIGHT = 200
|
||||
const SPEECH_RECOGNITION_LANG = 'en-US'
|
||||
|
||||
const DROP_OVERLAY_ICONS = [
|
||||
PdfIcon,
|
||||
@@ -267,6 +268,7 @@ export function UserInput({
|
||||
const [isListening, setIsListening] = useState(false)
|
||||
const recognitionRef = useRef<SpeechRecognitionInstance | null>(null)
|
||||
const prefixRef = useRef('')
|
||||
const valueRef = useRef(value)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -274,6 +276,10 @@ export function UserInput({
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
valueRef.current = value
|
||||
}, [value])
|
||||
|
||||
const textareaRef = mentionMenu.textareaRef
|
||||
const wasSendingRef = useRef(false)
|
||||
const atInsertPosRef = useRef<number | null>(null)
|
||||
@@ -390,6 +396,84 @@ export function UserInput({
|
||||
[textareaRef]
|
||||
)
|
||||
|
||||
const startRecognition = useCallback((): boolean => {
|
||||
const w = window as WindowWithSpeech
|
||||
const SpeechRecognitionAPI = w.SpeechRecognition || w.webkitSpeechRecognition
|
||||
if (!SpeechRecognitionAPI) return false
|
||||
|
||||
const recognition = new SpeechRecognitionAPI()
|
||||
recognition.continuous = true
|
||||
recognition.interimResults = true
|
||||
recognition.lang = SPEECH_RECOGNITION_LANG
|
||||
|
||||
recognition.onresult = (event: SpeechRecognitionEvent) => {
|
||||
let transcript = ''
|
||||
for (let i = 0; i < event.results.length; i++) {
|
||||
transcript += event.results[i][0].transcript
|
||||
}
|
||||
const prefix = prefixRef.current
|
||||
const newVal = prefix ? `${prefix} ${transcript}` : transcript
|
||||
setValue(newVal)
|
||||
valueRef.current = newVal
|
||||
}
|
||||
|
||||
recognition.onend = () => {
|
||||
if (recognitionRef.current === recognition) {
|
||||
prefixRef.current = valueRef.current
|
||||
try {
|
||||
recognition.start()
|
||||
} catch {
|
||||
recognitionRef.current = null
|
||||
setIsListening(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
recognition.onerror = (e: SpeechRecognitionErrorEvent) => {
|
||||
if (recognitionRef.current !== recognition) return
|
||||
if (e.error === 'aborted' || e.error === 'not-allowed') {
|
||||
recognitionRef.current = null
|
||||
setIsListening(false)
|
||||
}
|
||||
}
|
||||
|
||||
recognitionRef.current = recognition
|
||||
try {
|
||||
recognition.start()
|
||||
return true
|
||||
} catch {
|
||||
recognitionRef.current = null
|
||||
return false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const restartRecognition = useCallback(
|
||||
(newPrefix: string) => {
|
||||
if (!recognitionRef.current) return
|
||||
prefixRef.current = newPrefix
|
||||
recognitionRef.current.abort()
|
||||
recognitionRef.current = null
|
||||
if (!startRecognition()) {
|
||||
setIsListening(false)
|
||||
}
|
||||
},
|
||||
[startRecognition]
|
||||
)
|
||||
|
||||
const toggleListening = useCallback(() => {
|
||||
if (isListening) {
|
||||
recognitionRef.current?.stop()
|
||||
recognitionRef.current = null
|
||||
setIsListening(false)
|
||||
return
|
||||
}
|
||||
|
||||
prefixRef.current = value
|
||||
if (startRecognition()) {
|
||||
setIsListening(true)
|
||||
}
|
||||
}, [isListening, value, startRecognition])
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
const fileAttachmentsForApi: FileAttachmentForApi[] = files.attachedFiles
|
||||
.filter((f) => !f.uploading && f.key)
|
||||
@@ -407,13 +491,14 @@ export function UserInput({
|
||||
contextManagement.selectedContexts.length > 0 ? contextManagement.selectedContexts : undefined
|
||||
)
|
||||
setValue('')
|
||||
restartRecognition('')
|
||||
files.clearAttachedFiles()
|
||||
contextManagement.clearContexts()
|
||||
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto'
|
||||
}
|
||||
}, [onSubmit, files, value, contextManagement, textareaRef])
|
||||
}, [onSubmit, files, value, contextManagement, textareaRef, restartRecognition])
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
@@ -488,27 +573,33 @@ export function UserInput({
|
||||
[handleSubmit, mentionTokensWithContext, value, textareaRef]
|
||||
)
|
||||
|
||||
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const newValue = e.target.value
|
||||
const caret = e.target.selectionStart ?? newValue.length
|
||||
const handleInputChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const newValue = e.target.value
|
||||
const caret = e.target.selectionStart ?? newValue.length
|
||||
|
||||
if (
|
||||
caret > 0 &&
|
||||
newValue.charAt(caret - 1) === '@' &&
|
||||
(caret === 1 || /\s/.test(newValue.charAt(caret - 2)))
|
||||
) {
|
||||
const before = newValue.slice(0, caret - 1)
|
||||
const after = newValue.slice(caret)
|
||||
setValue(`${before}${after}`)
|
||||
atInsertPosRef.current = caret - 1
|
||||
setPlusMenuOpen(true)
|
||||
setPlusMenuSearch('')
|
||||
setPlusMenuActiveIndex(0)
|
||||
return
|
||||
}
|
||||
if (
|
||||
caret > 0 &&
|
||||
newValue.charAt(caret - 1) === '@' &&
|
||||
(caret === 1 || /\s/.test(newValue.charAt(caret - 2)))
|
||||
) {
|
||||
const before = newValue.slice(0, caret - 1)
|
||||
const after = newValue.slice(caret)
|
||||
const adjusted = `${before}${after}`
|
||||
setValue(adjusted)
|
||||
atInsertPosRef.current = caret - 1
|
||||
setPlusMenuOpen(true)
|
||||
setPlusMenuSearch('')
|
||||
setPlusMenuActiveIndex(0)
|
||||
restartRecognition(adjusted)
|
||||
return
|
||||
}
|
||||
|
||||
setValue(newValue)
|
||||
}, [])
|
||||
setValue(newValue)
|
||||
restartRecognition(newValue)
|
||||
},
|
||||
[restartRecognition]
|
||||
)
|
||||
|
||||
const handleSelectAdjust = useCallback(() => {
|
||||
const textarea = textareaRef.current
|
||||
@@ -536,56 +627,6 @@ export function UserInput({
|
||||
[isInitialView]
|
||||
)
|
||||
|
||||
const toggleListening = useCallback(() => {
|
||||
if (isListening) {
|
||||
recognitionRef.current?.stop()
|
||||
recognitionRef.current = null
|
||||
setIsListening(false)
|
||||
return
|
||||
}
|
||||
|
||||
const w = window as WindowWithSpeech
|
||||
const SpeechRecognitionAPI = w.SpeechRecognition || w.webkitSpeechRecognition
|
||||
if (!SpeechRecognitionAPI) return
|
||||
|
||||
prefixRef.current = value
|
||||
|
||||
const recognition = new SpeechRecognitionAPI()
|
||||
recognition.continuous = true
|
||||
recognition.interimResults = true
|
||||
recognition.lang = 'en-US'
|
||||
|
||||
recognition.onresult = (event: SpeechRecognitionEvent) => {
|
||||
let transcript = ''
|
||||
for (let i = 0; i < event.results.length; i++) {
|
||||
transcript += event.results[i][0].transcript
|
||||
}
|
||||
const prefix = prefixRef.current
|
||||
setValue(prefix ? `${prefix} ${transcript}` : transcript)
|
||||
}
|
||||
|
||||
recognition.onend = () => {
|
||||
if (recognitionRef.current === recognition) {
|
||||
try {
|
||||
recognition.start()
|
||||
} catch {
|
||||
recognitionRef.current = null
|
||||
setIsListening(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
recognition.onerror = (e: SpeechRecognitionErrorEvent) => {
|
||||
if (e.error === 'aborted' || e.error === 'not-allowed') {
|
||||
recognitionRef.current = null
|
||||
setIsListening(false)
|
||||
}
|
||||
}
|
||||
|
||||
recognitionRef.current = recognition
|
||||
recognition.start()
|
||||
setIsListening(true)
|
||||
}, [isListening, value])
|
||||
|
||||
const renderOverlayContent = useCallback(() => {
|
||||
const contexts = contextManagement.selectedContexts
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { PanelLeft } from '@/components/emcn/icons'
|
||||
@@ -11,21 +11,10 @@ import {
|
||||
LandingWorkflowSeedStorage,
|
||||
} from '@/lib/core/utils/browser-storage'
|
||||
import { persistImportedWorkflow } from '@/lib/workflows/operations/import-export'
|
||||
import { MessageActions } from '@/app/workspace/[workspaceId]/components'
|
||||
import { useChatHistory, useMarkTaskRead } from '@/hooks/queries/tasks'
|
||||
import type { ChatContext } from '@/stores/panel'
|
||||
import {
|
||||
assistantMessageHasRenderableContent,
|
||||
ChatMessageAttachments,
|
||||
MessageContent,
|
||||
MothershipView,
|
||||
QueuedMessages,
|
||||
TemplatePrompts,
|
||||
UserInput,
|
||||
UserMessageContent,
|
||||
} from './components'
|
||||
import { PendingTagIndicator } from './components/message-content/components/special-tags'
|
||||
import { useAutoScroll, useChat, useMothershipResize } from './hooks'
|
||||
import { MothershipChat, MothershipView, TemplatePrompts, UserInput } from './components'
|
||||
import { getMothershipUseChatOptions, useChat, useMothershipResize } from './hooks'
|
||||
import type { FileAttachmentForApi, MothershipResource, MothershipResourceType } from './types'
|
||||
|
||||
const logger = createLogger('Home')
|
||||
@@ -173,7 +162,11 @@ export function Home({ chatId }: HomeProps = {}) {
|
||||
sendNow,
|
||||
editQueuedMessage,
|
||||
streamingFile,
|
||||
} = useChat(workspaceId, chatId, { onResourceEvent: handleResourceEvent })
|
||||
} = useChat(
|
||||
workspaceId,
|
||||
chatId,
|
||||
getMothershipUseChatOptions({ onResourceEvent: handleResourceEvent })
|
||||
)
|
||||
|
||||
const [editingInputValue, setEditingInputValue] = useState('')
|
||||
const [prevChatId, setPrevChatId] = useState(chatId)
|
||||
@@ -285,22 +278,7 @@ export function Home({ chatId }: HomeProps = {}) {
|
||||
[addResource, handleResourceEvent]
|
||||
)
|
||||
|
||||
const { ref: scrollContainerRef, scrollToBottom } = useAutoScroll(isSending)
|
||||
|
||||
const hasMessages = messages.length > 0
|
||||
const initialScrollDoneRef = useRef(false)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!hasMessages) {
|
||||
initialScrollDoneRef.current = false
|
||||
return
|
||||
}
|
||||
if (initialScrollDoneRef.current) return
|
||||
if (resources.length > 0 && isResourceCollapsed) return
|
||||
|
||||
initialScrollDoneRef.current = true
|
||||
scrollToBottom()
|
||||
}, [hasMessages, resources.length, isResourceCollapsed, scrollToBottom])
|
||||
|
||||
useEffect(() => {
|
||||
if (hasMessages) return
|
||||
@@ -322,11 +300,14 @@ export function Home({ chatId }: HomeProps = {}) {
|
||||
return (
|
||||
<div className='h-full overflow-y-auto bg-[var(--bg)] [scrollbar-gutter:stable]'>
|
||||
<div className='flex min-h-full flex-col items-center justify-center px-[24px] pb-[2vh]'>
|
||||
<h1 className='mb-[24px] max-w-[42rem] font-[430] font-season text-[32px] text-[var(--text-primary)] tracking-[-0.02em]'>
|
||||
<h1
|
||||
data-tour='home-greeting'
|
||||
className='mb-[24px] max-w-[42rem] font-[430] font-season text-[32px] text-[var(--text-primary)] tracking-[-0.02em]'
|
||||
>
|
||||
What should we get done
|
||||
{session?.user?.name ? `, ${session.user.name.split(' ')[0]}` : ''}?
|
||||
</h1>
|
||||
<div ref={initialViewInputRef} className='w-full'>
|
||||
<div ref={initialViewInputRef} className='w-full' data-tour='home-chat-input'>
|
||||
<UserInput
|
||||
defaultValue={initialPrompt}
|
||||
onSubmit={handleSubmit}
|
||||
@@ -339,6 +320,7 @@ export function Home({ chatId }: HomeProps = {}) {
|
||||
</div>
|
||||
<div
|
||||
ref={templateRef}
|
||||
data-tour='home-templates'
|
||||
className='-mt-[30vh] mx-auto w-full max-w-[68rem] px-[16px] pb-[32px] sm:px-[24px] lg:px-[40px]'
|
||||
>
|
||||
<TemplatePrompts onSelect={handleSubmit} />
|
||||
@@ -350,90 +332,23 @@ export function Home({ chatId }: HomeProps = {}) {
|
||||
return (
|
||||
<div className='relative flex h-full bg-[var(--bg)]'>
|
||||
<div className='flex h-full min-w-[320px] flex-1 flex-col'>
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className='min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-6 pt-4 pb-8 [scrollbar-gutter:stable]'
|
||||
>
|
||||
<div className='mx-auto max-w-[42rem] space-y-6'>
|
||||
{messages.map((msg, index) => {
|
||||
if (msg.role === 'user') {
|
||||
const hasAttachments = msg.attachments && msg.attachments.length > 0
|
||||
return (
|
||||
<div key={msg.id} className='flex flex-col items-end gap-[6px] pt-3'>
|
||||
{hasAttachments && (
|
||||
<ChatMessageAttachments
|
||||
attachments={msg.attachments!}
|
||||
align='end'
|
||||
className='max-w-[70%]'
|
||||
/>
|
||||
)}
|
||||
<div className='max-w-[70%] overflow-hidden rounded-[16px] bg-[var(--surface-5)] px-3.5 py-2'>
|
||||
<UserMessageContent content={msg.content} contexts={msg.contexts} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const hasAnyBlocks = Boolean(msg.contentBlocks?.length)
|
||||
const hasRenderableAssistant = assistantMessageHasRenderableContent(
|
||||
msg.contentBlocks ?? [],
|
||||
msg.content ?? ''
|
||||
)
|
||||
const isLastAssistant = msg.role === 'assistant' && index === messages.length - 1
|
||||
const isThisStreaming = isSending && isLastAssistant
|
||||
|
||||
if (!hasAnyBlocks && !msg.content?.trim() && isThisStreaming) {
|
||||
return <PendingTagIndicator key={msg.id} />
|
||||
}
|
||||
|
||||
if (!hasRenderableAssistant && !msg.content?.trim() && !isThisStreaming) {
|
||||
return null
|
||||
}
|
||||
|
||||
const isLastMessage = index === messages.length - 1
|
||||
|
||||
return (
|
||||
<div key={msg.id} className='group/msg relative pb-5'>
|
||||
{!isThisStreaming && (msg.content || msg.contentBlocks?.length) && (
|
||||
<div className='absolute right-0 bottom-0 z-10'>
|
||||
<MessageActions content={msg.content} requestId={msg.requestId} />
|
||||
</div>
|
||||
)}
|
||||
<MessageContent
|
||||
blocks={msg.contentBlocks || []}
|
||||
fallbackContent={msg.content}
|
||||
isStreaming={isThisStreaming}
|
||||
onOptionSelect={isLastMessage ? sendMessage : undefined}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`flex-shrink-0 px-[24px] pb-[16px]${isInputEntering ? ' animate-slide-in-bottom' : ''}`}
|
||||
onAnimationEnd={isInputEntering ? () => setIsInputEntering(false) : undefined}
|
||||
>
|
||||
<div className='mx-auto max-w-[42rem]'>
|
||||
<QueuedMessages
|
||||
messageQueue={messageQueue}
|
||||
onRemove={removeFromQueue}
|
||||
onSendNow={sendNow}
|
||||
onEdit={handleEditQueuedMessage}
|
||||
/>
|
||||
<UserInput
|
||||
onSubmit={handleSubmit}
|
||||
isSending={isSending}
|
||||
onStopGeneration={stopGeneration}
|
||||
isInitialView={false}
|
||||
userId={session?.user?.id}
|
||||
onContextAdd={handleContextAdd}
|
||||
editValue={editingInputValue}
|
||||
onEditValueConsumed={clearEditingValue}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<MothershipChat
|
||||
messages={messages}
|
||||
isSending={isSending}
|
||||
onSubmit={handleSubmit}
|
||||
onStopGeneration={stopGeneration}
|
||||
messageQueue={messageQueue}
|
||||
onRemoveQueuedMessage={removeFromQueue}
|
||||
onSendQueuedMessage={sendNow}
|
||||
onEditQueuedMessage={handleEditQueuedMessage}
|
||||
userId={session?.user?.id}
|
||||
onContextAdd={handleContextAdd}
|
||||
editValue={editingInputValue}
|
||||
onEditValueConsumed={clearEditingValue}
|
||||
animateInput={isInputEntering}
|
||||
onInputAnimationEnd={isInputEntering ? () => setIsInputEntering(false) : undefined}
|
||||
initialScrollBlocked={resources.length > 0 && isResourceCollapsed}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Resize handle — zero-width flex child whose absolute child straddles the border */}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
export { useAnimatedPlaceholder } from './use-animated-placeholder'
|
||||
export { useAutoScroll } from './use-auto-scroll'
|
||||
export type { UseChatReturn } from './use-chat'
|
||||
export { useChat } from './use-chat'
|
||||
export {
|
||||
getMothershipUseChatOptions,
|
||||
getWorkflowCopilotUseChatOptions,
|
||||
useChat,
|
||||
} from './use-chat'
|
||||
export { useMothershipResize } from './use-mothership-resize'
|
||||
export { useStreamingReveal } from './use-streaming-reveal'
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
markRunToolManuallyStopped,
|
||||
reportManualRunToolStop,
|
||||
} from '@/lib/copilot/client-sse/run-tool-execution'
|
||||
import { MOTHERSHIP_CHAT_API_PATH } from '@/lib/copilot/constants'
|
||||
import { COPILOT_CHAT_API_PATH, MOTHERSHIP_CHAT_API_PATH } from '@/lib/copilot/constants'
|
||||
import {
|
||||
extractResourcesFromToolResult,
|
||||
isResourceToolName,
|
||||
@@ -263,6 +263,29 @@ export interface UseChatOptions {
|
||||
onStreamEnd?: (chatId: string, messages: ChatMessage[]) => void
|
||||
}
|
||||
|
||||
export function getMothershipUseChatOptions(
|
||||
options: Pick<UseChatOptions, 'onResourceEvent' | 'onStreamEnd'> = {}
|
||||
): UseChatOptions {
|
||||
return {
|
||||
apiPath: MOTHERSHIP_CHAT_API_PATH,
|
||||
stopPath: '/api/mothership/chat/stop',
|
||||
...options,
|
||||
}
|
||||
}
|
||||
|
||||
export function getWorkflowCopilotUseChatOptions(
|
||||
options: Pick<
|
||||
UseChatOptions,
|
||||
'workflowId' | 'onToolResult' | 'onTitleUpdate' | 'onStreamEnd'
|
||||
> = {}
|
||||
): UseChatOptions {
|
||||
return {
|
||||
apiPath: COPILOT_CHAT_API_PATH,
|
||||
stopPath: '/api/mothership/chat/stop',
|
||||
...options,
|
||||
}
|
||||
}
|
||||
|
||||
export function useChat(
|
||||
workspaceId: string,
|
||||
initialChatId?: string,
|
||||
@@ -323,8 +346,8 @@ export function useChat(
|
||||
reader: ReadableStreamDefaultReader<Uint8Array>,
|
||||
assistantId: string,
|
||||
expectedGen?: number
|
||||
) => Promise<void>
|
||||
>(async () => {})
|
||||
) => Promise<boolean>
|
||||
>(async () => false)
|
||||
const finalizeRef = useRef<(options?: { error?: boolean }) => void>(() => {})
|
||||
|
||||
const abortControllerRef = useRef<AbortController | null>(null)
|
||||
@@ -415,6 +438,8 @@ export function useChat(
|
||||
setIsReconnecting(false)
|
||||
setResources([])
|
||||
setActiveResourceId(null)
|
||||
setStreamingFile(null)
|
||||
streamingFileRef.current = null
|
||||
setMessageQueue([])
|
||||
}, [initialChatId, queryClient])
|
||||
|
||||
@@ -433,6 +458,8 @@ export function useChat(
|
||||
setIsReconnecting(false)
|
||||
setResources([])
|
||||
setActiveResourceId(null)
|
||||
setStreamingFile(null)
|
||||
streamingFileRef.current = null
|
||||
setMessageQueue([])
|
||||
}, [isHomePage])
|
||||
|
||||
@@ -441,12 +468,6 @@ export function useChat(
|
||||
|
||||
const activeStreamId = chatHistory.activeStreamId
|
||||
const snapshot = chatHistory.streamSnapshot
|
||||
|
||||
if (activeStreamId && !snapshot && !sendingRef.current) {
|
||||
queryClient.invalidateQueries({ queryKey: taskKeys.detail(chatHistory.id) })
|
||||
return
|
||||
}
|
||||
|
||||
appliedChatIdRef.current = chatHistory.id
|
||||
const mappedMessages = chatHistory.messages.map(mapStoredMessage)
|
||||
const shouldPreserveActiveStreamingMessage =
|
||||
@@ -497,7 +518,6 @@ export function useChat(
|
||||
}
|
||||
|
||||
if (activeStreamId && !sendingRef.current) {
|
||||
abortControllerRef.current?.abort()
|
||||
const gen = ++streamGenRef.current
|
||||
const abortController = new AbortController()
|
||||
abortControllerRef.current = abortController
|
||||
@@ -508,6 +528,7 @@ export function useChat(
|
||||
const assistantId = crypto.randomUUID()
|
||||
|
||||
const reconnect = async () => {
|
||||
let reconnectFailed = false
|
||||
try {
|
||||
const encoder = new TextEncoder()
|
||||
|
||||
@@ -515,14 +536,8 @@ export function useChat(
|
||||
const streamStatus = snapshot?.status ?? ''
|
||||
|
||||
if (batchEvents.length === 0 && streamStatus === 'unknown') {
|
||||
const cid = chatIdRef.current
|
||||
if (cid) {
|
||||
fetch(stopPathRef.current, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ chatId: cid, streamId: activeStreamId, content: '' }),
|
||||
}).catch(() => {})
|
||||
}
|
||||
reconnectFailed = true
|
||||
setError(RECONNECT_TAIL_ERROR)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -550,6 +565,7 @@ export function useChat(
|
||||
{ signal: abortController.signal }
|
||||
)
|
||||
if (!sseRes.ok || !sseRes.body) {
|
||||
reconnectFailed = true
|
||||
logger.warn('SSE tail reconnect returned no readable body', {
|
||||
status: sseRes.status,
|
||||
streamId: activeStreamId,
|
||||
@@ -565,6 +581,7 @@ export function useChat(
|
||||
}
|
||||
} catch (err) {
|
||||
if (!(err instanceof Error && err.name === 'AbortError')) {
|
||||
reconnectFailed = true
|
||||
logger.warn('SSE tail failed during reconnect', err)
|
||||
setError(RECONNECT_TAIL_ERROR)
|
||||
}
|
||||
@@ -575,13 +592,21 @@ export function useChat(
|
||||
},
|
||||
})
|
||||
|
||||
await processSSEStreamRef.current(combinedStream.getReader(), assistantId, gen)
|
||||
const hadStreamError = await processSSEStreamRef.current(
|
||||
combinedStream.getReader(),
|
||||
assistantId,
|
||||
gen
|
||||
)
|
||||
if (hadStreamError) {
|
||||
reconnectFailed = true
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name === 'AbortError') return
|
||||
reconnectFailed = true
|
||||
} finally {
|
||||
setIsReconnecting(false)
|
||||
if (streamGenRef.current === gen) {
|
||||
finalizeRef.current()
|
||||
finalizeRef.current(reconnectFailed ? { error: true } : undefined)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -619,7 +644,34 @@ export function useChat(
|
||||
return b
|
||||
}
|
||||
|
||||
const appendInlineErrorTag = (tag: string) => {
|
||||
if (runningText.includes(tag)) return
|
||||
const tb = ensureTextBlock()
|
||||
const prefix = runningText.length > 0 && !runningText.endsWith('\n') ? '\n' : ''
|
||||
tb.content = `${tb.content ?? ''}${prefix}${tag}`
|
||||
if (activeSubagent) tb.subagent = activeSubagent
|
||||
runningText += `${prefix}${tag}`
|
||||
streamingContentRef.current = runningText
|
||||
flush()
|
||||
}
|
||||
|
||||
const buildInlineErrorTag = (payload: SSEPayload) => {
|
||||
const data = getPayloadData(payload) as Record<string, unknown> | undefined
|
||||
const message =
|
||||
(data?.displayMessage as string | undefined) ||
|
||||
payload.error ||
|
||||
'An unexpected error occurred'
|
||||
const provider = (data?.provider as string | undefined) || undefined
|
||||
const code = (data?.code as string | undefined) || undefined
|
||||
return `<mothership-error>${JSON.stringify({
|
||||
message,
|
||||
...(code ? { code } : {}),
|
||||
...(provider ? { provider } : {}),
|
||||
})}</mothership-error>`
|
||||
}
|
||||
|
||||
const isStale = () => expectedGen !== undefined && streamGenRef.current !== expectedGen
|
||||
let sawStreamError = false
|
||||
|
||||
const flush = () => {
|
||||
if (isStale()) return
|
||||
@@ -644,12 +696,9 @@ export function useChat(
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
if (isStale()) {
|
||||
reader.cancel().catch(() => {})
|
||||
break
|
||||
}
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
if (isStale()) continue
|
||||
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const lines = buffer.split('\n')
|
||||
@@ -1113,21 +1162,20 @@ export function useChat(
|
||||
break
|
||||
}
|
||||
case 'error': {
|
||||
sawStreamError = true
|
||||
setError(parsed.error || 'An error occurred')
|
||||
appendInlineErrorTag(buildInlineErrorTag(parsed))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isStale()) {
|
||||
reader.cancel().catch(() => {})
|
||||
break
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (streamReaderRef.current === reader) {
|
||||
streamReaderRef.current = null
|
||||
}
|
||||
}
|
||||
return sawStreamError
|
||||
},
|
||||
[workspaceId, queryClient, addResource, removeResource]
|
||||
)
|
||||
@@ -1354,7 +1402,10 @@ export function useChat(
|
||||
|
||||
if (!response.body) throw new Error('No response body')
|
||||
|
||||
await processSSEStream(response.body.getReader(), assistantId, gen)
|
||||
const hadStreamError = await processSSEStream(response.body.getReader(), assistantId, gen)
|
||||
if (streamGenRef.current === gen) {
|
||||
finalize(hadStreamError ? { error: true } : undefined)
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name === 'AbortError') return
|
||||
setError(err instanceof Error ? err.message : 'Failed to send message')
|
||||
@@ -1363,9 +1414,6 @@ export function useChat(
|
||||
}
|
||||
return
|
||||
}
|
||||
if (streamGenRef.current === gen) {
|
||||
finalize()
|
||||
}
|
||||
},
|
||||
[workspaceId, queryClient, processSSEStream, finalize]
|
||||
)
|
||||
@@ -1387,6 +1435,25 @@ export function useChat(
|
||||
sendingRef.current = false
|
||||
setIsSending(false)
|
||||
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) => {
|
||||
if (!msg.contentBlocks?.some((b) => b.toolCall?.status === 'executing')) return msg
|
||||
const updated = msg.contentBlocks!.map((block) => {
|
||||
if (block.toolCall?.status !== 'executing') return block
|
||||
return {
|
||||
...block,
|
||||
toolCall: {
|
||||
...block.toolCall,
|
||||
status: 'cancelled' as const,
|
||||
displayTitle: 'Stopped by user',
|
||||
},
|
||||
}
|
||||
})
|
||||
updated.push({ type: 'stopped' as const })
|
||||
return { ...msg, contentBlocks: updated }
|
||||
})
|
||||
)
|
||||
|
||||
if (sid) {
|
||||
fetch('/api/copilot/chat/abort', {
|
||||
method: 'POST',
|
||||
@@ -1410,25 +1477,6 @@ export function useChat(
|
||||
streamingFileRef.current = null
|
||||
setResources((rs) => rs.filter((resource) => resource.id !== 'streaming-file'))
|
||||
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) => {
|
||||
if (!msg.contentBlocks?.some((b) => b.toolCall?.status === 'executing')) return msg
|
||||
const updated = msg.contentBlocks!.map((block) => {
|
||||
if (block.toolCall?.status !== 'executing') return block
|
||||
return {
|
||||
...block,
|
||||
toolCall: {
|
||||
...block.toolCall,
|
||||
status: 'cancelled' as const,
|
||||
displayTitle: 'Stopped by user',
|
||||
},
|
||||
}
|
||||
})
|
||||
updated.push({ type: 'stopped' as const })
|
||||
return { ...msg, contentBlocks: updated }
|
||||
})
|
||||
)
|
||||
|
||||
const execState = useExecutionStore.getState()
|
||||
const consoleStore = useTerminalConsoleStore.getState()
|
||||
for (const [workflowId, wfExec] of execState.workflowExecutions) {
|
||||
@@ -1500,7 +1548,6 @@ export function useChat(
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
streamReaderRef.current?.cancel().catch(() => {})
|
||||
streamReaderRef.current = null
|
||||
abortControllerRef.current = null
|
||||
streamGenRef.current++
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Banner } from '@/components/emcn'
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
import { useStopImpersonating } from '@/hooks/queries/admin-users'
|
||||
|
||||
function getImpersonationBannerText(userLabel: string, userEmail?: string) {
|
||||
return `Impersonating ${userLabel}${userEmail ? ` (${userEmail})` : ''}. Changes will apply to this account until you switch back.`
|
||||
}
|
||||
|
||||
export function ImpersonationBanner() {
|
||||
const { data: session, isPending } = useSession()
|
||||
const stopImpersonating = useStopImpersonating()
|
||||
const [isRedirecting, setIsRedirecting] = useState(false)
|
||||
const userLabel = session?.user?.name || 'this user'
|
||||
const userEmail = session?.user?.email
|
||||
|
||||
if (isPending || !session?.session?.impersonatedBy) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Banner
|
||||
variant='destructive'
|
||||
text={getImpersonationBannerText(userLabel, userEmail)}
|
||||
textClassName='text-red-700 dark:text-red-300'
|
||||
actionLabel={
|
||||
stopImpersonating.isPending || isRedirecting ? 'Returning...' : 'Stop impersonating'
|
||||
}
|
||||
actionVariant='destructive'
|
||||
actionDisabled={stopImpersonating.isPending || isRedirecting}
|
||||
onAction={() =>
|
||||
stopImpersonating.mutate(undefined, {
|
||||
onError: () => {
|
||||
setIsRedirecting(false)
|
||||
},
|
||||
onSuccess: () => {
|
||||
setIsRedirecting(true)
|
||||
window.location.assign('/workspace')
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import { ToastProvider } from '@/components/emcn'
|
||||
import { NavTour } from '@/app/workspace/[workspaceId]/components/product-tour'
|
||||
import { ImpersonationBanner } from '@/app/workspace/[workspaceId]/impersonation-banner'
|
||||
import { GlobalCommandsProvider } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
|
||||
import { ProviderModelsLoader } from '@/app/workspace/[workspaceId]/providers/provider-models-loader'
|
||||
import { SettingsLoader } from '@/app/workspace/[workspaceId]/providers/settings-loader'
|
||||
@@ -11,16 +13,20 @@ export default function WorkspaceLayout({ children }: { children: React.ReactNod
|
||||
<SettingsLoader />
|
||||
<ProviderModelsLoader />
|
||||
<GlobalCommandsProvider>
|
||||
<div className='flex h-screen w-full bg-[var(--surface-1)]'>
|
||||
<div className='flex h-screen w-full flex-col overflow-hidden bg-[var(--surface-1)]'>
|
||||
<ImpersonationBanner />
|
||||
<WorkspacePermissionsProvider>
|
||||
<div className='shrink-0' suppressHydrationWarning>
|
||||
<Sidebar />
|
||||
</div>
|
||||
<div className='flex min-w-0 flex-1 flex-col p-[8px] pl-0'>
|
||||
<div className='flex-1 overflow-hidden rounded-[8px] border border-[var(--border)] bg-[var(--bg)]'>
|
||||
{children}
|
||||
<div className='flex min-h-0 flex-1'>
|
||||
<div className='shrink-0' suppressHydrationWarning>
|
||||
<Sidebar />
|
||||
</div>
|
||||
<div className='flex min-w-0 flex-1 flex-col p-[8px] pl-0'>
|
||||
<div className='flex-1 overflow-hidden rounded-[8px] border border-[var(--border)] bg-[var(--bg)]'>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<NavTour />
|
||||
</WorkspacePermissionsProvider>
|
||||
</div>
|
||||
</GlobalCommandsProvider>
|
||||
|
||||
@@ -36,7 +36,7 @@ function WorkflowsListInner({
|
||||
searchQuery: string
|
||||
segmentDurationMs: number
|
||||
}) {
|
||||
const { workflows } = useWorkflowRegistry()
|
||||
const workflows = useWorkflowRegistry((s) => s.workflows)
|
||||
|
||||
return (
|
||||
<div className='flex h-full flex-col overflow-hidden rounded-[6px] bg-[var(--surface-2)] dark:bg-[var(--surface-1)]'>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { Skeleton } from '@/components/emcn'
|
||||
import { formatLatency } from '@/app/workspace/[workspaceId]/logs/utils'
|
||||
import type { DashboardStatsResponse, WorkflowStats } from '@/hooks/queries/logs'
|
||||
@@ -146,7 +147,14 @@ function DashboardInner({ stats, isLoading, error }: DashboardProps) {
|
||||
const [lastAnchorIndices, setLastAnchorIndices] = useState<Record<string, number>>({})
|
||||
const lastAnchorIndicesRef = useRef<Record<string, number>>({})
|
||||
|
||||
const { workflowIds, searchQuery, toggleWorkflowId, timeRange } = useFilterStore()
|
||||
const { workflowIds, searchQuery, toggleWorkflowId, timeRange } = useFilterStore(
|
||||
useShallow((s) => ({
|
||||
workflowIds: s.workflowIds,
|
||||
searchQuery: s.searchQuery,
|
||||
toggleWorkflowId: s.toggleWorkflowId,
|
||||
timeRange: s.timeRange,
|
||||
}))
|
||||
)
|
||||
|
||||
const allWorkflows = useWorkflowRegistry((state) => state.workflows)
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { memo, useCallback, useMemo, useState } from 'react'
|
||||
import { ArrowUp, Bell, Library, MoreHorizontal, RefreshCw } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import {
|
||||
Button,
|
||||
Combobox,
|
||||
@@ -195,7 +196,25 @@ export const LogsToolbar = memo(function LogsToolbar({
|
||||
setDateRange,
|
||||
clearDateRange,
|
||||
resetFilters,
|
||||
} = useFilterStore()
|
||||
} = useFilterStore(
|
||||
useShallow((s) => ({
|
||||
level: s.level,
|
||||
setLevel: s.setLevel,
|
||||
workflowIds: s.workflowIds,
|
||||
setWorkflowIds: s.setWorkflowIds,
|
||||
folderIds: s.folderIds,
|
||||
setFolderIds: s.setFolderIds,
|
||||
triggers: s.triggers,
|
||||
setTriggers: s.setTriggers,
|
||||
timeRange: s.timeRange,
|
||||
setTimeRange: s.setTimeRange,
|
||||
startDate: s.startDate,
|
||||
endDate: s.endDate,
|
||||
setDateRange: s.setDateRange,
|
||||
clearDateRange: s.clearDateRange,
|
||||
resetFilters: s.resetFilters,
|
||||
}))
|
||||
)
|
||||
|
||||
const [datePickerOpen, setDatePickerOpen] = useState(false)
|
||||
const [previousTimeRange, setPreviousTimeRange] = useState(timeRange)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import {
|
||||
Bell,
|
||||
Button,
|
||||
@@ -230,7 +231,30 @@ export default function Logs() {
|
||||
setTimeRange,
|
||||
setDateRange,
|
||||
clearDateRange,
|
||||
} = useFilterStore()
|
||||
} = useFilterStore(
|
||||
useShallow((s) => ({
|
||||
setWorkspaceId: s.setWorkspaceId,
|
||||
initializeFromURL: s.initializeFromURL,
|
||||
timeRange: s.timeRange,
|
||||
startDate: s.startDate,
|
||||
endDate: s.endDate,
|
||||
level: s.level,
|
||||
workflowIds: s.workflowIds,
|
||||
folderIds: s.folderIds,
|
||||
setWorkflowIds: s.setWorkflowIds,
|
||||
setSearchQuery: s.setSearchQuery,
|
||||
triggers: s.triggers,
|
||||
viewMode: s.viewMode,
|
||||
setViewMode: s.setViewMode,
|
||||
resetFilters: s.resetFilters,
|
||||
setLevel: s.setLevel,
|
||||
setFolderIds: s.setFolderIds,
|
||||
setTriggers: s.setTriggers,
|
||||
setTimeRange: s.setTimeRange,
|
||||
setDateRange: s.setDateRange,
|
||||
clearDateRange: s.clearDateRange,
|
||||
}))
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
setWorkspaceId(workspaceId)
|
||||
@@ -1133,7 +1157,25 @@ function LogsFilterPanel({ searchQuery, onSearchQueryChange }: LogsFilterPanelPr
|
||||
setDateRange,
|
||||
clearDateRange,
|
||||
resetFilters,
|
||||
} = useFilterStore()
|
||||
} = useFilterStore(
|
||||
useShallow((s) => ({
|
||||
level: s.level,
|
||||
setLevel: s.setLevel,
|
||||
workflowIds: s.workflowIds,
|
||||
setWorkflowIds: s.setWorkflowIds,
|
||||
folderIds: s.folderIds,
|
||||
setFolderIds: s.setFolderIds,
|
||||
triggers: s.triggers,
|
||||
setTriggers: s.setTriggers,
|
||||
timeRange: s.timeRange,
|
||||
setTimeRange: s.setTimeRange,
|
||||
startDate: s.startDate,
|
||||
endDate: s.endDate,
|
||||
setDateRange: s.setDateRange,
|
||||
clearDateRange: s.clearDateRange,
|
||||
resetFilters: s.resetFilters,
|
||||
}))
|
||||
)
|
||||
|
||||
const [datePickerOpen, setDatePickerOpen] = useState(false)
|
||||
const [previousTimeRange, setPreviousTimeRange] = useState(timeRange)
|
||||
|
||||
@@ -8,6 +8,7 @@ import { cn } from '@/lib/core/utils/cn'
|
||||
import {
|
||||
useAdminUsers,
|
||||
useBanUser,
|
||||
useImpersonateUser,
|
||||
useSetUserRole,
|
||||
useUnbanUser,
|
||||
} from '@/hooks/queries/admin-users'
|
||||
@@ -28,6 +29,7 @@ export function Admin() {
|
||||
const setUserRole = useSetUserRole()
|
||||
const banUser = useBanUser()
|
||||
const unbanUser = useUnbanUser()
|
||||
const impersonateUser = useImpersonateUser()
|
||||
|
||||
const [workflowId, setWorkflowId] = useState('')
|
||||
const [usersOffset, setUsersOffset] = useState(0)
|
||||
@@ -35,6 +37,8 @@ export function Admin() {
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [banUserId, setBanUserId] = useState<string | null>(null)
|
||||
const [banReason, setBanReason] = useState('')
|
||||
const [impersonatingUserId, setImpersonatingUserId] = useState<string | null>(null)
|
||||
const [impersonationGuardError, setImpersonationGuardError] = useState<string | null>(null)
|
||||
|
||||
const {
|
||||
data: usersData,
|
||||
@@ -67,6 +71,29 @@ export function Admin() {
|
||||
)
|
||||
}
|
||||
|
||||
const handleImpersonate = (userId: string) => {
|
||||
setImpersonationGuardError(null)
|
||||
if (session?.user?.role !== 'admin') {
|
||||
setImpersonatingUserId(null)
|
||||
setImpersonationGuardError('Only admins can impersonate users.')
|
||||
return
|
||||
}
|
||||
|
||||
setImpersonatingUserId(userId)
|
||||
impersonateUser.reset()
|
||||
impersonateUser.mutate(
|
||||
{ userId },
|
||||
{
|
||||
onError: () => {
|
||||
setImpersonatingUserId(null)
|
||||
},
|
||||
onSuccess: () => {
|
||||
window.location.assign('/workspace')
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const pendingUserIds = useMemo(() => {
|
||||
const ids = new Set<string>()
|
||||
if (setUserRole.isPending && (setUserRole.variables as { userId?: string })?.userId)
|
||||
@@ -75,6 +102,9 @@ export function Admin() {
|
||||
ids.add((banUser.variables as { userId: string }).userId)
|
||||
if (unbanUser.isPending && (unbanUser.variables as { userId?: string })?.userId)
|
||||
ids.add((unbanUser.variables as { userId: string }).userId)
|
||||
if (impersonateUser.isPending && (impersonateUser.variables as { userId?: string })?.userId)
|
||||
ids.add((impersonateUser.variables as { userId: string }).userId)
|
||||
if (impersonatingUserId) ids.add(impersonatingUserId)
|
||||
return ids
|
||||
}, [
|
||||
setUserRole.isPending,
|
||||
@@ -83,6 +113,9 @@ export function Admin() {
|
||||
banUser.variables,
|
||||
unbanUser.isPending,
|
||||
unbanUser.variables,
|
||||
impersonateUser.isPending,
|
||||
impersonateUser.variables,
|
||||
impersonatingUserId,
|
||||
])
|
||||
return (
|
||||
<div className='flex h-full flex-col gap-[24px]'>
|
||||
@@ -152,9 +185,15 @@ export function Admin() {
|
||||
</p>
|
||||
)}
|
||||
|
||||
{(setUserRole.error || banUser.error || unbanUser.error) && (
|
||||
{(setUserRole.error ||
|
||||
banUser.error ||
|
||||
unbanUser.error ||
|
||||
impersonateUser.error ||
|
||||
impersonationGuardError) && (
|
||||
<p className='text-[13px] text-[var(--text-error)]'>
|
||||
{(setUserRole.error || banUser.error || unbanUser.error)?.message ??
|
||||
{impersonationGuardError ||
|
||||
(setUserRole.error || banUser.error || unbanUser.error || impersonateUser.error)
|
||||
?.message ||
|
||||
'Action failed. Please try again.'}
|
||||
</p>
|
||||
)}
|
||||
@@ -175,7 +214,7 @@ export function Admin() {
|
||||
<span className='flex-1'>Email</span>
|
||||
<span className='w-[80px]'>Role</span>
|
||||
<span className='w-[80px]'>Status</span>
|
||||
<span className='w-[180px] text-right'>Actions</span>
|
||||
<span className='w-[250px] text-right'>Actions</span>
|
||||
</div>
|
||||
|
||||
{usersData.users.length === 0 && (
|
||||
@@ -206,9 +245,22 @@ export function Admin() {
|
||||
<Badge variant='green'>Active</Badge>
|
||||
)}
|
||||
</span>
|
||||
<span className='flex w-[180px] justify-end gap-[4px]'>
|
||||
<span className='flex w-[250px] justify-end gap-[4px]'>
|
||||
{u.id !== session?.user?.id && (
|
||||
<>
|
||||
<Button
|
||||
variant='active'
|
||||
className='h-[28px] px-[8px] text-[12px]'
|
||||
onClick={() => handleImpersonate(u.id)}
|
||||
disabled={pendingUserIds.has(u.id)}
|
||||
>
|
||||
{impersonatingUserId === u.id ||
|
||||
(impersonateUser.isPending &&
|
||||
(impersonateUser.variables as { userId?: string } | undefined)
|
||||
?.userId === u.id)
|
||||
? 'Switching...'
|
||||
: 'Impersonate'}
|
||||
</Button>
|
||||
<Button
|
||||
variant='active'
|
||||
className='h-[28px] px-[8px] text-[12px]'
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { Camera, Check, Pencil } from 'lucide-react'
|
||||
import { Camera, Check, Info, Pencil } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import {
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
Switch,
|
||||
Tooltip,
|
||||
} from '@/components/emcn'
|
||||
import { signOut, useSession } from '@/lib/auth/auth-client'
|
||||
import { ANONYMOUS_USER_ID } from '@/lib/auth/constants'
|
||||
@@ -375,7 +376,22 @@ export function General() {
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-between'>
|
||||
<Label htmlFor='auto-connect'>Auto-connect on drop</Label>
|
||||
<div className='flex items-center gap-[6px]'>
|
||||
<Label htmlFor='auto-connect'>Auto-connect on drop</Label>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Info className='h-[14px] w-[14px] cursor-default text-[var(--text-muted)]' />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='bottom' align='start'>
|
||||
<p>Automatically connect blocks when dropped near each other</p>
|
||||
<Tooltip.Preview
|
||||
src='/tooltips/auto-connect-on-drop.mp4'
|
||||
alt='Auto-connect on drop example'
|
||||
loop={false}
|
||||
/>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
<Switch
|
||||
id='auto-connect'
|
||||
checked={settings?.autoConnect ?? true}
|
||||
@@ -384,7 +400,21 @@ export function General() {
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-between'>
|
||||
<Label htmlFor='error-notifications'>Workflow error notifications</Label>
|
||||
<div className='flex items-center gap-[6px]'>
|
||||
<Label htmlFor='error-notifications'>Canvas error notifications</Label>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Info className='h-[14px] w-[14px] cursor-default text-[var(--text-muted)]' />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='bottom' align='start'>
|
||||
<p>Show error popups on blocks when a workflow run fails</p>
|
||||
<Tooltip.Preview
|
||||
src='/tooltips/canvas-error-notification.mp4'
|
||||
alt='Canvas error notification example'
|
||||
/>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
<Switch
|
||||
id='error-notifications'
|
||||
checked={settings?.errorNotificationsEnabled ?? true}
|
||||
@@ -461,7 +491,7 @@ export function General() {
|
||||
)}
|
||||
{isHosted && (
|
||||
<Button
|
||||
onClick={() => window.open('/?from=settings', '_blank', 'noopener,noreferrer')}
|
||||
onClick={() => window.open('/?home', '_blank', 'noopener,noreferrer')}
|
||||
variant='active'
|
||||
className='ml-auto'
|
||||
>
|
||||
|
||||
@@ -1,287 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { ChangeEvent } from 'react'
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { Button, Input, Label, Textarea } from '@/components/emcn'
|
||||
import { Upload } from '@/components/emcn/icons'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { extractSkillFromZip, parseSkillMarkdown } from './utils'
|
||||
|
||||
interface ImportedSkill {
|
||||
name: string
|
||||
description: string
|
||||
content: string
|
||||
}
|
||||
|
||||
interface SkillImportProps {
|
||||
onImport: (data: ImportedSkill) => void
|
||||
}
|
||||
|
||||
type ImportState = 'idle' | 'loading' | 'error'
|
||||
|
||||
const ACCEPTED_EXTENSIONS = ['.md', '.zip']
|
||||
|
||||
function isAcceptedFile(file: File): boolean {
|
||||
const name = file.name.toLowerCase()
|
||||
return ACCEPTED_EXTENSIONS.some((ext) => name.endsWith(ext))
|
||||
}
|
||||
|
||||
export function SkillImport({ onImport }: SkillImportProps) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [dragCounter, setDragCounter] = useState(0)
|
||||
const [fileState, setFileState] = useState<ImportState>('idle')
|
||||
const [fileError, setFileError] = useState('')
|
||||
|
||||
const [githubUrl, setGithubUrl] = useState('')
|
||||
const [githubState, setGithubState] = useState<ImportState>('idle')
|
||||
const [githubError, setGithubError] = useState('')
|
||||
|
||||
const [pasteContent, setPasteContent] = useState('')
|
||||
const [pasteError, setPasteError] = useState('')
|
||||
|
||||
const processFile = useCallback(
|
||||
async (file: File) => {
|
||||
if (!isAcceptedFile(file)) {
|
||||
setFileError('Unsupported file type. Use .md or .zip files.')
|
||||
setFileState('error')
|
||||
return
|
||||
}
|
||||
|
||||
setFileState('loading')
|
||||
setFileError('')
|
||||
|
||||
try {
|
||||
let rawContent: string
|
||||
|
||||
if (file.name.toLowerCase().endsWith('.zip')) {
|
||||
rawContent = await extractSkillFromZip(file)
|
||||
} else {
|
||||
rawContent = await file.text()
|
||||
}
|
||||
|
||||
const parsed = parseSkillMarkdown(rawContent)
|
||||
setFileState('idle')
|
||||
onImport(parsed)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to process file'
|
||||
setFileError(message)
|
||||
setFileState('error')
|
||||
}
|
||||
},
|
||||
[onImport]
|
||||
)
|
||||
|
||||
const handleFileChange = useCallback(
|
||||
(e: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) processFile(file)
|
||||
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||
},
|
||||
[processFile]
|
||||
)
|
||||
|
||||
const handleDragEnter = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setDragCounter((prev) => {
|
||||
const next = prev + 1
|
||||
if (next === 1) setIsDragging(true)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setDragCounter((prev) => {
|
||||
const next = prev - 1
|
||||
if (next === 0) setIsDragging(false)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
e.dataTransfer.dropEffect = 'copy'
|
||||
}, [])
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsDragging(false)
|
||||
setDragCounter(0)
|
||||
|
||||
const file = e.dataTransfer.files?.[0]
|
||||
if (file) processFile(file)
|
||||
},
|
||||
[processFile]
|
||||
)
|
||||
|
||||
const handleGithubImport = useCallback(async () => {
|
||||
const trimmed = githubUrl.trim()
|
||||
if (!trimmed) {
|
||||
setGithubError('Please enter a GitHub URL')
|
||||
setGithubState('error')
|
||||
return
|
||||
}
|
||||
|
||||
setGithubState('loading')
|
||||
setGithubError('')
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/skills/import', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url: trimmed }),
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || `Import failed (HTTP ${res.status})`)
|
||||
}
|
||||
|
||||
const parsed = parseSkillMarkdown(data.content)
|
||||
setGithubState('idle')
|
||||
onImport(parsed)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to import from GitHub'
|
||||
setGithubError(message)
|
||||
setGithubState('error')
|
||||
}
|
||||
}, [githubUrl, onImport])
|
||||
|
||||
const handlePasteImport = useCallback(() => {
|
||||
const trimmed = pasteContent.trim()
|
||||
if (!trimmed) {
|
||||
setPasteError('Please paste some content first')
|
||||
return
|
||||
}
|
||||
|
||||
setPasteError('')
|
||||
const parsed = parseSkillMarkdown(trimmed)
|
||||
onImport(parsed)
|
||||
}, [pasteContent, onImport])
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-[18px]'>
|
||||
{/* File drop zone */}
|
||||
<div className='flex flex-col gap-[4px]'>
|
||||
<Label className='font-medium text-[14px]'>Upload File</Label>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
disabled={fileState === 'loading'}
|
||||
className={cn(
|
||||
'flex w-full cursor-pointer flex-col items-center justify-center gap-[8px] rounded-[8px] border border-dashed px-[16px] py-[32px] transition-colors',
|
||||
'border-[var(--border-1)] bg-[var(--surface-1)] hover:bg-[var(--surface-4)]',
|
||||
isDragging && 'border-[var(--surface-7)] bg-[var(--surface-4)]',
|
||||
fileState === 'loading' && 'pointer-events-none opacity-60'
|
||||
)}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type='file'
|
||||
accept='.md,.zip'
|
||||
onChange={handleFileChange}
|
||||
className='hidden'
|
||||
/>
|
||||
{fileState === 'loading' ? (
|
||||
<Loader2 className='h-[20px] w-[20px] animate-spin text-[var(--text-tertiary)]' />
|
||||
) : (
|
||||
<Upload className='h-[20px] w-[20px] text-[var(--text-tertiary)]' />
|
||||
)}
|
||||
<div className='flex flex-col gap-[2px] text-center'>
|
||||
<span className='text-[14px] text-[var(--text-primary)]'>
|
||||
{isDragging ? 'Drop file here' : 'Drop file here or click to browse'}
|
||||
</span>
|
||||
<span className='text-[11px] text-[var(--text-tertiary)]'>
|
||||
.md file with YAML frontmatter, or .zip containing a SKILL.md
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
{fileError && <p className='text-[13px] text-[var(--text-error)]'>{fileError}</p>}
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* GitHub URL */}
|
||||
<div className='flex flex-col gap-[4px]'>
|
||||
<Label htmlFor='skill-github-url' className='font-medium text-[14px]'>
|
||||
Import from GitHub
|
||||
</Label>
|
||||
<div className='flex gap-[8px]'>
|
||||
<Input
|
||||
id='skill-github-url'
|
||||
placeholder='https://github.com/owner/repo/blob/main/SKILL.md'
|
||||
value={githubUrl}
|
||||
onChange={(e) => {
|
||||
setGithubUrl(e.target.value)
|
||||
if (githubError) setGithubError('')
|
||||
}}
|
||||
className='flex-1'
|
||||
disabled={githubState === 'loading'}
|
||||
/>
|
||||
<Button
|
||||
variant='default'
|
||||
onClick={handleGithubImport}
|
||||
disabled={githubState === 'loading' || !githubUrl.trim()}
|
||||
>
|
||||
{githubState === 'loading' ? (
|
||||
<Loader2 className='h-[14px] w-[14px] animate-spin' />
|
||||
) : (
|
||||
'Fetch'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
{githubError && <p className='text-[13px] text-[var(--text-error)]'>{githubError}</p>}
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Paste content */}
|
||||
<div className='flex flex-col gap-[4px]'>
|
||||
<Label htmlFor='skill-paste' className='font-medium text-[14px]'>
|
||||
Paste SKILL.md Content
|
||||
</Label>
|
||||
<Textarea
|
||||
id='skill-paste'
|
||||
placeholder={
|
||||
'---\nname: my-skill\ndescription: What this skill does\n---\n\n# Instructions...'
|
||||
}
|
||||
value={pasteContent}
|
||||
onChange={(e: ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setPasteContent(e.target.value)
|
||||
if (pasteError) setPasteError('')
|
||||
}}
|
||||
className='min-h-[120px] resize-y font-mono text-[14px]'
|
||||
/>
|
||||
{pasteError && <p className='text-[13px] text-[var(--text-error)]'>{pasteError}</p>}
|
||||
<div className='flex justify-end'>
|
||||
<Button variant='default' onClick={handlePasteImport} disabled={!pasteContent.trim()}>
|
||||
Import
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Divider() {
|
||||
return (
|
||||
<div className='flex items-center gap-[12px]'>
|
||||
<div className='h-px flex-1 bg-[var(--border-1)]' />
|
||||
<span className='text-[12px] text-[var(--text-tertiary)]'>or</span>
|
||||
<div className='h-px flex-1 bg-[var(--border-1)]' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import type { ChangeEvent } from 'react'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import {
|
||||
Button,
|
||||
@@ -12,15 +12,10 @@ import {
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalTabs,
|
||||
ModalTabsContent,
|
||||
ModalTabsList,
|
||||
ModalTabsTrigger,
|
||||
Textarea,
|
||||
} from '@/components/emcn'
|
||||
import type { SkillDefinition } from '@/hooks/queries/skills'
|
||||
import { useCreateSkill, useUpdateSkill } from '@/hooks/queries/skills'
|
||||
import { SkillImport } from './skill-import'
|
||||
|
||||
interface SkillModalProps {
|
||||
open: boolean
|
||||
@@ -39,8 +34,6 @@ interface FieldErrors {
|
||||
general?: string
|
||||
}
|
||||
|
||||
type TabValue = 'create' | 'import'
|
||||
|
||||
export function SkillModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
@@ -59,7 +52,6 @@ export function SkillModal({
|
||||
const [content, setContent] = useState('')
|
||||
const [errors, setErrors] = useState<FieldErrors>({})
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [activeTab, setActiveTab] = useState<TabValue>('create')
|
||||
const [prevOpen, setPrevOpen] = useState(false)
|
||||
const [prevInitialValues, setPrevInitialValues] = useState(initialValues)
|
||||
|
||||
@@ -68,7 +60,6 @@ export function SkillModal({
|
||||
setDescription(initialValues?.description ?? '')
|
||||
setContent(initialValues?.content ?? '')
|
||||
setErrors({})
|
||||
setActiveTab('create')
|
||||
}
|
||||
if (open !== prevOpen) setPrevOpen(open)
|
||||
if (initialValues !== prevInitialValues) setPrevInitialValues(initialValues)
|
||||
@@ -133,137 +124,97 @@ export function SkillModal({
|
||||
}
|
||||
}
|
||||
|
||||
const handleImport = useCallback(
|
||||
(data: { name: string; description: string; content: string }) => {
|
||||
setName(data.name)
|
||||
setDescription(data.description)
|
||||
setContent(data.content)
|
||||
setErrors({})
|
||||
setActiveTab('create')
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const isEditing = !!initialValues
|
||||
|
||||
const createForm = (
|
||||
<div className='flex flex-col gap-[18px]'>
|
||||
<div className='flex flex-col gap-[4px]'>
|
||||
<Label htmlFor='skill-name' className='font-medium text-[14px]'>
|
||||
Name
|
||||
</Label>
|
||||
<Input
|
||||
id='skill-name'
|
||||
placeholder='my-skill-name'
|
||||
value={name}
|
||||
onChange={(e) => {
|
||||
setName(e.target.value)
|
||||
if (errors.name || errors.general)
|
||||
setErrors((prev) => ({ ...prev, name: undefined, general: undefined }))
|
||||
}}
|
||||
/>
|
||||
{errors.name ? (
|
||||
<p className='text-[13px] text-[var(--text-error)]'>{errors.name}</p>
|
||||
) : (
|
||||
<span className='text-[11px] text-[var(--text-muted)]'>
|
||||
Lowercase letters, numbers, and hyphens (e.g. my-skill)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-[4px]'>
|
||||
<Label htmlFor='skill-description' className='font-medium text-[14px]'>
|
||||
Description
|
||||
</Label>
|
||||
<Input
|
||||
id='skill-description'
|
||||
placeholder='What this skill does and when to use it...'
|
||||
value={description}
|
||||
onChange={(e) => {
|
||||
setDescription(e.target.value)
|
||||
if (errors.description || errors.general)
|
||||
setErrors((prev) => ({ ...prev, description: undefined, general: undefined }))
|
||||
}}
|
||||
maxLength={1024}
|
||||
/>
|
||||
{errors.description && (
|
||||
<p className='text-[13px] text-[var(--text-error)]'>{errors.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-[4px]'>
|
||||
<Label htmlFor='skill-content' className='font-medium text-[14px]'>
|
||||
Content
|
||||
</Label>
|
||||
<Textarea
|
||||
id='skill-content'
|
||||
placeholder='Skill instructions in markdown...'
|
||||
value={content}
|
||||
onChange={(e: ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setContent(e.target.value)
|
||||
if (errors.content || errors.general)
|
||||
setErrors((prev) => ({ ...prev, content: undefined, general: undefined }))
|
||||
}}
|
||||
className='min-h-[200px] resize-y font-mono text-[14px]'
|
||||
/>
|
||||
{errors.content && <p className='text-[13px] text-[var(--text-error)]'>{errors.content}</p>}
|
||||
</div>
|
||||
|
||||
{errors.general && <p className='text-[13px] text-[var(--text-error)]'>{errors.general}</p>}
|
||||
</div>
|
||||
)
|
||||
|
||||
const footer = (
|
||||
<ModalFooter className='items-center justify-between'>
|
||||
{isEditing && onDelete ? (
|
||||
<Button variant='destructive' onClick={() => onDelete(initialValues.id)}>
|
||||
Delete
|
||||
</Button>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
<div className='flex gap-2'>
|
||||
<Button variant='default' onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant='primary' onClick={handleSave} disabled={saving || !hasChanges}>
|
||||
{saving ? 'Saving...' : isEditing ? 'Update' : 'Create'}
|
||||
</Button>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
)
|
||||
|
||||
return (
|
||||
<Modal open={open} onOpenChange={onOpenChange}>
|
||||
<ModalContent size='lg'>
|
||||
{isEditing ? (
|
||||
<>
|
||||
<ModalHeader>Edit Skill</ModalHeader>
|
||||
<ModalBody>{createForm}</ModalBody>
|
||||
{footer}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ModalHeader>Add Skill</ModalHeader>
|
||||
<ModalTabs
|
||||
value={activeTab}
|
||||
onValueChange={(v) => setActiveTab(v as TabValue)}
|
||||
className='flex min-h-0 flex-1 flex-col'
|
||||
>
|
||||
<ModalTabsList activeValue={activeTab}>
|
||||
<ModalTabsTrigger value='create'>Create</ModalTabsTrigger>
|
||||
<ModalTabsTrigger value='import'>Import</ModalTabsTrigger>
|
||||
</ModalTabsList>
|
||||
<ModalBody>
|
||||
<ModalTabsContent value='create'>{createForm}</ModalTabsContent>
|
||||
<ModalTabsContent value='import'>
|
||||
<SkillImport onImport={handleImport} />
|
||||
</ModalTabsContent>
|
||||
</ModalBody>
|
||||
</ModalTabs>
|
||||
{activeTab === 'create' && footer}
|
||||
</>
|
||||
)}
|
||||
<ModalHeader>{initialValues ? 'Edit Skill' : 'Create Skill'}</ModalHeader>
|
||||
<ModalBody>
|
||||
<div className='flex flex-col gap-[18px]'>
|
||||
<div className='flex flex-col gap-[4px]'>
|
||||
<Label htmlFor='skill-name' className='font-medium text-[14px]'>
|
||||
Name
|
||||
</Label>
|
||||
<Input
|
||||
id='skill-name'
|
||||
placeholder='my-skill-name'
|
||||
value={name}
|
||||
onChange={(e) => {
|
||||
setName(e.target.value)
|
||||
if (errors.name || errors.general)
|
||||
setErrors((prev) => ({ ...prev, name: undefined, general: undefined }))
|
||||
}}
|
||||
/>
|
||||
{errors.name ? (
|
||||
<p className='text-[13px] text-[var(--text-error)]'>{errors.name}</p>
|
||||
) : (
|
||||
<span className='text-[11px] text-[var(--text-muted)]'>
|
||||
Lowercase letters, numbers, and hyphens (e.g. my-skill)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-[4px]'>
|
||||
<Label htmlFor='skill-description' className='font-medium text-[14px]'>
|
||||
Description
|
||||
</Label>
|
||||
<Input
|
||||
id='skill-description'
|
||||
placeholder='What this skill does and when to use it...'
|
||||
value={description}
|
||||
onChange={(e) => {
|
||||
setDescription(e.target.value)
|
||||
if (errors.description || errors.general)
|
||||
setErrors((prev) => ({ ...prev, description: undefined, general: undefined }))
|
||||
}}
|
||||
maxLength={1024}
|
||||
/>
|
||||
{errors.description && (
|
||||
<p className='text-[13px] text-[var(--text-error)]'>{errors.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-[4px]'>
|
||||
<Label htmlFor='skill-content' className='font-medium text-[14px]'>
|
||||
Content
|
||||
</Label>
|
||||
<Textarea
|
||||
id='skill-content'
|
||||
placeholder='Skill instructions in markdown...'
|
||||
value={content}
|
||||
onChange={(e: ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setContent(e.target.value)
|
||||
if (errors.content || errors.general)
|
||||
setErrors((prev) => ({ ...prev, content: undefined, general: undefined }))
|
||||
}}
|
||||
className='min-h-[200px] resize-y font-mono text-[14px]'
|
||||
/>
|
||||
{errors.content && (
|
||||
<p className='text-[13px] text-[var(--text-error)]'>{errors.content}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{errors.general && (
|
||||
<p className='text-[13px] text-[var(--text-error)]'>{errors.general}</p>
|
||||
)}
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter className='items-center justify-between'>
|
||||
{initialValues && onDelete ? (
|
||||
<Button variant='destructive' onClick={() => onDelete(initialValues.id)}>
|
||||
Delete
|
||||
</Button>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
<div className='flex gap-2'>
|
||||
<Button variant='default' onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant='primary' onClick={handleSave} disabled={saving || !hasChanges}>
|
||||
{saving ? 'Saving...' : initialValues ? 'Update' : 'Create'}
|
||||
</Button>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)
|
||||
|
||||
@@ -1,191 +0,0 @@
|
||||
/**
|
||||
* @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')
|
||||
})
|
||||
})
|
||||
@@ -1,112 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
BookOpen,
|
||||
Card,
|
||||
Connections,
|
||||
HexSimple,
|
||||
@@ -38,7 +37,6 @@ export type SettingsSection =
|
||||
| 'skills'
|
||||
| 'workflow-mcp-servers'
|
||||
| 'inbox'
|
||||
| 'docs'
|
||||
| 'admin'
|
||||
| 'recently-deleted'
|
||||
|
||||
@@ -156,14 +154,6 @@ export const allNavigationItems: NavigationItem[] = [
|
||||
requiresEnterprise: true,
|
||||
selfHostedOverride: isSSOEnabled,
|
||||
},
|
||||
{
|
||||
id: 'docs',
|
||||
label: 'Docs',
|
||||
icon: BookOpen,
|
||||
section: 'system',
|
||||
requiresHosted: true,
|
||||
externalUrl: 'https://docs.sim.ai',
|
||||
},
|
||||
{
|
||||
id: 'admin',
|
||||
label: 'Admin',
|
||||
|
||||
@@ -196,6 +196,18 @@ export function Table({
|
||||
const columnWidthsRef = useRef(columnWidths)
|
||||
columnWidthsRef.current = columnWidths
|
||||
const [resizingColumn, setResizingColumn] = useState<string | null>(null)
|
||||
const [columnOrder, setColumnOrder] = useState<string[] | null>(null)
|
||||
const columnOrderRef = useRef(columnOrder)
|
||||
columnOrderRef.current = columnOrder
|
||||
const [dragColumnName, setDragColumnName] = useState<string | null>(null)
|
||||
const dragColumnNameRef = useRef(dragColumnName)
|
||||
dragColumnNameRef.current = dragColumnName
|
||||
const [dropTargetColumnName, setDropTargetColumnName] = useState<string | null>(null)
|
||||
const dropTargetColumnNameRef = useRef(dropTargetColumnName)
|
||||
dropTargetColumnNameRef.current = dropTargetColumnName
|
||||
const [dropSide, setDropSide] = useState<'left' | 'right'>('left')
|
||||
const dropSideRef = useRef(dropSide)
|
||||
dropSideRef.current = dropSide
|
||||
const metadataSeededRef = useRef(false)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
@@ -239,6 +251,23 @@ export function Table({
|
||||
[tableData?.schema?.columns]
|
||||
)
|
||||
|
||||
const displayColumns = useMemo(() => {
|
||||
if (!columnOrder || columnOrder.length === 0) return columns
|
||||
const colMap = new Map(columns.map((c) => [c.name, c]))
|
||||
const ordered: ColumnDefinition[] = []
|
||||
for (const name of columnOrder) {
|
||||
const col = colMap.get(name)
|
||||
if (col) {
|
||||
ordered.push(col)
|
||||
colMap.delete(name)
|
||||
}
|
||||
}
|
||||
for (const col of colMap.values()) {
|
||||
ordered.push(col)
|
||||
}
|
||||
return ordered
|
||||
}, [columns, columnOrder])
|
||||
|
||||
const maxPosition = useMemo(() => (rows.length > 0 ? rows[rows.length - 1].position : -1), [rows])
|
||||
const maxPositionRef = useRef(maxPosition)
|
||||
maxPositionRef.current = maxPosition
|
||||
@@ -258,23 +287,23 @@ export function Table({
|
||||
[selectionAnchor, selectionFocus]
|
||||
)
|
||||
|
||||
const displayColCount = isLoadingTable ? SKELETON_COL_COUNT : columns.length
|
||||
const displayColCount = isLoadingTable ? SKELETON_COL_COUNT : displayColumns.length
|
||||
const tableWidth = useMemo(() => {
|
||||
const colsWidth = isLoadingTable
|
||||
? displayColCount * COL_WIDTH
|
||||
: columns.reduce((sum, col) => sum + (columnWidths[col.name] ?? COL_WIDTH), 0)
|
||||
: displayColumns.reduce((sum, col) => sum + (columnWidths[col.name] ?? COL_WIDTH), 0)
|
||||
return CHECKBOX_COL_WIDTH + colsWidth + ADD_COL_WIDTH
|
||||
}, [isLoadingTable, displayColCount, columns, columnWidths])
|
||||
}, [isLoadingTable, displayColCount, displayColumns, columnWidths])
|
||||
|
||||
const resizeIndicatorLeft = useMemo(() => {
|
||||
if (!resizingColumn) return 0
|
||||
let left = CHECKBOX_COL_WIDTH
|
||||
for (const col of columns) {
|
||||
for (const col of displayColumns) {
|
||||
left += columnWidths[col.name] ?? COL_WIDTH
|
||||
if (col.name === resizingColumn) return left
|
||||
}
|
||||
return 0
|
||||
}, [resizingColumn, columns, columnWidths])
|
||||
}, [resizingColumn, displayColumns, columnWidths])
|
||||
|
||||
const isAllRowsSelected = useMemo(() => {
|
||||
if (checkedRows.size > 0 && rows.length > 0 && checkedRows.size >= rows.length) {
|
||||
@@ -289,14 +318,15 @@ export function Table({
|
||||
normalizedSelection.startRow === 0 &&
|
||||
normalizedSelection.endRow === maxPosition &&
|
||||
normalizedSelection.startCol === 0 &&
|
||||
normalizedSelection.endCol === columns.length - 1
|
||||
normalizedSelection.endCol === displayColumns.length - 1
|
||||
)
|
||||
}, [checkedRows, normalizedSelection, maxPosition, columns.length, rows])
|
||||
}, [checkedRows, normalizedSelection, maxPosition, displayColumns.length, rows])
|
||||
|
||||
const isAllRowsSelectedRef = useRef(isAllRowsSelected)
|
||||
isAllRowsSelectedRef.current = isAllRowsSelected
|
||||
|
||||
const columnsRef = useRef(columns)
|
||||
const columnsRef = useRef(displayColumns)
|
||||
const schemaColumnsRef = useRef(columns)
|
||||
const rowsRef = useRef(rows)
|
||||
const selectionAnchorRef = useRef(selectionAnchor)
|
||||
const selectionFocusRef = useRef(selectionFocus)
|
||||
@@ -304,7 +334,8 @@ export function Table({
|
||||
const checkedRowsRef = useRef(checkedRows)
|
||||
checkedRowsRef.current = checkedRows
|
||||
|
||||
columnsRef.current = columns
|
||||
columnsRef.current = displayColumns
|
||||
schemaColumnsRef.current = columns
|
||||
rowsRef.current = rows
|
||||
selectionAnchorRef.current = selectionAnchor
|
||||
selectionFocusRef.current = selectionFocus
|
||||
@@ -329,10 +360,20 @@ export function Table({
|
||||
const columnRename = useInlineRename({
|
||||
onSave: (columnName, newName) => {
|
||||
pushUndoRef.current({ type: 'rename-column', oldName: columnName, newName })
|
||||
setColumnWidths((prev) => {
|
||||
if (!(columnName in prev)) return prev
|
||||
return { ...prev, [newName]: prev[columnName] }
|
||||
})
|
||||
let updatedWidths = columnWidthsRef.current
|
||||
if (columnName in updatedWidths) {
|
||||
const { [columnName]: width, ...rest } = updatedWidths
|
||||
updatedWidths = { ...rest, [newName]: width }
|
||||
setColumnWidths(updatedWidths)
|
||||
}
|
||||
const updatedOrder = columnOrderRef.current?.map((n) => (n === columnName ? newName : n))
|
||||
if (updatedOrder) {
|
||||
setColumnOrder(updatedOrder)
|
||||
updateMetadataRef.current({
|
||||
columnWidths: updatedWidths,
|
||||
columnOrder: updatedOrder,
|
||||
})
|
||||
}
|
||||
updateColumnMutation.mutate({ columnName, updates: { name: newName } })
|
||||
},
|
||||
})
|
||||
@@ -607,11 +648,58 @@ export function Table({
|
||||
updateMetadataRef.current({ columnWidths: columnWidthsRef.current })
|
||||
}, [])
|
||||
|
||||
const handleColumnDragStart = useCallback((columnName: string) => {
|
||||
setDragColumnName(columnName)
|
||||
}, [])
|
||||
|
||||
const handleColumnDragOver = useCallback((columnName: string, side: 'left' | 'right') => {
|
||||
if (columnName === dropTargetColumnNameRef.current && side === dropSideRef.current) return
|
||||
setDropTargetColumnName(columnName)
|
||||
setDropSide(side)
|
||||
}, [])
|
||||
|
||||
const handleColumnDragEnd = useCallback(() => {
|
||||
const dragged = dragColumnNameRef.current
|
||||
if (!dragged) return
|
||||
const target = dropTargetColumnNameRef.current
|
||||
const side = dropSideRef.current
|
||||
if (target && dragged !== target) {
|
||||
const cols = columnsRef.current
|
||||
const currentOrder = columnOrderRef.current ?? cols.map((c) => c.name)
|
||||
const fromIndex = currentOrder.indexOf(dragged)
|
||||
const toIndex = currentOrder.indexOf(target)
|
||||
if (fromIndex !== -1 && toIndex !== -1) {
|
||||
const newOrder = currentOrder.filter((n) => n !== dragged)
|
||||
let insertIndex = newOrder.indexOf(target)
|
||||
if (side === 'right') insertIndex += 1
|
||||
newOrder.splice(insertIndex, 0, dragged)
|
||||
setColumnOrder(newOrder)
|
||||
updateMetadataRef.current({
|
||||
columnWidths: columnWidthsRef.current,
|
||||
columnOrder: newOrder,
|
||||
})
|
||||
}
|
||||
}
|
||||
setDragColumnName(null)
|
||||
setDropTargetColumnName(null)
|
||||
}, [])
|
||||
|
||||
const handleColumnDragLeave = useCallback(() => {
|
||||
dropTargetColumnNameRef.current = null
|
||||
setDropTargetColumnName(null)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!tableData?.metadata?.columnWidths || metadataSeededRef.current) return
|
||||
if (!tableData?.metadata || metadataSeededRef.current) return
|
||||
if (!tableData.metadata.columnWidths && !tableData.metadata.columnOrder) return
|
||||
metadataSeededRef.current = true
|
||||
setColumnWidths(tableData.metadata.columnWidths)
|
||||
}, [tableData?.metadata?.columnWidths])
|
||||
if (tableData.metadata.columnWidths) {
|
||||
setColumnWidths(tableData.metadata.columnWidths)
|
||||
}
|
||||
if (tableData.metadata.columnOrder) {
|
||||
setColumnOrder(tableData.metadata.columnOrder)
|
||||
}
|
||||
}, [tableData?.metadata])
|
||||
|
||||
useEffect(() => {
|
||||
const handleMouseUp = () => {
|
||||
@@ -1214,7 +1302,7 @@ export function Table({
|
||||
}, [])
|
||||
|
||||
const generateColumnName = useCallback(() => {
|
||||
const existing = columnsRef.current.map((c) => c.name.toLowerCase())
|
||||
const existing = schemaColumnsRef.current.map((c) => c.name.toLowerCase())
|
||||
let name = 'untitled'
|
||||
let i = 2
|
||||
while (existing.includes(name.toLowerCase())) {
|
||||
@@ -1226,7 +1314,7 @@ export function Table({
|
||||
|
||||
const handleAddColumn = useCallback(() => {
|
||||
const name = generateColumnName()
|
||||
const position = columnsRef.current.length
|
||||
const position = schemaColumnsRef.current.length
|
||||
addColumnMutation.mutate(
|
||||
{ name, type: 'string' },
|
||||
{
|
||||
@@ -1250,9 +1338,30 @@ export function Table({
|
||||
updateColumnMutation.mutate({ columnName, updates: { type: newType } })
|
||||
}, [])
|
||||
|
||||
const insertColumnInOrder = useCallback(
|
||||
(anchorColumn: string, newColumn: string, side: 'left' | 'right') => {
|
||||
const order = columnOrderRef.current
|
||||
if (!order) return
|
||||
const newOrder = [...order]
|
||||
let anchorIdx = newOrder.indexOf(anchorColumn)
|
||||
if (anchorIdx === -1) {
|
||||
newOrder.push(anchorColumn)
|
||||
anchorIdx = newOrder.length - 1
|
||||
}
|
||||
const insertIdx = anchorIdx + (side === 'right' ? 1 : 0)
|
||||
newOrder.splice(insertIdx, 0, newColumn)
|
||||
setColumnOrder(newOrder)
|
||||
updateMetadataRef.current({
|
||||
columnWidths: columnWidthsRef.current,
|
||||
columnOrder: newOrder,
|
||||
})
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const handleInsertColumnLeft = useCallback(
|
||||
(columnName: string) => {
|
||||
const index = columnsRef.current.findIndex((c) => c.name === columnName)
|
||||
const index = schemaColumnsRef.current.findIndex((c) => c.name === columnName)
|
||||
if (index === -1) return
|
||||
const name = generateColumnName()
|
||||
addColumnMutation.mutate(
|
||||
@@ -1260,16 +1369,17 @@ export function Table({
|
||||
{
|
||||
onSuccess: () => {
|
||||
pushUndoRef.current({ type: 'create-column', columnName: name, position: index })
|
||||
insertColumnInOrder(columnName, name, 'left')
|
||||
},
|
||||
}
|
||||
)
|
||||
},
|
||||
[generateColumnName]
|
||||
[generateColumnName, insertColumnInOrder]
|
||||
)
|
||||
|
||||
const handleInsertColumnRight = useCallback(
|
||||
(columnName: string) => {
|
||||
const index = columnsRef.current.findIndex((c) => c.name === columnName)
|
||||
const index = schemaColumnsRef.current.findIndex((c) => c.name === columnName)
|
||||
if (index === -1) return
|
||||
const name = generateColumnName()
|
||||
const position = index + 1
|
||||
@@ -1278,11 +1388,12 @@ export function Table({
|
||||
{
|
||||
onSuccess: () => {
|
||||
pushUndoRef.current({ type: 'create-column', columnName: name, position })
|
||||
insertColumnInOrder(columnName, name, 'right')
|
||||
},
|
||||
}
|
||||
)
|
||||
},
|
||||
[generateColumnName]
|
||||
[generateColumnName, insertColumnInOrder]
|
||||
)
|
||||
|
||||
const handleToggleUnique = useCallback((columnName: string) => {
|
||||
@@ -1310,8 +1421,20 @@ export function Table({
|
||||
|
||||
const handleDeleteColumnConfirm = useCallback(() => {
|
||||
if (!deletingColumn) return
|
||||
deleteColumnMutation.mutate(deletingColumn)
|
||||
const columnToDelete = deletingColumn
|
||||
setDeletingColumn(null)
|
||||
deleteColumnMutation.mutate(columnToDelete, {
|
||||
onSuccess: () => {
|
||||
const order = columnOrderRef.current
|
||||
if (!order) return
|
||||
const newOrder = order.filter((n) => n !== columnToDelete)
|
||||
setColumnOrder(newOrder)
|
||||
updateMetadataRef.current({
|
||||
columnWidths: columnWidthsRef.current,
|
||||
columnOrder: newOrder,
|
||||
})
|
||||
},
|
||||
})
|
||||
}, [deletingColumn])
|
||||
|
||||
const handleSortChange = useCallback((column: string, direction: SortDirection) => {
|
||||
@@ -1327,13 +1450,13 @@ export function Table({
|
||||
}, [])
|
||||
const columnOptions = useMemo<ColumnOption[]>(
|
||||
() =>
|
||||
columns.map((col) => ({
|
||||
displayColumns.map((col) => ({
|
||||
id: col.name,
|
||||
label: col.name,
|
||||
type: col.type,
|
||||
icon: COLUMN_TYPE_ICONS[col.type],
|
||||
})),
|
||||
[columns]
|
||||
[displayColumns]
|
||||
)
|
||||
|
||||
const tableDataRef = useRef(tableData)
|
||||
@@ -1404,8 +1527,8 @@ export function Table({
|
||||
)
|
||||
|
||||
const filterElement = useMemo(
|
||||
() => <TableFilter columns={columns} onApply={handleFilterApply} />,
|
||||
[columns, handleFilterApply]
|
||||
() => <TableFilter columns={displayColumns} onApply={handleFilterApply} />,
|
||||
[displayColumns, handleFilterApply]
|
||||
)
|
||||
|
||||
const activeSortState = useMemo(() => {
|
||||
@@ -1501,7 +1624,7 @@ export function Table({
|
||||
<col style={{ width: ADD_COL_WIDTH }} />
|
||||
</colgroup>
|
||||
) : (
|
||||
<TableColGroup columns={columns} columnWidths={columnWidths} />
|
||||
<TableColGroup columns={displayColumns} columnWidths={columnWidths} />
|
||||
)}
|
||||
<thead className='sticky top-0 z-10'>
|
||||
{isLoadingTable ? (
|
||||
@@ -1532,7 +1655,7 @@ export function Table({
|
||||
checked={isAllRowsSelected}
|
||||
onCheckedChange={handleSelectAllToggle}
|
||||
/>
|
||||
{columns.map((column) => (
|
||||
{displayColumns.map((column) => (
|
||||
<ColumnHeaderMenu
|
||||
key={column.name}
|
||||
column={column}
|
||||
@@ -1553,6 +1676,13 @@ export function Table({
|
||||
onResizeStart={handleColumnResizeStart}
|
||||
onResize={handleColumnResize}
|
||||
onResizeEnd={handleColumnResizeEnd}
|
||||
isDragging={dragColumnName === column.name}
|
||||
isDropTarget={dropTargetColumnName === column.name}
|
||||
dropSide={dropTargetColumnName === column.name ? dropSide : undefined}
|
||||
onDragStart={handleColumnDragStart}
|
||||
onDragOver={handleColumnDragOver}
|
||||
onDragEnd={handleColumnDragEnd}
|
||||
onDragLeave={handleColumnDragLeave}
|
||||
/>
|
||||
))}
|
||||
{userPermissions.canEdit && (
|
||||
@@ -1578,7 +1708,7 @@ export function Table({
|
||||
<PositionGapRows
|
||||
count={gapCount}
|
||||
startPosition={prevPosition + 1}
|
||||
columns={columns}
|
||||
columns={displayColumns}
|
||||
normalizedSelection={normalizedSelection}
|
||||
checkedRows={checkedRows}
|
||||
firstRowUnderHeader={prevPosition === -1}
|
||||
@@ -1589,7 +1719,7 @@ export function Table({
|
||||
)}
|
||||
<DataRow
|
||||
row={row}
|
||||
columns={columns}
|
||||
columns={displayColumns}
|
||||
rowIndex={row.position}
|
||||
isFirstRow={row.position === 0}
|
||||
editingColumnName={
|
||||
@@ -2445,6 +2575,13 @@ const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({
|
||||
onResizeStart,
|
||||
onResize,
|
||||
onResizeEnd,
|
||||
isDragging,
|
||||
isDropTarget,
|
||||
dropSide,
|
||||
onDragStart,
|
||||
onDragOver,
|
||||
onDragEnd,
|
||||
onDragLeave,
|
||||
}: {
|
||||
column: ColumnDefinition
|
||||
readOnly?: boolean
|
||||
@@ -2462,6 +2599,13 @@ const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({
|
||||
onResizeStart: (columnName: string) => void
|
||||
onResize: (columnName: string, width: number) => void
|
||||
onResizeEnd: () => void
|
||||
isDragging?: boolean
|
||||
isDropTarget?: boolean
|
||||
dropSide?: 'left' | 'right'
|
||||
onDragStart?: (columnName: string) => void
|
||||
onDragOver?: (columnName: string, side: 'left' | 'right') => void
|
||||
onDragEnd?: () => void
|
||||
onDragLeave?: () => void
|
||||
}) {
|
||||
const renameInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
@@ -2503,8 +2647,68 @@ const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({
|
||||
[column.name, onResizeStart, onResize, onResizeEnd]
|
||||
)
|
||||
|
||||
const handleDragStart = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
if (readOnly || isRenaming) {
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
e.dataTransfer.setData('text/plain', column.name)
|
||||
onDragStart?.(column.name)
|
||||
},
|
||||
[column.name, readOnly, isRenaming, onDragStart]
|
||||
)
|
||||
|
||||
const handleDragOver = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = 'move'
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
|
||||
const midX = rect.left + rect.width / 2
|
||||
const side = e.clientX < midX ? 'left' : 'right'
|
||||
onDragOver?.(column.name, side)
|
||||
},
|
||||
[column.name, onDragOver]
|
||||
)
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
}, [])
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
onDragEnd?.()
|
||||
}, [onDragEnd])
|
||||
|
||||
const handleDragLeave = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
const th = e.currentTarget as HTMLElement
|
||||
const related = e.relatedTarget as Node | null
|
||||
if (related && th.contains(related)) return
|
||||
onDragLeave?.()
|
||||
},
|
||||
[onDragLeave]
|
||||
)
|
||||
|
||||
return (
|
||||
<th className='relative border-[var(--border)] border-r border-b bg-[var(--bg)] p-0 text-left align-middle'>
|
||||
<th
|
||||
className={cn(
|
||||
'relative border-[var(--border)] border-r border-b bg-[var(--bg)] p-0 text-left align-middle',
|
||||
isDragging && 'opacity-40'
|
||||
)}
|
||||
draggable={!readOnly && !isRenaming}
|
||||
onDragStart={handleDragStart}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragLeave={handleDragLeave}
|
||||
>
|
||||
{isDropTarget && dropSide === 'left' && (
|
||||
<div className='pointer-events-none absolute top-0 bottom-0 left-[-1px] z-10 w-[2px] bg-[var(--selection)]' />
|
||||
)}
|
||||
{isDropTarget && dropSide === 'right' && (
|
||||
<div className='pointer-events-none absolute top-0 right-[-1px] bottom-0 z-10 w-[2px] bg-[var(--selection)]' />
|
||||
)}
|
||||
{isRenaming ? (
|
||||
<div className='flex h-full w-full min-w-0 items-center px-[8px] py-[7px]'>
|
||||
<ColumnTypeIcon type={column.type} />
|
||||
@@ -2533,7 +2737,7 @@ const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type='button'
|
||||
className='flex h-full w-full min-w-0 cursor-pointer items-center px-[8px] py-[7px] outline-none'
|
||||
className='flex h-full w-full min-w-0 cursor-grab items-center px-[8px] py-[7px] outline-none active:cursor-grabbing'
|
||||
>
|
||||
<ColumnTypeIcon type={column.type} />
|
||||
<span className='ml-[6px] min-w-0 overflow-clip text-ellipsis whitespace-nowrap font-medium text-[13px] text-[var(--text-primary)]'>
|
||||
@@ -2589,6 +2793,8 @@ const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({
|
||||
)}
|
||||
<div
|
||||
className='-right-[3px] absolute top-0 z-[1] h-full w-[6px] cursor-col-resize'
|
||||
draggable={false}
|
||||
onDragStart={(e) => e.stopPropagation()}
|
||||
onPointerDown={handleResizePointerDown}
|
||||
/>
|
||||
</th>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Star, User } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { VerifiedBadge } from '@/components/ui/verified-badge'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
@@ -288,9 +289,14 @@ function TemplateCardInner({
|
||||
<div className='mt-[10px] flex items-center justify-between'>
|
||||
<div className='flex min-w-0 flex-1 items-center gap-[6px]'>
|
||||
{authorImageUrl ? (
|
||||
<div className='h-[20px] w-[20px] flex-shrink-0 overflow-hidden rounded-full'>
|
||||
<img src={authorImageUrl} alt={author} className='h-full w-full object-cover' />
|
||||
</div>
|
||||
<Image
|
||||
src={authorImageUrl}
|
||||
alt={author}
|
||||
width={20}
|
||||
height={20}
|
||||
className='flex-shrink-0 rounded-full object-cover'
|
||||
unoptimized
|
||||
/>
|
||||
) : (
|
||||
<div className='flex h-[20px] w-[20px] flex-shrink-0 items-center justify-center rounded-full bg-[var(--surface-7)]'>
|
||||
<User className='h-[12px] w-[12px] text-[var(--text-tertiary)]' />
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Square,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
@@ -220,7 +221,7 @@ interface StartInputFormatField {
|
||||
* position across sessions using the floating chat store.
|
||||
*/
|
||||
export function Chat() {
|
||||
const { activeWorkflowId } = useWorkflowRegistry()
|
||||
const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId)
|
||||
const blocks = useWorkflowStore((state) => state.blocks)
|
||||
const triggerWorkflowUpdate = useWorkflowStore((state) => state.triggerUpdate)
|
||||
const setSubBlockValue = useSubBlockStore((state) => state.setValue)
|
||||
@@ -242,7 +243,26 @@ export function Chat() {
|
||||
getConversationId,
|
||||
clearChat,
|
||||
exportChatCSV,
|
||||
} = useChatStore()
|
||||
} = useChatStore(
|
||||
useShallow((s) => ({
|
||||
isChatOpen: s.isChatOpen,
|
||||
chatPosition: s.chatPosition,
|
||||
chatWidth: s.chatWidth,
|
||||
chatHeight: s.chatHeight,
|
||||
setIsChatOpen: s.setIsChatOpen,
|
||||
setChatPosition: s.setChatPosition,
|
||||
setChatDimensions: s.setChatDimensions,
|
||||
messages: s.messages,
|
||||
addMessage: s.addMessage,
|
||||
selectedWorkflowOutputs: s.selectedWorkflowOutputs,
|
||||
setSelectedWorkflowOutput: s.setSelectedWorkflowOutput,
|
||||
appendMessageContent: s.appendMessageContent,
|
||||
finalizeMessageStream: s.finalizeMessageStream,
|
||||
getConversationId: s.getConversationId,
|
||||
clearChat: s.clearChat,
|
||||
exportChatCSV: s.exportChatCSV,
|
||||
}))
|
||||
)
|
||||
|
||||
const hasConsoleHydrated = useTerminalConsoleStore((state) => state._hasHydrated)
|
||||
const entriesFromStore = useTerminalConsoleStore((state) => state.entries)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import type React from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { RepeatIcon, SplitIcon } from 'lucide-react'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { Combobox, type ComboboxOptionGroup } from '@/components/emcn'
|
||||
import { getEffectiveBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
|
||||
import { hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils'
|
||||
@@ -80,7 +81,14 @@ export function OutputSelect({
|
||||
maxHeight = 200,
|
||||
}: OutputSelectProps) {
|
||||
const blocks = useWorkflowStore((state) => state.blocks)
|
||||
const { isShowingDiff, isDiffReady, hasActiveDiff, baselineWorkflow } = useWorkflowDiffStore()
|
||||
const { isShowingDiff, isDiffReady, hasActiveDiff, baselineWorkflow } = useWorkflowDiffStore(
|
||||
useShallow((s) => ({
|
||||
isShowingDiff: s.isShowingDiff,
|
||||
isDiffReady: s.isDiffReady,
|
||||
hasActiveDiff: s.hasActiveDiff,
|
||||
baselineWorkflow: s.baselineWorkflow,
|
||||
}))
|
||||
)
|
||||
const subBlockValues = useSubBlockStore((state) =>
|
||||
workflowId ? state.workflowValues[workflowId] : null
|
||||
)
|
||||
|
||||
@@ -58,7 +58,7 @@ const commands: CommandItem[] = [
|
||||
export function CommandList() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const { open: openSearchModal } = useSearchModalStore()
|
||||
const openSearchModal = useSearchModalStore((s) => s.open)
|
||||
const preventZoomRef = usePreventZoom()
|
||||
|
||||
const workspaceId = params.workspaceId as string | undefined
|
||||
@@ -179,6 +179,7 @@ export function CommandList() {
|
||||
)}
|
||||
>
|
||||
<div
|
||||
data-tour='command-list'
|
||||
className='pointer-events-auto flex flex-col gap-[8px]'
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
@@ -195,7 +196,6 @@ export function CommandList() {
|
||||
filter:
|
||||
'brightness(0) saturate(100%) invert(69%) sepia(0%) saturate(0%) hue-rotate(202deg) brightness(94%) contrast(89%)',
|
||||
}}
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -17,8 +17,6 @@ import { generateToolInputSchema, sanitizeToolName } from '@/lib/mcp/workflow-to
|
||||
import { normalizeInputFormatValue } from '@/lib/workflows/input-format'
|
||||
import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers'
|
||||
import type { InputFormatField } from '@/lib/workflows/types'
|
||||
import { McpServerFormModal } from '@/app/workspace/[workspaceId]/settings/components/mcp/components/mcp-server-form-modal/mcp-server-form-modal'
|
||||
import { useAllowedMcpDomains, useCreateMcpServer } from '@/hooks/queries/mcp'
|
||||
import {
|
||||
useAddWorkflowMcpTool,
|
||||
useDeleteWorkflowMcpTool,
|
||||
@@ -28,7 +26,7 @@ import {
|
||||
type WorkflowMcpServer,
|
||||
type WorkflowMcpTool,
|
||||
} from '@/hooks/queries/workflow-mcp-servers'
|
||||
import { useAvailableEnvVarKeys } from '@/hooks/use-available-env-vars'
|
||||
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
|
||||
@@ -102,11 +100,7 @@ export function McpDeploy({
|
||||
}: McpDeployProps) {
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
const [showMcpModal, setShowMcpModal] = useState(false)
|
||||
|
||||
const createMcpServer = useCreateMcpServer()
|
||||
const { data: allowedMcpDomains = null } = useAllowedMcpDomains()
|
||||
const availableEnvVars = useAvailableEnvVarKeys(workspaceId)
|
||||
const { navigateToSettings } = useSettingsNavigation()
|
||||
|
||||
const { data: servers = [], isLoading: isLoadingServers } = useWorkflowMcpServers(workspaceId)
|
||||
const addToolMutation = useAddWorkflowMcpTool()
|
||||
@@ -470,27 +464,17 @@ export function McpDeploy({
|
||||
|
||||
if (servers.length === 0) {
|
||||
return (
|
||||
<>
|
||||
<div className='flex h-full flex-col items-center justify-center gap-3'>
|
||||
<p className='text-[13px] text-[var(--text-muted)]'>
|
||||
Create an MCP Server in Settings → MCP Servers first.
|
||||
</p>
|
||||
<Button variant='tertiary' onClick={() => setShowMcpModal(true)}>
|
||||
Create MCP Server
|
||||
</Button>
|
||||
</div>
|
||||
<McpServerFormModal
|
||||
open={showMcpModal}
|
||||
onOpenChange={setShowMcpModal}
|
||||
mode='add'
|
||||
onSubmit={async (config) => {
|
||||
await createMcpServer.mutateAsync({ workspaceId, config: { ...config, enabled: true } })
|
||||
}}
|
||||
workspaceId={workspaceId}
|
||||
availableEnvVars={availableEnvVars}
|
||||
allowedMcpDomains={allowedMcpDomains}
|
||||
/>
|
||||
</>
|
||||
<div className='flex h-full flex-col items-center justify-center gap-3'>
|
||||
<p className='text-[13px] text-[var(--text-muted)]'>
|
||||
Create an MCP Server in Settings → MCP Servers first.
|
||||
</p>
|
||||
<Button
|
||||
variant='tertiary'
|
||||
onClick={() => navigateToSettings({ section: 'workflow-mcp-servers' })}
|
||||
>
|
||||
Create MCP Server
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import isEqual from 'lodash/isEqual'
|
||||
import { isEqual } from 'es-toolkit'
|
||||
import { useReactFlow } from 'reactflow'
|
||||
import { useStoreWithEqualityFn } from 'zustand/traditional'
|
||||
import { Combobox, type ComboboxOption } from '@/components/emcn/components'
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Button, Combobox } from '@/components/emcn/components'
|
||||
import { getSubscriptionStatus } from '@/lib/billing/client'
|
||||
import { getEnv, isTruthy } from '@/lib/core/config/env'
|
||||
import { getPollingProviderFromOAuth } from '@/lib/credential-sets/providers'
|
||||
import { writePendingCredentialCreateRequest } from '@/lib/credentials/client-state'
|
||||
import {
|
||||
getCanonicalScopesForProvider,
|
||||
getProviderIdFromServiceId,
|
||||
@@ -25,6 +26,7 @@ import { useOAuthCredentials } from '@/hooks/queries/oauth/oauth-credentials'
|
||||
import { useOrganizations } from '@/hooks/queries/organization'
|
||||
import { useSubscriptionData } from '@/hooks/queries/subscription'
|
||||
import { useCredentialRefreshTriggers } from '@/hooks/use-credential-refresh-triggers'
|
||||
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
|
||||
const isBillingEnabled = isTruthy(getEnv('NEXT_PUBLIC_BILLING_ENABLED'))
|
||||
@@ -51,10 +53,7 @@ export function CredentialSelector({
|
||||
const [showOAuthModal, setShowOAuthModal] = useState(false)
|
||||
const [editingValue, setEditingValue] = useState('')
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
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 { activeWorkflowId } = useWorkflowRegistry()
|
||||
const [storeValue, setStoreValue] = useSubBlockValue<string | null>(blockId, subBlock.id)
|
||||
|
||||
const requiredScopes = subBlock.requiredScopes || []
|
||||
@@ -104,7 +103,7 @@ export function CredentialSelector({
|
||||
} = useOAuthCredentials(effectiveProviderId, {
|
||||
enabled: Boolean(effectiveProviderId),
|
||||
workspaceId,
|
||||
workflowId: effectiveWorkflowId,
|
||||
workflowId: activeWorkflowId || undefined,
|
||||
})
|
||||
|
||||
const selectedCredential = useMemo(
|
||||
@@ -158,6 +157,7 @@ export function CredentialSelector({
|
||||
const displayValue = isEditing ? editingValue : resolvedLabel
|
||||
|
||||
useCredentialRefreshTriggers(refetchCredentials, effectiveProviderId, workspaceId)
|
||||
const { navigateToSettings } = useSettingsNavigation()
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(isOpen: boolean) => {
|
||||
@@ -199,8 +199,21 @@ export function CredentialSelector({
|
||||
)
|
||||
|
||||
const handleAddCredential = useCallback(() => {
|
||||
setShowOAuthModal(true)
|
||||
}, [])
|
||||
writePendingCredentialCreateRequest({
|
||||
workspaceId,
|
||||
type: 'oauth',
|
||||
providerId: effectiveProviderId,
|
||||
displayName: '',
|
||||
serviceId,
|
||||
requiredScopes: getCanonicalScopesForProvider(effectiveProviderId),
|
||||
requestedAt: Date.now(),
|
||||
returnOrigin: activeWorkflowId
|
||||
? { type: 'workflow', workflowId: activeWorkflowId }
|
||||
: undefined,
|
||||
})
|
||||
|
||||
navigateToSettings({ section: 'integrations' })
|
||||
}, [workspaceId, effectiveProviderId, serviceId, activeWorkflowId])
|
||||
|
||||
const getProviderIcon = useCallback((providerName: OAuthProvider) => {
|
||||
const { baseProvider } = parseProvider(providerName)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import isEqual from 'lodash/isEqual'
|
||||
import { isEqual } from 'es-toolkit'
|
||||
import { useStoreWithEqualityFn } from 'zustand/traditional'
|
||||
import { Badge } from '@/components/emcn'
|
||||
import { Combobox, type ComboboxOption } from '@/components/emcn/components'
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import isEqual from 'lodash/isEqual'
|
||||
import { isEqual } from 'es-toolkit'
|
||||
import { ChevronDown, ChevronsUpDown, ChevronUp, Plus } from 'lucide-react'
|
||||
import { Button, Popover, PopoverContent, PopoverItem, PopoverTrigger } from '@/components/emcn'
|
||||
import { Trash } from '@/components/emcn/icons/trash'
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createElement, useCallback, useEffect, useMemo, useRef, useState } from
|
||||
import { ExternalLink } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { Button, Combobox } from '@/components/emcn/components'
|
||||
import { writePendingCredentialCreateRequest } from '@/lib/credentials/client-state'
|
||||
import {
|
||||
getCanonicalScopesForProvider,
|
||||
getProviderIdFromServiceId,
|
||||
@@ -15,6 +16,7 @@ import { getMissingRequiredScopes } from '@/lib/oauth/utils'
|
||||
import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal'
|
||||
import { useOAuthCredentials } from '@/hooks/queries/oauth/oauth-credentials'
|
||||
import { useCredentialRefreshTriggers } from '@/hooks/use-credential-refresh-triggers'
|
||||
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
|
||||
const getProviderIcon = (providerName: OAuthProvider) => {
|
||||
@@ -72,10 +74,8 @@ export function ToolCredentialSelector({
|
||||
const [showOAuthModal, setShowOAuthModal] = useState(false)
|
||||
const [editingInputValue, setEditingInputValue] = useState('')
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
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 { activeWorkflowId } = useWorkflowRegistry()
|
||||
const { navigateToSettings } = useSettingsNavigation()
|
||||
|
||||
const selectedId = value || ''
|
||||
const effectiveLabel = label || `Select ${getProviderName(provider)} account`
|
||||
@@ -89,7 +89,7 @@ export function ToolCredentialSelector({
|
||||
} = useOAuthCredentials(effectiveProviderId, {
|
||||
enabled: Boolean(effectiveProviderId),
|
||||
workspaceId,
|
||||
workflowId: effectiveWorkflowId,
|
||||
workflowId: activeWorkflowId || undefined,
|
||||
})
|
||||
|
||||
const selectedCredential = useMemo(
|
||||
@@ -164,8 +164,18 @@ export function ToolCredentialSelector({
|
||||
)
|
||||
|
||||
const handleAddCredential = useCallback(() => {
|
||||
setShowOAuthModal(true)
|
||||
}, [])
|
||||
writePendingCredentialCreateRequest({
|
||||
workspaceId,
|
||||
type: 'oauth',
|
||||
providerId: effectiveProviderId,
|
||||
displayName: '',
|
||||
serviceId,
|
||||
requiredScopes: getCanonicalScopesForProvider(effectiveProviderId),
|
||||
requestedAt: Date.now(),
|
||||
})
|
||||
|
||||
navigateToSettings({ section: 'integrations' })
|
||||
}, [workspaceId, effectiveProviderId, serviceId])
|
||||
|
||||
const comboboxOptions = useMemo(() => {
|
||||
const options = credentials.map((cred) => ({
|
||||
|
||||
@@ -27,7 +27,6 @@ import type { McpToolSchema } from '@/lib/mcp/types'
|
||||
import { getProviderIdFromServiceId, type OAuthProvider, type OAuthService } from '@/lib/oauth'
|
||||
import { extractInputFieldsFromBlocks } from '@/lib/workflows/input-format'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import { McpServerFormModal } from '@/app/workspace/[workspaceId]/settings/components/mcp/components/mcp-server-form-modal/mcp-server-form-modal'
|
||||
import {
|
||||
LongInput,
|
||||
ShortInput,
|
||||
@@ -49,7 +48,6 @@ import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/c
|
||||
import type { WandControlHandlers } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block'
|
||||
import { getAllBlocks } from '@/blocks'
|
||||
import type { SubBlockConfig as BlockSubBlockConfig } from '@/blocks/types'
|
||||
import { BUILT_IN_TOOL_TYPES } from '@/blocks/utils'
|
||||
import { useMcpTools } from '@/hooks/mcp/use-mcp-tools'
|
||||
import {
|
||||
type CustomTool as CustomToolDefinition,
|
||||
@@ -57,15 +55,12 @@ import {
|
||||
} from '@/hooks/queries/custom-tools'
|
||||
import { useDeploymentInfo, useDeployWorkflow } from '@/hooks/queries/deployments'
|
||||
import {
|
||||
useAllowedMcpDomains,
|
||||
useCreateMcpServer,
|
||||
useForceRefreshMcpTools,
|
||||
useMcpServers,
|
||||
useMcpToolsEvents,
|
||||
useStoredMcpTools,
|
||||
} from '@/hooks/queries/mcp'
|
||||
import { useWorkflowState, useWorkflows } from '@/hooks/queries/workflows'
|
||||
import { useAvailableEnvVarKeys } from '@/hooks/use-available-env-vars'
|
||||
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
||||
import { usePermissionConfig } from '@/hooks/use-permission-config'
|
||||
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
|
||||
@@ -335,6 +330,24 @@ function resolveCustomToolFromReference(
|
||||
* These are distinguished from third-party integrations for categorization
|
||||
* in the tool selection dropdown.
|
||||
*/
|
||||
const BUILT_IN_TOOL_TYPES = new Set([
|
||||
'api',
|
||||
'file',
|
||||
'function',
|
||||
'knowledge',
|
||||
'search',
|
||||
'thinking',
|
||||
'image_generator',
|
||||
'video_generator',
|
||||
'vision',
|
||||
'translate',
|
||||
'tts',
|
||||
'stt',
|
||||
'memory',
|
||||
'table',
|
||||
'webhook_request',
|
||||
'workflow',
|
||||
])
|
||||
|
||||
/**
|
||||
* Checks if a block supports multiple operations.
|
||||
@@ -456,7 +469,6 @@ export const ToolInput = memo(function ToolInput({
|
||||
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlockId)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [customToolModalOpen, setCustomToolModalOpen] = useState(false)
|
||||
const [mcpModalOpen, setMcpModalOpen] = useState(false)
|
||||
const [editingToolIndex, setEditingToolIndex] = useState<number | null>(null)
|
||||
const [draggedIndex, setDraggedIndex] = useState<number | null>(null)
|
||||
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null)
|
||||
@@ -495,9 +507,6 @@ export const ToolInput = memo(function ToolInput({
|
||||
const forceRefreshMcpTools = useForceRefreshMcpTools()
|
||||
useMcpToolsEvents(workspaceId)
|
||||
const { navigateToSettings } = useSettingsNavigation()
|
||||
const createMcpServer = useCreateMcpServer()
|
||||
const { data: allowedMcpDomains = null } = useAllowedMcpDomains()
|
||||
const availableEnvVars = useAvailableEnvVarKeys(workspaceId)
|
||||
const mcpDataLoading = mcpLoading || mcpServersLoading
|
||||
|
||||
const { data: workflowsList = [] } = useWorkflows(workspaceId, { syncRegistry: false })
|
||||
@@ -1370,7 +1379,7 @@ export const ToolInput = memo(function ToolInput({
|
||||
icon: McpIcon,
|
||||
onSelect: () => {
|
||||
setOpen(false)
|
||||
setMcpModalOpen(true)
|
||||
navigateToSettings({ section: 'mcp' })
|
||||
},
|
||||
disabled: isPreview,
|
||||
})
|
||||
@@ -2086,18 +2095,6 @@ export const ToolInput = memo(function ToolInput({
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<McpServerFormModal
|
||||
open={mcpModalOpen}
|
||||
onOpenChange={setMcpModalOpen}
|
||||
mode='add'
|
||||
onSubmit={async (config) => {
|
||||
await createMcpServer.mutateAsync({ workspaceId, config: { ...config, enabled: true } })
|
||||
}}
|
||||
workspaceId={workspaceId}
|
||||
availableEnvVars={availableEnvVars}
|
||||
allowedMcpDomains={allowedMcpDomains}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -85,7 +85,7 @@ export function VariablesInput({
|
||||
const params = useParams()
|
||||
const workflowId = params.workflowId as string
|
||||
const [storeValue, setStoreValue] = useSubBlockValue<VariableAssignment[]>(blockId, subBlockId)
|
||||
const { variables: workflowVariables } = useVariablesStore()
|
||||
const workflowVariables = useVariablesStore((s) => s.variables)
|
||||
const accessiblePrefixes = useAccessibleReferencePrefixes(blockId)
|
||||
|
||||
const [showTags, setShowTags] = useState(false)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { isEqual } from 'lodash'
|
||||
import { isEqual } from 'es-toolkit'
|
||||
import { useStoreWithEqualityFn } from 'zustand/traditional'
|
||||
import {
|
||||
buildCanonicalIndex,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { isEqual } from 'lodash'
|
||||
import { isEqual } from 'es-toolkit'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { useStoreWithEqualityFn } from 'zustand/traditional'
|
||||
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { type JSX, type MouseEvent, memo, useCallback, useMemo, useRef, useState } from 'react'
|
||||
import isEqual from 'lodash/isEqual'
|
||||
import { isEqual } from 'es-toolkit'
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowLeftRight,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import isEqual from 'lodash/isEqual'
|
||||
import { isEqual } from 'es-toolkit'
|
||||
import {
|
||||
BookOpen,
|
||||
Check,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { useToolbarStore } from '@/stores/panel'
|
||||
|
||||
/**
|
||||
@@ -76,7 +77,12 @@ export function useToolbarResize({
|
||||
triggersContentRef,
|
||||
triggersHeaderRef,
|
||||
}: UseToolbarResizeProps) {
|
||||
const { toolbarTriggersHeight, setToolbarTriggersHeight } = useToolbarStore()
|
||||
const { toolbarTriggersHeight, setToolbarTriggersHeight } = useToolbarStore(
|
||||
useShallow((s) => ({
|
||||
toolbarTriggersHeight: s.toolbarTriggersHeight,
|
||||
setToolbarTriggersHeight: s.setToolbarTriggersHeight,
|
||||
}))
|
||||
)
|
||||
|
||||
const [isResizing, setIsResizing] = useState(false)
|
||||
const startYRef = useRef<number>(0)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { PANEL_WIDTH } from '@/stores/constants'
|
||||
import { usePanelStore } from '@/stores/panel'
|
||||
|
||||
@@ -13,7 +14,13 @@ const CONTENT_WINDOW_GAP = 8
|
||||
* @returns Resize state and handlers
|
||||
*/
|
||||
export function usePanelResize() {
|
||||
const { setPanelWidth, isResizing, setIsResizing } = usePanelStore()
|
||||
const { setPanelWidth, isResizing, setIsResizing } = usePanelStore(
|
||||
useShallow((s) => ({
|
||||
setPanelWidth: s.setPanelWidth,
|
||||
isResizing: s.isResizing,
|
||||
setIsResizing: s.setIsResizing,
|
||||
}))
|
||||
)
|
||||
|
||||
/**
|
||||
* Handles mouse down on resize handle
|
||||
|
||||
@@ -34,16 +34,9 @@ import { Lock, Unlock, Upload } from '@/components/emcn/icons'
|
||||
import { VariableIcon } from '@/components/icons'
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
import { generateWorkflowJson } from '@/lib/workflows/operations/import-export'
|
||||
import { ConversationListItem, MessageActions } from '@/app/workspace/[workspaceId]/components'
|
||||
import {
|
||||
assistantMessageHasRenderableContent,
|
||||
MessageContent,
|
||||
QueuedMessages,
|
||||
UserInput,
|
||||
UserMessageContent,
|
||||
} from '@/app/workspace/[workspaceId]/home/components'
|
||||
import { PendingTagIndicator } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
|
||||
import { useAutoScroll, useChat } from '@/app/workspace/[workspaceId]/home/hooks'
|
||||
import { ConversationListItem } from '@/app/workspace/[workspaceId]/components'
|
||||
import { MothershipChat } from '@/app/workspace/[workspaceId]/home/components'
|
||||
import { getWorkflowCopilotUseChatOptions, useChat } from '@/app/workspace/[workspaceId]/home/hooks'
|
||||
import type { FileAttachmentForApi } from '@/app/workspace/[workspaceId]/home/types'
|
||||
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
@@ -332,13 +325,15 @@ export const Panel = memo(function Panel() {
|
||||
removeFromQueue: copilotRemoveFromQueue,
|
||||
sendNow: copilotSendNow,
|
||||
editQueuedMessage: copilotEditQueuedMessage,
|
||||
} = useChat(workspaceId, copilotChatId, {
|
||||
apiPath: '/api/copilot/chat',
|
||||
stopPath: '/api/mothership/chat/stop',
|
||||
workflowId: activeWorkflowId || undefined,
|
||||
onTitleUpdate: loadCopilotChats,
|
||||
onToolResult: handleCopilotToolResult,
|
||||
})
|
||||
} = useChat(
|
||||
workspaceId,
|
||||
copilotChatId,
|
||||
getWorkflowCopilotUseChatOptions({
|
||||
workflowId: activeWorkflowId || undefined,
|
||||
onTitleUpdate: loadCopilotChats,
|
||||
onToolResult: handleCopilotToolResult,
|
||||
})
|
||||
)
|
||||
|
||||
const handleCopilotNewChat = useCallback(() => {
|
||||
if (!activeWorkflowId || !workspaceId) return
|
||||
@@ -403,9 +398,6 @@ export const Panel = memo(function Panel() {
|
||||
[copilotSendMessage]
|
||||
)
|
||||
|
||||
const { ref: copilotScrollRef, scrollToBottom: copilotScrollToBottom } =
|
||||
useAutoScroll(copilotIsSending)
|
||||
|
||||
/**
|
||||
* Mark hydration as complete on mount
|
||||
* This allows React to take over visibility control from CSS
|
||||
@@ -608,7 +600,7 @@ export const Panel = memo(function Panel() {
|
||||
<div className='flex gap-[6px]'>
|
||||
<DropdownMenu open={isMenuOpen} onOpenChange={setIsMenuOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button className='h-[30px] w-[30px] rounded-[5px]'>
|
||||
<Button className='h-[30px] w-[30px] rounded-[5px]' data-tour='panel-menu'>
|
||||
<MoreHorizontal />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
@@ -668,10 +660,11 @@ export const Panel = memo(function Panel() {
|
||||
</div>
|
||||
|
||||
{/* Deploy and Run */}
|
||||
<div className='flex gap-[6px]'>
|
||||
<div className='flex gap-[6px]' data-tour='deploy-run'>
|
||||
<Deploy activeWorkflowId={activeWorkflowId} userPermissions={userPermissions} />
|
||||
<Button
|
||||
className='h-[30px] gap-[8px] px-[10px]'
|
||||
data-tour='run-button'
|
||||
variant={isExecuting ? 'active' : 'tertiary'}
|
||||
onClick={isExecuting ? cancelWorkflow : () => runWorkflow()}
|
||||
disabled={!isExecuting && isButtonDisabled}
|
||||
@@ -699,6 +692,7 @@ export const Panel = memo(function Panel() {
|
||||
variant={_hasHydrated && activeTab === 'copilot' ? 'active' : 'ghost'}
|
||||
onClick={() => handleTabClick('copilot')}
|
||||
data-tab-button='copilot'
|
||||
data-tour='tab-copilot'
|
||||
>
|
||||
Copilot
|
||||
</Button>
|
||||
@@ -712,6 +706,7 @@ export const Panel = memo(function Panel() {
|
||||
variant={_hasHydrated && activeTab === 'toolbar' ? 'active' : 'ghost'}
|
||||
onClick={() => handleTabClick('toolbar')}
|
||||
data-tab-button='toolbar'
|
||||
data-tour='tab-toolbar'
|
||||
>
|
||||
Toolbar
|
||||
</Button>
|
||||
@@ -724,6 +719,7 @@ export const Panel = memo(function Panel() {
|
||||
variant={_hasHydrated && activeTab === 'editor' ? 'active' : 'ghost'}
|
||||
onClick={() => handleTabClick('editor')}
|
||||
data-tab-button='editor'
|
||||
data-tour='tab-editor'
|
||||
>
|
||||
Editor
|
||||
</Button>
|
||||
@@ -812,77 +808,21 @@ export const Panel = memo(function Panel() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={copilotScrollRef}
|
||||
className='min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-3 pt-2 pb-4'
|
||||
>
|
||||
<div className='space-y-4'>
|
||||
{copilotMessages.map((msg, index) => {
|
||||
if (msg.role === 'user') {
|
||||
return (
|
||||
<div key={msg.id} className='flex flex-col items-end gap-[6px] pt-2'>
|
||||
<div className='max-w-[85%] overflow-hidden rounded-[16px] bg-[var(--surface-5)] px-3 py-2'>
|
||||
<UserMessageContent content={msg.content} contexts={msg.contexts} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const hasAnyBlocks = Boolean(msg.contentBlocks?.length)
|
||||
const hasRenderableAssistant = assistantMessageHasRenderableContent(
|
||||
msg.contentBlocks ?? [],
|
||||
msg.content ?? ''
|
||||
)
|
||||
const isLastAssistant =
|
||||
msg.role === 'assistant' && index === copilotMessages.length - 1
|
||||
const isThisStreaming = copilotIsSending && isLastAssistant
|
||||
|
||||
if (!hasAnyBlocks && !msg.content?.trim() && isThisStreaming) {
|
||||
return <PendingTagIndicator key={msg.id} />
|
||||
}
|
||||
|
||||
if (!hasRenderableAssistant && !msg.content?.trim() && !isThisStreaming) {
|
||||
return null
|
||||
}
|
||||
|
||||
const isLastMessage = index === copilotMessages.length - 1
|
||||
|
||||
return (
|
||||
<div key={msg.id} className='group/msg relative pb-3'>
|
||||
{!isThisStreaming && (msg.content || msg.contentBlocks?.length) && (
|
||||
<div className='absolute right-0 bottom-0 z-10'>
|
||||
<MessageActions content={msg.content} requestId={msg.requestId} />
|
||||
</div>
|
||||
)}
|
||||
<MessageContent
|
||||
blocks={msg.contentBlocks || []}
|
||||
fallbackContent={msg.content}
|
||||
isStreaming={isThisStreaming}
|
||||
onOptionSelect={isLastMessage ? copilotSendMessage : undefined}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex-shrink-0 px-3 pb-3'>
|
||||
<QueuedMessages
|
||||
messageQueue={copilotMessageQueue}
|
||||
onRemove={copilotRemoveFromQueue}
|
||||
onSendNow={copilotSendNow}
|
||||
onEdit={handleCopilotEditQueuedMessage}
|
||||
/>
|
||||
<UserInput
|
||||
onSubmit={handleCopilotSubmit}
|
||||
isSending={copilotIsSending}
|
||||
onStopGeneration={copilotStopGeneration}
|
||||
isInitialView={false}
|
||||
userId={session?.user?.id}
|
||||
editValue={copilotEditingInputValue}
|
||||
onEditValueConsumed={clearCopilotEditingValue}
|
||||
/>
|
||||
</div>
|
||||
<MothershipChat
|
||||
className='min-h-0 flex-1'
|
||||
messages={copilotMessages}
|
||||
isSending={copilotIsSending}
|
||||
onSubmit={handleCopilotSubmit}
|
||||
onStopGeneration={copilotStopGeneration}
|
||||
messageQueue={copilotMessageQueue}
|
||||
onRemoveQueuedMessage={copilotRemoveFromQueue}
|
||||
onSendQueuedMessage={copilotSendNow}
|
||||
onEditQueuedMessage={handleCopilotEditQueuedMessage}
|
||||
userId={session?.user?.id}
|
||||
editValue={copilotEditingInputValue}
|
||||
onEditValueConsumed={clearCopilotEditingValue}
|
||||
layout='copilot-view'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Plus, X } from 'lucide-react'
|
||||
import Editor from 'react-simple-code-editor'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
@@ -92,17 +93,33 @@ const STRINGS = {
|
||||
* - Uses emcn Input/Code/Combobox components for a consistent UI
|
||||
*/
|
||||
export function Variables() {
|
||||
const { activeWorkflowId } = useWorkflowRegistry()
|
||||
const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId)
|
||||
|
||||
const { isOpen, position, width, height, setIsOpen, setPosition, setDimensions } =
|
||||
useVariablesStore()
|
||||
useVariablesStore(
|
||||
useShallow((s) => ({
|
||||
isOpen: s.isOpen,
|
||||
position: s.position,
|
||||
width: s.width,
|
||||
height: s.height,
|
||||
setIsOpen: s.setIsOpen,
|
||||
setPosition: s.setPosition,
|
||||
setDimensions: s.setDimensions,
|
||||
}))
|
||||
)
|
||||
|
||||
const { getVariablesByWorkflowId } = usePanelVariablesStore()
|
||||
const variables = usePanelVariablesStore((s) => s.variables)
|
||||
|
||||
const { collaborativeUpdateVariable, collaborativeAddVariable, collaborativeDeleteVariable } =
|
||||
useCollaborativeWorkflow()
|
||||
|
||||
const workflowVariables = activeWorkflowId ? getVariablesByWorkflowId(activeWorkflowId) : []
|
||||
const workflowVariables = useMemo(
|
||||
() =>
|
||||
activeWorkflowId
|
||||
? Object.values(variables).filter((v) => v.workflowId === activeWorkflowId)
|
||||
: [],
|
||||
[variables, activeWorkflowId]
|
||||
)
|
||||
|
||||
const actualPosition = useMemo(
|
||||
() => getVariablesPosition(position, width, height),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { memo, useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import isEqual from 'lodash/isEqual'
|
||||
import { isEqual } from 'es-toolkit'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { Handle, type NodeProps, Position, useUpdateNodeInternals } from 'reactflow'
|
||||
import { useStoreWithEqualityFn } from 'zustand/traditional'
|
||||
|
||||
@@ -4,6 +4,7 @@ import { memo, useCallback, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { Scan } from 'lucide-react'
|
||||
import { useReactFlow } from 'reactflow'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import {
|
||||
Button,
|
||||
ChevronDown,
|
||||
@@ -36,7 +37,9 @@ const logger = createLogger('WorkflowControls')
|
||||
export const WorkflowControls = memo(function WorkflowControls() {
|
||||
const reactFlowInstance = useReactFlow()
|
||||
const { fitViewToBounds } = useCanvasViewport(reactFlowInstance)
|
||||
const { mode, setMode } = useCanvasModeStore()
|
||||
const { mode, setMode } = useCanvasModeStore(
|
||||
useShallow((s) => ({ mode: s.mode, setMode: s.setMode }))
|
||||
)
|
||||
const { undo, redo } = useCollaborativeWorkflow()
|
||||
const showWorkflowControls = useShowActionBar()
|
||||
const updateSetting = useUpdateGeneralSetting()
|
||||
@@ -80,13 +83,14 @@ export const WorkflowControls = memo(function WorkflowControls() {
|
||||
}
|
||||
|
||||
if (!showWorkflowControls) {
|
||||
return null
|
||||
return <div data-tour='workflow-controls' className='hidden' />
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className='absolute bottom-[16px] left-[16px] z-10 flex h-[36px] items-center gap-[2px] rounded-[8px] border border-[var(--border)] bg-[var(--surface-1)] p-[4px]'
|
||||
data-tour='workflow-controls'
|
||||
onContextMenu={handleContextMenu}
|
||||
>
|
||||
{/* Canvas Mode Selector */}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans'
|
||||
import { processStreamingBlockLogs } from '@/lib/tokenization'
|
||||
import {
|
||||
@@ -97,12 +98,27 @@ function normalizeErrorMessage(error: unknown): string {
|
||||
export function useWorkflowExecution() {
|
||||
const queryClient = useQueryClient()
|
||||
const currentWorkflow = useCurrentWorkflow()
|
||||
const { activeWorkflowId, workflows } = useWorkflowRegistry()
|
||||
const { activeWorkflowId, workflows } = useWorkflowRegistry(
|
||||
useShallow((s) => ({ activeWorkflowId: s.activeWorkflowId, workflows: s.workflows }))
|
||||
)
|
||||
const { toggleConsole, addConsole, updateConsole, cancelRunningEntries, clearExecutionEntries } =
|
||||
useTerminalConsoleStore()
|
||||
useTerminalConsoleStore(
|
||||
useShallow((s) => ({
|
||||
toggleConsole: s.toggleConsole,
|
||||
addConsole: s.addConsole,
|
||||
updateConsole: s.updateConsole,
|
||||
cancelRunningEntries: s.cancelRunningEntries,
|
||||
clearExecutionEntries: s.clearExecutionEntries,
|
||||
}))
|
||||
)
|
||||
const hasHydrated = useTerminalConsoleStore((s) => s._hasHydrated)
|
||||
const { getAllVariables } = useEnvironmentStore()
|
||||
const { getVariablesByWorkflowId, variables } = useVariablesStore()
|
||||
const getAllVariables = useEnvironmentStore((s) => s.getAllVariables)
|
||||
const { getVariablesByWorkflowId, variables } = useVariablesStore(
|
||||
useShallow((s) => ({
|
||||
getVariablesByWorkflowId: s.getVariablesByWorkflowId,
|
||||
variables: s.variables,
|
||||
}))
|
||||
)
|
||||
const { isExecuting, isDebugging, pendingBlocks, executor, debugContext } =
|
||||
useCurrentWorkflowExecution()
|
||||
const setCurrentExecutionId = useExecutionStore((s) => s.setCurrentExecutionId)
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { WorkflowTour } from '@/app/workspace/[workspaceId]/components/product-tour'
|
||||
import { ErrorBoundary } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/error'
|
||||
|
||||
export default function WorkflowLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<main className='flex h-full flex-1 flex-col overflow-hidden'>
|
||||
<ErrorBoundary>{children}</ErrorBoundary>
|
||||
<WorkflowTour />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3909,7 +3909,11 @@ const WorkflowContent = React.memo(
|
||||
return (
|
||||
<div className='flex h-full w-full overflow-hidden'>
|
||||
<div className='flex min-w-0 flex-1 flex-col'>
|
||||
<div ref={canvasContainerRef} className='relative flex-1 overflow-hidden'>
|
||||
<div
|
||||
ref={canvasContainerRef}
|
||||
className='relative flex-1 overflow-hidden'
|
||||
data-tour='canvas'
|
||||
>
|
||||
{!isWorkflowReady && (
|
||||
<div className='absolute inset-0 z-[5] flex items-center justify-center bg-[var(--bg)]'>
|
||||
<div
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { memo, useCallback, useEffect, useMemo } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { EmptyAreaContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/empty-area-context-menu'
|
||||
import { FolderItem } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item'
|
||||
import { WorkflowItem } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item'
|
||||
@@ -78,7 +79,14 @@ export const WorkflowList = memo(function WorkflowList({
|
||||
}: WorkflowListProps) {
|
||||
const { isLoading: foldersLoading } = useFolders(workspaceId)
|
||||
const folders = useFolderStore((state) => state.folders)
|
||||
const { getFolderTree, expandedFolders, getFolderPath, setExpanded } = useFolderStore()
|
||||
const { getFolderTree, expandedFolders, getFolderPath, setExpanded } = useFolderStore(
|
||||
useShallow((s) => ({
|
||||
getFolderTree: s.getFolderTree,
|
||||
expandedFolders: s.expandedFolders,
|
||||
getFolderPath: s.getFolderPath,
|
||||
setExpanded: s.setExpanded,
|
||||
}))
|
||||
)
|
||||
|
||||
const {
|
||||
isOpen: isEmptyAreaMenuOpen,
|
||||
|
||||
@@ -47,7 +47,8 @@ export function useDragDrop(options: UseDragDropOptions = {}) {
|
||||
const workspaceId = params.workspaceId as string | undefined
|
||||
const reorderWorkflowsMutation = useReorderWorkflows()
|
||||
const reorderFoldersMutation = useReorderFolders()
|
||||
const { setExpanded, expandedFolders } = useFolderStore()
|
||||
const setExpanded = useFolderStore((s) => s.setExpanded)
|
||||
const expandedFolders = useFolderStore((s) => s.expandedFolders)
|
||||
|
||||
const handleAutoScroll = useCallback(() => {
|
||||
if (!scrollContainerRef.current) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback } from 'react'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { useFolderStore } from '@/stores/folders/store'
|
||||
|
||||
interface UseFolderSelectionProps {
|
||||
@@ -44,7 +45,15 @@ export function useFolderSelection({
|
||||
selectFolderOnly,
|
||||
selectFolderRange,
|
||||
toggleFolderSelection,
|
||||
} = useFolderStore()
|
||||
} = useFolderStore(
|
||||
useShallow((s) => ({
|
||||
selectedFolders: s.selectedFolders,
|
||||
lastSelectedFolderId: s.lastSelectedFolderId,
|
||||
selectFolderOnly: s.selectFolderOnly,
|
||||
selectFolderRange: s.selectFolderRange,
|
||||
toggleFolderSelection: s.toggleFolderSelection,
|
||||
}))
|
||||
)
|
||||
|
||||
/**
|
||||
* Deselect any workflows whose folder (or any ancestor folder) is currently selected.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { SIDEBAR_WIDTH } from '@/stores/constants'
|
||||
import { useSidebarStore } from '@/stores/sidebar/store'
|
||||
|
||||
@@ -10,7 +11,13 @@ import { useSidebarStore } from '@/stores/sidebar/store'
|
||||
* @returns Resize state and handlers
|
||||
*/
|
||||
export function useSidebarResize() {
|
||||
const { setSidebarWidth, isResizing, setIsResizing } = useSidebarStore()
|
||||
const { setSidebarWidth, isResizing, setIsResizing } = useSidebarStore(
|
||||
useShallow((s) => ({
|
||||
setSidebarWidth: s.setSidebarWidth,
|
||||
isResizing: s.isResizing,
|
||||
setIsResizing: s.setIsResizing,
|
||||
}))
|
||||
)
|
||||
|
||||
/**
|
||||
* Handles mouse down on resize handle
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback } from 'react'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { useFolderStore } from '@/stores/folders/store'
|
||||
|
||||
interface UseWorkflowSelectionProps {
|
||||
@@ -30,7 +31,14 @@ export function useWorkflowSelection({
|
||||
activeWorkflowId,
|
||||
workflowAncestorFolderIds,
|
||||
}: UseWorkflowSelectionProps) {
|
||||
const { selectedWorkflows, selectOnly, selectRange, toggleWorkflowSelection } = useFolderStore()
|
||||
const { selectedWorkflows, selectOnly, selectRange, toggleWorkflowSelection } = useFolderStore(
|
||||
useShallow((s) => ({
|
||||
selectedWorkflows: s.selectedWorkflows,
|
||||
selectOnly: s.selectOnly,
|
||||
selectRange: s.selectRange,
|
||||
toggleWorkflowSelection: s.toggleWorkflowSelection,
|
||||
}))
|
||||
)
|
||||
|
||||
/**
|
||||
* After a workflow selection change, deselect any folder that is an ancestor of a selected
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user