Compare commits

...

16 Commits

Author SHA1 Message Date
Vikhyath Mondreti
a02016e247 v0.4.24: sso for chat deployment, usage indicator for file storage, mcp improvements, local kb file storage 2025-10-27 15:32:53 -07:00
Waleed
8620ab255a improvement(docs): added a copy page button to the docs pages (#1743)
* improvement(docs): added a copy page button to the docs pages

* added copy page button & fixed TOC alignment
2025-10-27 15:05:27 -07:00
Waleed
47ddfb639e fix(dropdown): auto-add character to trigger tag dropdown on connection tag drop in agent tools (#1742) 2025-10-27 14:28:29 -07:00
Waleed
5d48c2780c improvement(ui): enhance ring outline for code subblock and mcp tool subblocks in agent (#1741) 2025-10-27 13:59:19 -07:00
Waleed
38614fad79 fix(mcp): resolve variables & block references in mcp subblocks (#1735)
* fix(mcp): resolve variables & block references in mcp subblocks

* cleanup

* ack PR comment

* added variables access to mcp tools when added in agent block

* fix sequential migrations issues
2025-10-27 13:13:11 -07:00
Waleed
6f32aea96b feat(kb): added support for local file storage for knowledgebase (#1738)
* feat(kb): added support for local file storage for knowledgebase

* updated tests

* ack PR comments

* added back env example
2025-10-27 11:28:45 -07:00
Waleed
98e98496e8 Revert "improvement(consts): removed redundant default consts in favor of env…" (#1739)
This reverts commit 659b46fa2f.
2025-10-26 23:06:14 -07:00
Waleed
659b46fa2f improvement(consts): removed redundant default consts in favor of envvar defaults for storage & usage limits (#1737)
* improvement(consts): removed redundant default consts in favor of envvar defaults for storage & usage limits

* remove unnecessary tests
2025-10-26 21:43:00 -07:00
Waleed
fb3d6d4c88 feat(files): added usage indicator for file storage to settings (#1736)
* feat(files): added usage indicator for file storage to settings

* cleanup
2025-10-26 21:36:25 -07:00
Waleed
ec2cc82b72 feat(i18n): update translations (#1734) 2025-10-26 18:35:20 -07:00
Adam Gough
274d5e3afc fix(clay): fixed clay tool (#1725)
* fixed clay tool

* added metadata

* added metadata to types

* fix(clay): remove (optional) from subblock name

* regen docs
2025-10-26 18:30:08 -07:00
Waleed
c552bb9c5f fix(elevenlabs): added internal auth helper for proxy routes (#1732)
* fix(elevenlabs): added internal auth helper for proxy routes

* remove concurrent tests

* build fix
2025-10-25 17:54:27 -07:00
Vikhyath Mondreti
ad7b791242 improvement(deployments): simplify deployments for chat and indicate active version (#1730)
* improvement(deployment-ux): deployment should indicate and make details configurable when activating previous version

* fix activation UI

* remove redundant code

* revert pulsing dot

* fix redeploy bug

* bill workspace owner for deployed chat

* deployed chat

* fix bugs

* fix tests, address greptile

* fix

* ui bug to load api key

* fix qdrant fetch tool
2025-10-25 16:55:34 -07:00
Waleed
ce4893a53c feat(sso-chat-deployment): added sso auth option for chat deployment (#1729)
* feat(sso-chat-deployment): added sso auth option for chat deployment

* ack PR comments
2025-10-25 14:58:25 -07:00
Vikhyath Mondreti
7f1ff7fd86 fix(billing): should allow restoring subscription (#1728)
* fix(already-cancelled-sub): UI should allow restoring subscription

* restore functionality fixed

* fix
2025-10-25 12:59:57 -07:00
Adam Gough
517f1a91b6 fix(google-scopes): added forms and different drive scope (#1532)
* added google forms scope and google drive scope

* added back file scope

---------

Co-authored-by: Adam Gough <adamgough@Mac.attlocal.net>
Co-authored-by: Adam Gough <adamgough@Mac-530.lan>
2025-10-25 12:08:49 -07:00
97 changed files with 3090 additions and 1666 deletions

View File

@@ -6,6 +6,7 @@ import Link from 'next/link'
import { notFound } from 'next/navigation'
import { StructuredData } from '@/components/structured-data'
import { CodeBlock } from '@/components/ui/code-block'
import { CopyPageButton } from '@/components/ui/copy-page-button'
import { source } from '@/lib/source'
export const dynamic = 'force-dynamic'
@@ -193,8 +194,19 @@ export default async function Page(props: { params: Promise<{ slug?: string[]; l
component: <CustomFooter />,
}}
>
<DocsTitle>{page.data.title}</DocsTitle>
<DocsDescription>{page.data.description}</DocsDescription>
<div className='relative'>
<div className='absolute top-1 right-0'>
<CopyPageButton
content={`# ${page.data.title}
${page.data.description || ''}
${page.data.content || ''}`}
/>
</div>
<DocsTitle>{page.data.title}</DocsTitle>
<DocsDescription>{page.data.description}</DocsDescription>
</div>
<DocsBody>
<MDX
components={{

View File

@@ -36,7 +36,9 @@
/* Shift the sidebar slightly left from the content edge for extra breathing room */
--sidebar-shift: 90px;
--sidebar-offset: max(0px, calc(var(--edge-gutter) - var(--sidebar-shift)));
--toc-offset: var(--edge-gutter);
/* Shift TOC slightly right to match sidebar spacing for symmetry */
--toc-shift: 90px;
--toc-offset: max(0px, calc(var(--edge-gutter) - var(--toc-shift)));
/* Sidebar and TOC have 20px internal padding - navbar accounts for this directly */
/* Extra gap between sidebar/TOC and the main text content */
--content-gap: 1.75rem;
@@ -107,8 +109,21 @@ aside#nd-sidebar {
aside#nd-sidebar {
left: var(--sidebar-offset) !important;
}
[data-toc] {
margin-right: var(--toc-offset) !important;
/* TOC positioning - target all possible selectors */
[data-toc],
aside[data-toc],
div[data-toc],
.fd-toc,
#nd-toc,
nav[data-toc],
aside:has([role="complementary"]) {
right: var(--toc-offset) !important;
}
/* Alternative TOC container targeting */
[data-docs-page] > aside:last-child,
main ~ aside {
right: var(--toc-offset) !important;
}
}

View File

@@ -19,13 +19,13 @@ export function Navbar() {
{/* Desktop: Single row layout */}
<div className='hidden h-16 w-full items-center lg:flex'>
<div
className='grid w-full grid-cols-[auto_1fr_auto] items-center'
className='relative flex w-full items-center justify-between'
style={{
paddingLeft: 'calc(var(--sidebar-offset) + 20px)',
paddingRight: 'calc(var(--toc-offset) + 20px)',
paddingRight: 'calc(var(--toc-offset) + 60px)',
}}
>
{/* Left cluster: translate by sidebar delta to align with sidebar edge */}
{/* Left cluster: logo */}
<div className='flex items-center'>
<Link href='/' className='flex min-w-[100px] items-center'>
<Image
@@ -38,12 +38,12 @@ export function Navbar() {
</Link>
</div>
{/* Center cluster: search */}
<div className='flex flex-1 items-center justify-center pl-32'>
{/* Center cluster: search - absolutely positioned to center */}
<div className='-translate-x-1/2 absolute left-1/2 flex items-center justify-center'>
<SearchTrigger />
</div>
{/* Right cluster aligns with TOC edge using the same right gutter */}
{/* Right cluster aligns with TOC edge */}
<div className='flex items-center gap-4'>
<Link
href='https://sim.ai'

View File

@@ -0,0 +1,42 @@
'use client'
import { useState } from 'react'
import { Check, Copy } from 'lucide-react'
interface CopyPageButtonProps {
content: string
}
export function CopyPageButton({ content }: CopyPageButtonProps) {
const [copied, setCopied] = useState(false)
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(content)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch (err) {
console.error('Failed to copy:', err)
}
}
return (
<button
onClick={handleCopy}
className='flex items-center gap-1.5 rounded-lg border border-border/40 bg-background px-2.5 py-1.5 text-muted-foreground/60 text-sm transition-all hover:border-border hover:bg-accent/50 hover:text-muted-foreground'
aria-label={copied ? 'Copied to clipboard' : 'Copy page content'}
>
{copied ? (
<>
<Check className='h-4 w-4' />
<span>Copied</span>
</>
) : (
<>
<Copy className='h-4 w-4' />
<span>Copy page</span>
</>
)}
</button>
)
}

View File

@@ -207,18 +207,18 @@ Populate Clay with data from a JSON file. Enables direct communication and notif
#### Input
| Parameter | Type | Required | Description |
| Parameter | Typ | Erforderlich | Beschreibung |
| --------- | ---- | -------- | ----------- |
| `webhookURL` | string | Yes | The webhook URL to populate |
| `data` | json | Yes | The data to populate |
| `authToken` | string | Yes | Auth token for Clay webhook authentication |
| `webhookURL` | string | Ja | Die Webhook-URL, die befüllt werden soll |
| `data` | json | Ja | Die Daten, die befüllt werden sollen |
| `authToken` | string | Nein | Optionaler Auth-Token für die Clay-Webhook-Authentifizierung \(die meisten Webhooks benötigen dies nicht\) |
#### Output
| Parameter | Type | Description |
| Parameter | Typ | Beschreibung |
| --------- | ---- | ----------- |
| `success` | boolean | Operation success status |
| `output` | json | Clay populate operation results including response data from Clay webhook |
| `data` | json | Antwortdaten vom Clay-Webhook |
| `metadata` | object | Webhook-Antwort-Metadaten |
## Notes

View File

@@ -7,7 +7,7 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="discord"
color="#E0E0E0"
color="#5865F2"
icon={true}
iconSvg={`<svg className="block-icon"

View File

@@ -139,7 +139,8 @@ Suche nach ähnlichen Vektoren in einer Qdrant-Sammlung
| `collection` | string | Ja | Sammlungsname |
| `vector` | array | Ja | Zu suchender Vektor |
| `limit` | number | Nein | Anzahl der zurückzugebenden Ergebnisse |
| `filter` | object | Nein | Filter für die Suche |
| `filter` | object | Nein | Auf die Suche anzuwendender Filter |
| `search_return_data` | string | Nein | Aus der Suche zurückzugebende Daten |
| `with_payload` | boolean | Nein | Payload in Antwort einschließen |
| `with_vector` | boolean | Nein | Vektor in Antwort einschließen |
@@ -161,7 +162,8 @@ Punkte anhand der ID aus einer Qdrant-Sammlung abrufen
| `url` | string | Ja | Qdrant-Basis-URL |
| `apiKey` | string | Nein | Qdrant-API-Schlüssel \(optional\) |
| `collection` | string | Ja | Sammlungsname |
| `ids` | array | Ja | Array von abzurufenden Punkt-IDs |
| `ids` | array | Ja | Array von Punkt-IDs zum Abrufen |
| `fetch_return_data` | string | Nein | Aus dem Abruf zurückzugebende Daten |
| `with_payload` | boolean | Nein | Payload in Antwort einschließen |
| `with_vector` | boolean | Nein | Vektor in Antwort einschließen |

View File

@@ -214,14 +214,14 @@ Populate Clay with data from a JSON file. Enables direct communication and notif
| --------- | ---- | -------- | ----------- |
| `webhookURL` | string | Yes | The webhook URL to populate |
| `data` | json | Yes | The data to populate |
| `authToken` | string | Yes | Auth token for Clay webhook authentication |
| `authToken` | string | No | Optional auth token for Clay webhook authentication \(most webhooks do not require this\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `success` | boolean | Operation success status |
| `output` | json | Clay populate operation results including response data from Clay webhook |
| `data` | json | Response data from Clay webhook |
| `metadata` | object | Webhook response metadata |

View File

@@ -7,7 +7,7 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="discord"
color="#E0E0E0"
color="#5865F2"
icon={true}
iconSvg={`<svg className="block-icon"

View File

@@ -143,6 +143,7 @@ Search for similar vectors in a Qdrant collection
| `vector` | array | Yes | Vector to search for |
| `limit` | number | No | Number of results to return |
| `filter` | object | No | Filter to apply to the search |
| `search_return_data` | string | No | Data to return from search |
| `with_payload` | boolean | No | Include payload in response |
| `with_vector` | boolean | No | Include vector in response |
@@ -165,6 +166,7 @@ Fetch points by ID from a Qdrant collection
| `apiKey` | string | No | Qdrant API key \(optional\) |
| `collection` | string | Yes | Collection name |
| `ids` | array | Yes | Array of point IDs to fetch |
| `fetch_return_data` | string | No | Data to return from fetch |
| `with_payload` | boolean | No | Include payload in response |
| `with_vector` | boolean | No | Include vector in response |

View File

@@ -207,18 +207,18 @@ Poblar Clay con datos de un archivo JSON. Permite comunicación directa y notifi
#### Entrada
| Parámetro | Tipo | Requerido | Descripción |
| --------- | ---- | -------- | ----------- |
| `webhookURL` | string | Sí | La URL del webhook para poblar |
| `data` | json | Sí | Los datos para poblar |
| `authToken` | string | | Token de autenticación para la autenticación del webhook de Clay |
| Parámetro | Tipo | Obligatorio | Descripción |
| --------- | ---- | ----------- | ----------- |
| `webhookURL` | string | Sí | La URL del webhook a completar |
| `data` | json | Sí | Los datos para completar |
| `authToken` | string | No | Token de autenticación opcional para la autenticación del webhook de Clay \(la mayoría de los webhooks no requieren esto\) |
#### Salida
| Parámetro | Tipo | Descripción |
| --------- | ---- | ----------- |
| `success` | boolean | Estado de éxito de la operación |
| `output` | json | Resultados de la operación de poblado de Clay incluyendo datos de respuesta del webhook de Clay |
| `data` | json | Datos de respuesta del webhook de Clay |
| `metadata` | object | Metadatos de respuesta del webhook |
## Notas

View File

@@ -7,7 +7,7 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="discord"
color="#E0E0E0"
color="#5865F2"
icon={true}
iconSvg={`<svg className="block-icon"

View File

@@ -133,13 +133,14 @@ Buscar vectores similares en una colección de Qdrant
#### Entrada
| Parámetro | Tipo | Obligatorio | Descripción |
| --------- | ---- | -------- | ----------- |
| --------- | ---- | ----------- | ----------- |
| `url` | string | Sí | URL base de Qdrant |
| `apiKey` | string | No | Clave API de Qdrant \(opcional\) |
| `collection` | string | Sí | Nombre de la colección |
| `vector` | array | Sí | Vector a buscar |
| `vector` | array | Sí | Vector para buscar |
| `limit` | number | No | Número de resultados a devolver |
| `filter` | object | No | Filtro a aplicar a la búsqueda |
| `filter` | object | No | Filtro para aplicar a la búsqueda |
| `search_return_data` | string | No | Datos a devolver de la búsqueda |
| `with_payload` | boolean | No | Incluir payload en la respuesta |
| `with_vector` | boolean | No | Incluir vector en la respuesta |
@@ -157,11 +158,12 @@ Obtener puntos por ID desde una colección de Qdrant
#### Entrada
| Parámetro | Tipo | Obligatorio | Descripción |
| --------- | ---- | -------- | ----------- |
| --------- | ---- | ----------- | ----------- |
| `url` | string | Sí | URL base de Qdrant |
| `apiKey` | string | No | Clave API de Qdrant \(opcional\) |
| `collection` | string | Sí | Nombre de la colección |
| `ids` | array | Sí | Array de IDs de puntos a obtener |
| `ids` | array | Sí | Array de IDs de puntos para recuperar |
| `fetch_return_data` | string | No | Datos a devolver de la recuperación |
| `with_payload` | boolean | No | Incluir payload en la respuesta |
| `with_vector` | boolean | No | Incluir vector en la respuesta |

View File

@@ -211,14 +211,14 @@ Remplir Clay avec des données provenant d'un fichier JSON. Permet une communica
| --------- | ---- | ---------- | ----------- |
| `webhookURL` | string | Oui | L'URL du webhook à remplir |
| `data` | json | Oui | Les données à remplir |
| `authToken` | string | Oui | Jeton d'authentification pour l'authentification du webhook Clay |
| `authToken` | string | Non | Jeton d'authentification optionnel pour l'authentification du webhook Clay \(la plupart des webhooks ne nécessitent pas cela\) |
#### Sortie
| Paramètre | Type | Description |
| --------- | ---- | ----------- |
| `success` | boolean | Statut de réussite de l'opération |
| `output` | json | Résultats de l'opération de remplissage Clay incluant les données de réponse du webhook Clay |
| `data` | json | Données de réponse du webhook Clay |
| `metadata` | object | Métadonnées de réponse du webhook |
## Notes

View File

@@ -7,7 +7,7 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="discord"
color="#E0E0E0"
color="#5865F2"
icon={true}
iconSvg={`<svg className="block-icon"

View File

@@ -133,13 +133,14 @@ Rechercher des vecteurs similaires dans une collection Qdrant
#### Entrée
| Paramètre | Type | Obligatoire | Description |
| --------- | ---- | ---------- | ----------- |
| `url` | chaîne | Oui | URL de base Qdrant |
| --------- | ---- | ----------- | ----------- |
| `url` | chaîne | Oui | URL de base de Qdrant |
| `apiKey` | chaîne | Non | Clé API Qdrant \(facultative\) |
| `collection` | chaîne | Oui | Nom de la collection |
| `vector` | tableau | Oui | Vecteur à rechercher |
| `limit` | nombre | Non | Nombre de résultats à retourner |
| `filter` | objet | Non | Filtre à appliquer à la recherche |
| `search_return_data` | chaîne | Non | Données à retourner de la recherche |
| `with_payload` | booléen | Non | Inclure la charge utile dans la réponse |
| `with_vector` | booléen | Non | Inclure le vecteur dans la réponse |
@@ -157,11 +158,12 @@ Récupérer des points par ID depuis une collection Qdrant
#### Entrée
| Paramètre | Type | Obligatoire | Description |
| --------- | ---- | ---------- | ----------- |
| `url` | chaîne | Oui | URL de base Qdrant |
| --------- | ---- | ----------- | ----------- |
| `url` | chaîne | Oui | URL de base de Qdrant |
| `apiKey` | chaîne | Non | Clé API Qdrant \(facultative\) |
| `collection` | chaîne | Oui | Nom de la collection |
| `ids` | tableau | Oui | Tableau d'identifiants de points à récupérer |
| `fetch_return_data` | chaîne | Non | Données à retourner de la récupération |
| `with_payload` | booléen | Non | Inclure la charge utile dans la réponse |
| `with_vector` | booléen | Non | Inclure le vecteur dans la réponse |

View File

@@ -207,18 +207,18 @@ Populate Clay with data from a JSON file. Enables direct communication and notif
#### Input
| Parameter | Type | Required | Description |
| パラメータ | 型 | 必須 | 説明 |
| --------- | ---- | -------- | ----------- |
| `webhookURL` | string | Yes | The webhook URL to populate |
| `data` | json | Yes | The data to populate |
| `authToken` | string | Yes | Auth token for Clay webhook authentication |
| `webhookURL` | string | はい | 設定するウェブフックURL |
| `data` | json | はい | 設定するデータ |
| `authToken` | string | いいえ | Clayウェブフック認証用のオプション認証トークンほとんどのウェブフックではこれは不要です |
#### Output
| Parameter | Type | Description |
| パラメータ | 型 | 説明 |
| --------- | ---- | ----------- |
| `success` | boolean | Operation success status |
| `output` | json | Clay populate operation results including response data from Clay webhook |
| `data` | json | Clayウェブフックからのレスポンスデータ |
| `metadata` | object | ウェブフックレスポンスのメタデータ |
## Notes

View File

@@ -7,7 +7,7 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="discord"
color="#E0E0E0"
color="#5865F2"
icon={true}
iconSvg={`<svg className="block-icon"

View File

@@ -140,6 +140,7 @@ Qdrantコレクション内で類似ベクトルを検索する
| `vector` | array | はい | 検索対象のベクトル |
| `limit` | number | いいえ | 返す結果の数 |
| `filter` | object | いいえ | 検索に適用するフィルター |
| `search_return_data` | string | いいえ | 検索から返すデータ |
| `with_payload` | boolean | いいえ | レスポンスにペイロードを含める |
| `with_vector` | boolean | いいえ | レスポンスにベクトルを含める |
@@ -162,6 +163,7 @@ QdrantコレクションからIDによってポイントを取得する
| `apiKey` | string | いいえ | Qdrant APIキーオプション |
| `collection` | string | はい | コレクション名 |
| `ids` | array | はい | 取得するポイントIDの配列 |
| `fetch_return_data` | string | いいえ | 取得から返すデータ |
| `with_payload` | boolean | いいえ | レスポンスにペイロードを含める |
| `with_vector` | boolean | いいえ | レスポンスにベクトルを含める |

View File

@@ -209,16 +209,16 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
| 参数 | 类型 | 必需 | 描述 |
| --------- | ---- | -------- | ----------- |
| `webhookURL` | string | 是 | 用于填充的 webhook URL |
| `webhookURL` | string | 是 | 填充的 webhook URL |
| `data` | json | 是 | 要填充的数据 |
| `authToken` | string | | 用于 Clay webhook 认证的授权令牌 |
| `authToken` | string | | 用于 Clay webhook 认证的可选身份验证令牌(大多数 webhook 不需要此令牌) |
#### 输出
| 参数 | 类型 | 描述 |
| --------- | ---- | ----------- |
| `success` | boolean | 操作成功状态 |
| `output` | json | Clay 填充操作结果,包括来自 Clay webhook 响应数据 |
| `data` | json | 来自 Clay webhook 的响应数据 |
| `metadata` | object | webhook 响应数据 |
## 注意事项

View File

@@ -7,7 +7,7 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="discord"
color="#E0E0E0"
color="#5865F2"
icon={true}
iconSvg={`<svg className="block-icon"

View File

@@ -138,8 +138,9 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
| `apiKey` | string | 否 | Qdrant API 密钥(可选)|
| `collection` | string | 是 | 集合名称 |
| `vector` | array | 是 | 要搜索的向量 |
| `limit` | number | 否 | 返回结果数量 |
| `filter` | object | 否 | 应用于搜索的过滤器 |
| `limit` | number | 否 | 返回结果数量 |
| `filter` | object | 否 | 应用于搜索的过滤器 |
| `search_return_data` | string | 否 | 搜索中要返回的数据 |
| `with_payload` | boolean | 否 | 在响应中包含有效负载 |
| `with_vector` | boolean | 否 | 在响应中包含向量 |
@@ -162,6 +163,7 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
| `apiKey` | string | 否 | Qdrant API 密钥(可选)|
| `collection` | string | 是 | 集合名称 |
| `ids` | array | 是 | 要获取的点 ID 数组 |
| `fetch_return_data` | string | 否 | 获取中要返回的数据 |
| `with_payload` | boolean | 否 | 在响应中包含有效负载 |
| `with_vector` | boolean | 否 | 在响应中包含向量 |

View File

@@ -945,13 +945,13 @@ checksums:
content/17: f5bef3db56ed3a56395f7ae1fa41ecf3
content/18: 7ca733ac5374e92a9cc8ef35e1075fb1
content/19: 371d0e46b4bd2c23f559b8bc112f6955
content/20: 8ce52b8ffed51482dff6fa0f2846c498
content/20: 0eada001684acb8efe28fcfed38a5163
content/21: bcadfc362b69078beee0088e5936c98b
content/22: b875ec2f16d200917e9860b49a5a9772
content/23: c0c2276dd4207eb2b08d4dc9132e7ec3
content/24: 85de953906920f3fb4eafa8fdb918feb
content/25: 371d0e46b4bd2c23f559b8bc112f6955
content/26: f63654037687387924343d8f7453f379
content/26: b0cf90320ac6b98d5bf00b87052cd76e
content/27: bcadfc362b69078beee0088e5936c98b
content/28: e62b89406f01af79e2e293d352aa2499
content/29: b3f310d5ef115bea5a8b75bf25d7ea9a
@@ -2183,7 +2183,7 @@ checksums:
meta/title: 06ec7d95ab44931ed9d1925e4063d703
meta/description: cc9ab492bdda4a2cb9085537d6e6a0c0
content/0: 1b031fb0c62c46b177aeed5c3d3f8f80
content/1: 73fb594a22fcf560087b53ea6aa592f6
content/1: f744e92fcc234d02bb9b352a2ab1e1e3
content/2: b229bf34f0106ccb5af6f0b2a044e21a
content/3: 45e7cee1fa342c4d13a1a6cb70733a14
content/4: 62db68be640983ea9a383ee162bd8463
@@ -2264,9 +2264,9 @@ checksums:
content/11: 3524f0dac9a9152db223bcc2682a842d
content/12: 341fbcb79af9a7cb1bf5ac653f51807c
content/13: 371d0e46b4bd2c23f559b8bc112f6955
content/14: 85aebdee44deb5b2f03e95112a776839
content/14: 6dba14b1346c18cd2342f502371a1042
content/15: bcadfc362b69078beee0088e5936c98b
content/16: 7062fc4e2ca6974003e0d209f2b52d9f
content/16: df68275133be883eac95664c3ed10063
content/17: b3f310d5ef115bea5a8b75bf25d7ea9a
content/18: 59815ce1d0dddd507d505b42aa01b648
44f1f9fe8d5081b7781dc70e012cb531:

View File

@@ -71,6 +71,12 @@ export default function SSOForm() {
}
}
// Pre-fill email if provided in URL (e.g., from deployed chat SSO)
const emailParam = searchParams.get('email')
if (emailParam) {
setEmail(emailParam)
}
// Check for SSO error from redirect
const error = searchParams.get('error')
if (error) {

View File

@@ -20,5 +20,4 @@ INTERNAL_API_SECRET=your_internal_api_secret # Use `openssl rand -hex 32` to gen
# If left commented out, emails will be logged to console instead
# Local AI Models (Optional)
# OLLAMA_URL=http://localhost:11434 # URL for local Ollama server - uncomment if using local models
# OLLAMA_URL=http://localhost:11434 # URL for local Ollama server - uncomment if using local models

View File

@@ -1,6 +1,6 @@
import { db } from '@sim/db'
import { subscription as subscriptionTable, user } from '@sim/db/schema'
import { and, eq } from 'drizzle-orm'
import { and, eq, or } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { requireStripeClient } from '@/lib/billing/stripe-client'
@@ -38,7 +38,10 @@ export async function POST(request: NextRequest) {
.where(
and(
eq(subscriptionTable.referenceId, organizationId),
eq(subscriptionTable.status, 'active')
or(
eq(subscriptionTable.status, 'active'),
eq(subscriptionTable.cancelAtPeriodEnd, true)
)
)
)
.limit(1)

View File

@@ -1,5 +1,5 @@
import { db } from '@sim/db'
import { chat, workflow } from '@sim/db/schema'
import { chat, workflow, workspace } from '@sim/db/schema'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { createLogger } from '@/lib/logs/console/logger'
@@ -94,11 +94,12 @@ export async function POST(
return addCorsHeaders(createErrorResponse('No input provided', 400), request)
}
// Get the workflow for this chat
// Get the workflow and workspace owner for this chat
const workflowResult = await db
.select({
isDeployed: workflow.isDeployed,
workspaceId: workflow.workspaceId,
variables: workflow.variables,
})
.from(workflow)
.where(eq(workflow.id, deployment.workflowId))
@@ -109,6 +110,22 @@ export async function POST(
return addCorsHeaders(createErrorResponse('Chat workflow is not available', 503), request)
}
let workspaceOwnerId = deployment.userId
if (workflowResult[0].workspaceId) {
const workspaceData = await db
.select({ ownerId: workspace.ownerId })
.from(workspace)
.where(eq(workspace.id, workflowResult[0].workspaceId))
.limit(1)
if (workspaceData.length === 0) {
logger.error(`[${requestId}] Workspace not found for workflow ${deployment.workflowId}`)
return addCorsHeaders(createErrorResponse('Workspace not found', 500), request)
}
workspaceOwnerId = workspaceData[0].ownerId
}
try {
const selectedOutputs: string[] = []
if (deployment.outputConfigs && Array.isArray(deployment.outputConfigs)) {
@@ -145,16 +162,19 @@ export async function POST(
}
}
const workflowForExecution = {
id: deployment.workflowId,
userId: deployment.userId,
workspaceId: workflowResult[0].workspaceId,
isDeployed: true,
variables: workflowResult[0].variables || {},
}
const stream = await createStreamingResponse({
requestId,
workflow: {
id: deployment.workflowId,
userId: deployment.userId,
workspaceId: workflowResult[0].workspaceId,
isDeployed: true,
},
workflow: workflowForExecution,
input: workflowInput,
executingUserId: deployment.userId,
executingUserId: workspaceOwnerId,
streamConfig: {
selectedOutputs,
isSecureMode: true,

View File

@@ -8,6 +8,7 @@ import { isDev } from '@/lib/environment'
import { createLogger } from '@/lib/logs/console/logger'
import { getEmailDomain } from '@/lib/urls/utils'
import { encryptSecret } from '@/lib/utils'
import { deployWorkflow } from '@/lib/workflows/db-helpers'
import { checkChatAccess } from '@/app/api/chat/utils'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
@@ -31,7 +32,7 @@ const chatUpdateSchema = z.object({
imageUrl: z.string().optional(),
})
.optional(),
authType: z.enum(['public', 'password', 'email']).optional(),
authType: z.enum(['public', 'password', 'email', 'sso']).optional(),
password: z.string().optional(),
allowedEmails: z.array(z.string()).optional(),
outputConfigs: z
@@ -134,6 +135,22 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
}
}
// Redeploy the workflow to ensure latest version is active
const deployResult = await deployWorkflow({
workflowId: existingChat[0].workflowId,
deployedBy: session.user.id,
})
if (!deployResult.success) {
logger.warn(
`Failed to redeploy workflow for chat update: ${deployResult.error}, continuing with chat update`
)
} else {
logger.info(
`Redeployed workflow ${existingChat[0].workflowId} for chat update (v${deployResult.version})`
)
}
let encryptedPassword
if (password) {
@@ -165,7 +182,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
updateData.allowedEmails = []
} else if (authType === 'password') {
updateData.allowedEmails = []
} else if (authType === 'email') {
} else if (authType === 'email' || authType === 'sso') {
updateData.password = null
}
}

View File

@@ -19,6 +19,7 @@ describe('Chat API Route', () => {
const mockCreateErrorResponse = vi.fn()
const mockEncryptSecret = vi.fn()
const mockCheckWorkflowAccessForChatCreation = vi.fn()
const mockDeployWorkflow = vi.fn()
beforeEach(() => {
vi.resetModules()
@@ -76,6 +77,14 @@ describe('Chat API Route', () => {
vi.doMock('@/app/api/chat/utils', () => ({
checkWorkflowAccessForChatCreation: mockCheckWorkflowAccessForChatCreation,
}))
vi.doMock('@/lib/workflows/db-helpers', () => ({
deployWorkflow: mockDeployWorkflow.mockResolvedValue({
success: true,
version: 1,
deployedAt: new Date(),
}),
}))
})
afterEach(() => {
@@ -236,7 +245,7 @@ describe('Chat API Route', () => {
it('should allow chat deployment when user owns workflow directly', async () => {
vi.doMock('@/lib/auth', () => ({
getSession: vi.fn().mockResolvedValue({
user: { id: 'user-id' },
user: { id: 'user-id', email: 'user@example.com' },
}),
}))
@@ -283,7 +292,7 @@ describe('Chat API Route', () => {
it('should allow chat deployment when user has workspace admin permission', async () => {
vi.doMock('@/lib/auth', () => ({
getSession: vi.fn().mockResolvedValue({
user: { id: 'user-id' },
user: { id: 'user-id', email: 'user@example.com' },
}),
}))
@@ -393,10 +402,10 @@ describe('Chat API Route', () => {
expect(mockCheckWorkflowAccessForChatCreation).toHaveBeenCalledWith('workflow-123', 'user-id')
})
it('should reject if workflow is not deployed', async () => {
it('should auto-deploy workflow if not already deployed', async () => {
vi.doMock('@/lib/auth', () => ({
getSession: vi.fn().mockResolvedValue({
user: { id: 'user-id' },
user: { id: 'user-id', email: 'user@example.com' },
}),
}))
@@ -415,6 +424,7 @@ describe('Chat API Route', () => {
hasAccess: true,
workflow: { userId: 'user-id', workspaceId: null, isDeployed: false },
})
mockReturning.mockResolvedValue([{ id: 'test-uuid' }])
const req = new NextRequest('http://localhost:3000/api/chat', {
method: 'POST',
@@ -423,11 +433,11 @@ describe('Chat API Route', () => {
const { POST } = await import('@/app/api/chat/route')
const response = await POST(req)
expect(response.status).toBe(400)
expect(mockCreateErrorResponse).toHaveBeenCalledWith(
'Workflow must be deployed before creating a chat',
400
)
expect(response.status).toBe(200)
expect(mockDeployWorkflow).toHaveBeenCalledWith({
workflowId: 'workflow-123',
deployedBy: 'user-id',
})
})
})
})

View File

@@ -9,6 +9,7 @@ import { isDev } from '@/lib/environment'
import { createLogger } from '@/lib/logs/console/logger'
import { getBaseUrl } from '@/lib/urls/utils'
import { encryptSecret } from '@/lib/utils'
import { deployWorkflow } from '@/lib/workflows/db-helpers'
import { checkWorkflowAccessForChatCreation } from '@/app/api/chat/utils'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
@@ -27,7 +28,7 @@ const chatSchema = z.object({
welcomeMessage: z.string(),
imageUrl: z.string().optional(),
}),
authType: z.enum(['public', 'password', 'email']).default('public'),
authType: z.enum(['public', 'password', 'email', 'sso']).default('public'),
password: z.string().optional(),
allowedEmails: z.array(z.string()).optional().default([]),
outputConfigs: z
@@ -98,6 +99,13 @@ export async function POST(request: NextRequest) {
)
}
if (authType === 'sso' && (!Array.isArray(allowedEmails) || allowedEmails.length === 0)) {
return createErrorResponse(
'At least one email or domain is required when using SSO access control',
400
)
}
// Check if identifier is available
const existingIdentifier = await db
.select()
@@ -119,11 +127,20 @@ export async function POST(request: NextRequest) {
return createErrorResponse('Workflow not found or access denied', 404)
}
// Verify the workflow is deployed (required for chat deployment)
if (!workflowRecord.isDeployed) {
return createErrorResponse('Workflow must be deployed before creating a chat', 400)
// Always deploy/redeploy the workflow to ensure latest version
const result = await deployWorkflow({
workflowId,
deployedBy: session.user.id,
})
if (!result.success) {
return createErrorResponse(result.error || 'Failed to deploy workflow', 500)
}
logger.info(
`${workflowRecord.isDeployed ? 'Redeployed' : 'Auto-deployed'} workflow ${workflowId} for chat (v${result.version})`
)
// Encrypt password if provided
let encryptedPassword = null
if (authType === 'password' && password) {
@@ -163,7 +180,7 @@ export async function POST(request: NextRequest) {
isActive: true,
authType,
password: encryptedPassword,
allowedEmails: authType === 'email' ? allowedEmails : [],
allowedEmails: authType === 'email' || authType === 'sso' ? allowedEmails : [],
outputConfigs,
createdAt: new Date(),
updatedAt: new Date(),

View File

@@ -262,7 +262,67 @@ export async function validateChatAuth(
}
}
// Unknown auth type
if (authType === 'sso') {
if (request.method === 'GET') {
return { authorized: false, error: 'auth_required_sso' }
}
try {
if (!parsedBody) {
return { authorized: false, error: 'SSO authentication is required' }
}
const { email, input, checkSSOAccess } = parsedBody
if (checkSSOAccess) {
if (!email) {
return { authorized: false, error: 'Email is required' }
}
const allowedEmails = deployment.allowedEmails || []
if (allowedEmails.includes(email)) {
return { authorized: true }
}
const domain = email.split('@')[1]
if (domain && allowedEmails.some((allowed: string) => allowed === `@${domain}`)) {
return { authorized: true }
}
return { authorized: false, error: 'Email not authorized for SSO access' }
}
const { auth } = await import('@/lib/auth')
const session = await auth.api.getSession({ headers: request.headers })
if (!session || !session.user) {
return { authorized: false, error: 'auth_required_sso' }
}
const userEmail = session.user.email
if (!userEmail) {
return { authorized: false, error: 'SSO session does not contain email' }
}
const allowedEmails = deployment.allowedEmails || []
if (allowedEmails.includes(userEmail)) {
return { authorized: true }
}
const domain = userEmail.split('@')[1]
if (domain && allowedEmails.some((allowed: string) => allowed === `@${domain}`)) {
return { authorized: true }
}
return { authorized: false, error: 'Your email is not authorized to access this chat' }
} catch (error) {
logger.error(`[${requestId}] Error validating SSO:`, error)
return { authorized: false, error: 'SSO authentication error' }
}
}
return { authorized: false, error: 'Unsupported authentication type' }
}

View File

@@ -121,10 +121,24 @@ export async function POST(request: NextRequest) {
}
if (!isUsingCloudStorage()) {
return NextResponse.json(
{ error: 'Direct uploads are only available when cloud storage is enabled' },
{ status: 400 }
logger.info(
`Local storage detected - batch presigned URLs not available, client will use API fallback`
)
return NextResponse.json({
files: files.map((file) => ({
fileName: file.fileName,
presignedUrl: '', // Empty URL signals fallback to API upload
fileInfo: {
path: '',
key: '',
name: file.fileName,
size: file.fileSize,
type: file.contentType,
},
directUploadSupported: false,
})),
directUploadSupported: false,
})
}
const storageProvider = getStorageProvider()

View File

@@ -25,7 +25,7 @@ describe('/api/files/presigned', () => {
})
describe('POST', () => {
it('should return error when cloud storage is not enabled', async () => {
it('should return graceful fallback response when cloud storage is not enabled', async () => {
setupFileApiMocks({
cloudEnabled: false,
storageProvider: 's3',
@@ -45,10 +45,14 @@ describe('/api/files/presigned', () => {
const response = await POST(request)
const data = await response.json()
expect(response.status).toBe(500)
expect(data.error).toBe('Direct uploads are only available when cloud storage is enabled')
expect(data.code).toBe('STORAGE_CONFIG_ERROR')
expect(response.status).toBe(200)
expect(data.directUploadSupported).toBe(false)
expect(data.presignedUrl).toBe('')
expect(data.fileName).toBe('test.txt')
expect(data.fileInfo).toBeDefined()
expect(data.fileInfo.name).toBe('test.txt')
expect(data.fileInfo.size).toBe(1024)
expect(data.fileInfo.type).toBe('text/plain')
})
it('should return error when fileName is missing', async () => {

View File

@@ -141,9 +141,21 @@ export async function POST(request: NextRequest) {
}
if (!isUsingCloudStorage()) {
throw new StorageConfigError(
'Direct uploads are only available when cloud storage is enabled'
logger.info(
`Local storage detected - presigned URL not available for ${fileName}, client will use API fallback`
)
return NextResponse.json({
fileName,
presignedUrl: '', // Empty URL signals fallback to API upload
fileInfo: {
path: '',
key: '',
name: fileName,
size: fileSize,
type: contentType,
},
directUploadSupported: false,
})
}
const storageProvider = getStorageProvider()

View File

@@ -90,16 +90,38 @@ export const POST = withMcpAuth('read')(
)
}
// Parse array arguments based on tool schema
// Cast arguments to their expected types based on tool schema
if (tool.inputSchema?.properties) {
for (const [paramName, paramSchema] of Object.entries(tool.inputSchema.properties)) {
const schema = paramSchema as any
const value = args[paramName]
if (value === undefined || value === null) {
continue
}
// Cast numbers
if (
schema.type === 'array' &&
args[paramName] !== undefined &&
typeof args[paramName] === 'string'
(schema.type === 'number' || schema.type === 'integer') &&
typeof value === 'string'
) {
const stringValue = args[paramName].trim()
const numValue =
schema.type === 'integer' ? Number.parseInt(value) : Number.parseFloat(value)
if (!Number.isNaN(numValue)) {
args[paramName] = numValue
}
}
// Cast booleans
else if (schema.type === 'boolean' && typeof value === 'string') {
if (value.toLowerCase() === 'true') {
args[paramName] = true
} else if (value.toLowerCase() === 'false') {
args[paramName] = false
}
}
// Cast arrays
else if (schema.type === 'array' && typeof value === 'string') {
const stringValue = value.trim()
if (stringValue) {
try {
// Try to parse as JSON first (handles ["item1", "item2"])

View File

@@ -1,4 +1,5 @@
import { type NextRequest, NextResponse } from 'next/server'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { createLogger } from '@/lib/logs/console/logger'
import { validateImageUrl } from '@/lib/security/input-validation'
import { generateRequestId } from '@/lib/utils'
@@ -14,6 +15,12 @@ export async function GET(request: NextRequest) {
const imageUrl = url.searchParams.get('url')
const requestId = generateRequestId()
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
if (!authResult.success) {
logger.error(`[${requestId}] Authentication failed for image proxy:`, authResult.error)
return new NextResponse('Unauthorized', { status: 401 })
}
if (!imageUrl) {
logger.error(`[${requestId}] Missing 'url' parameter`)
return new NextResponse('Missing URL parameter', { status: 400 })

View File

@@ -1,4 +1,6 @@
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { generateInternalToken } from '@/lib/auth/internal'
import { isDev } from '@/lib/environment'
import { createLogger } from '@/lib/logs/console/logger'
@@ -242,12 +244,18 @@ export async function GET(request: Request) {
}
}
export async function POST(request: Request) {
export async function POST(request: NextRequest) {
const requestId = generateRequestId()
const startTime = new Date()
const startTimeISO = startTime.toISOString()
try {
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
if (!authResult.success) {
logger.error(`[${requestId}] Authentication failed for proxy:`, authResult.error)
return createErrorResponse('Unauthorized', 401)
}
let requestBody
try {
requestBody = await request.json()
@@ -311,7 +319,6 @@ export async function POST(request: Request) {
error: result.error || 'Unknown error',
})
// Let the main executeTool handle error transformation to avoid double transformation
throw new Error(result.error || 'Tool execution failed')
}
@@ -319,10 +326,8 @@ export async function POST(request: Request) {
const endTimeISO = endTime.toISOString()
const duration = endTime.getTime() - startTime.getTime()
// Add explicit timing information directly to the response
const responseWithTimingData = {
...result,
// Add timing data both at root level and in nested timing object
startTime: startTimeISO,
endTime: endTimeISO,
duration,
@@ -335,7 +340,6 @@ export async function POST(request: Request) {
logger.info(`[${requestId}] Tool executed successfully: ${toolId} (${duration}ms)`)
// Return the response with CORS headers
return formatResponse(responseWithTimingData)
} catch (error: any) {
logger.error(`[${requestId}] Proxy request failed`, {
@@ -344,7 +348,6 @@ export async function POST(request: Request) {
name: error instanceof Error ? error.name : undefined,
})
// Add timing information even to error responses
const endTime = new Date()
const endTimeISO = endTime.toISOString()
const duration = endTime.getTime() - startTime.getTime()

View File

@@ -1,4 +1,6 @@
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { createLogger } from '@/lib/logs/console/logger'
import { validateAlphanumericId } from '@/lib/security/input-validation'
import { uploadFile } from '@/lib/uploads/storage-client'
@@ -6,19 +8,25 @@ import { getBaseUrl } from '@/lib/urls/utils'
const logger = createLogger('ProxyTTSAPI')
export async function POST(request: Request) {
export async function POST(request: NextRequest) {
try {
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
if (!authResult.success) {
logger.error('Authentication failed for TTS proxy:', authResult.error)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const { text, voiceId, apiKey, modelId = 'eleven_monolingual_v1' } = body
if (!text || !voiceId || !apiKey) {
return new NextResponse('Missing required parameters', { status: 400 })
return NextResponse.json({ error: 'Missing required parameters' }, { status: 400 })
}
const voiceIdValidation = validateAlphanumericId(voiceId, 'voiceId', 255)
if (!voiceIdValidation.isValid) {
logger.error(`Invalid voice ID: ${voiceIdValidation.error}`)
return new NextResponse(voiceIdValidation.error, { status: 400 })
return NextResponse.json({ error: voiceIdValidation.error }, { status: 400 })
}
logger.info('Proxying TTS request for voice:', voiceId)
@@ -41,16 +49,17 @@ export async function POST(request: Request) {
if (!response.ok) {
logger.error(`Failed to generate TTS: ${response.status} ${response.statusText}`)
return new NextResponse(`Failed to generate TTS: ${response.status} ${response.statusText}`, {
status: response.status,
})
return NextResponse.json(
{ error: `Failed to generate TTS: ${response.status} ${response.statusText}` },
{ status: response.status }
)
}
const audioBlob = await response.blob()
if (audioBlob.size === 0) {
logger.error('Empty audio received from ElevenLabs')
return new NextResponse('Empty audio received', { status: 422 })
return NextResponse.json({ error: 'Empty audio received' }, { status: 422 })
}
const audioBuffer = Buffer.from(await audioBlob.arrayBuffer())
@@ -67,11 +76,11 @@ export async function POST(request: Request) {
} catch (error) {
logger.error('Error proxying TTS:', error)
return new NextResponse(
`Internal Server Error: ${error instanceof Error ? error.message : 'Unknown error'}`,
return NextResponse.json(
{
status: 500,
}
error: `Internal Server Error: ${error instanceof Error ? error.message : 'Unknown error'}`,
},
{ status: 500 }
)
}
}

View File

@@ -1,4 +1,5 @@
import type { NextRequest } from 'next/server'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { env } from '@/lib/env'
import { createLogger } from '@/lib/logs/console/logger'
import { validateAlphanumericId } from '@/lib/security/input-validation'
@@ -7,6 +8,12 @@ const logger = createLogger('ProxyTTSStreamAPI')
export async function POST(request: NextRequest) {
try {
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
if (!authResult.success) {
logger.error('Authentication failed for TTS stream proxy:', authResult.error)
return new Response('Unauthorized', { status: 401 })
}
const body = await request.json()
const { text, voiceId, modelId = 'eleven_turbo_v2_5' } = body

View File

@@ -3,6 +3,7 @@ import { checkHybridAuth } from '@/lib/auth/hybrid'
import { checkServerSideUsageLimits } from '@/lib/billing'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import { getEffectiveCurrentPeriodCost } from '@/lib/billing/core/usage'
import { getUserStorageLimit, getUserStorageUsage } from '@/lib/billing/storage'
import { createLogger } from '@/lib/logs/console/logger'
import { createErrorResponse } from '@/app/api/workflows/utils'
import { RateLimiter } from '@/services/queue'
@@ -37,9 +38,11 @@ export async function GET(request: NextRequest) {
])
// Usage summary (current period cost + limit + plan)
const [usageCheck, effectiveCost] = await Promise.all([
const [usageCheck, effectiveCost, storageUsage, storageLimit] = await Promise.all([
checkServerSideUsageLimits(authenticatedUserId),
getEffectiveCurrentPeriodCost(authenticatedUserId),
getUserStorageUsage(authenticatedUserId),
getUserStorageLimit(authenticatedUserId),
])
const currentPeriodCost = effectiveCost
@@ -66,6 +69,11 @@ export async function GET(request: NextRequest) {
limit: usageCheck.limit,
plan: userSubscription?.plan || 'free',
},
storage: {
usedBytes: storageUsage,
limitBytes: storageLimit,
percentUsed: storageLimit > 0 ? (storageUsage / storageLimit) * 100 : 0,
},
})
} catch (error: any) {
logger.error('Error checking usage limits:', error)

View File

@@ -1,10 +1,9 @@
import { apiKey, db, workflow, workflowDeploymentVersion } from '@sim/db'
import { and, desc, eq, sql } from 'drizzle-orm'
import { and, desc, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { v4 as uuidv4 } from 'uuid'
import { createLogger } from '@/lib/logs/console/logger'
import { generateRequestId } from '@/lib/utils'
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/db-helpers'
import { deployWorkflow } from '@/lib/workflows/db-helpers'
import { validateWorkflowPermissions } from '@/lib/workflows/utils'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
@@ -138,37 +137,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
}
} catch (_err) {}
logger.debug(`[${requestId}] Getting current workflow state for deployment`)
const normalizedData = await loadWorkflowFromNormalizedTables(id)
if (!normalizedData) {
logger.error(`[${requestId}] Failed to load workflow from normalized tables`)
return createErrorResponse('Failed to load workflow state', 500)
}
const currentState = {
blocks: normalizedData.blocks,
edges: normalizedData.edges,
loops: normalizedData.loops,
parallels: normalizedData.parallels,
lastSaved: Date.now(),
}
logger.debug(`[${requestId}] Current state retrieved from normalized tables:`, {
blocksCount: Object.keys(currentState.blocks).length,
edgesCount: currentState.edges.length,
loopsCount: Object.keys(currentState.loops).length,
parallelsCount: Object.keys(currentState.parallels).length,
})
if (!currentState || !currentState.blocks) {
logger.error(`[${requestId}] Invalid workflow state retrieved`, { currentState })
throw new Error('Invalid workflow state: missing blocks')
}
const deployedAt = new Date()
logger.debug(`[${requestId}] Proceeding with deployment at ${deployedAt.toISOString()}`)
logger.debug(`[${requestId}] Validating API key for deployment`)
let keyInfo: { name: string; type: 'personal' | 'workspace' } | null = null
let matchedKey: {
@@ -260,46 +229,20 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
return createErrorResponse('Unable to determine deploying user', 400)
}
await db.transaction(async (tx) => {
const [{ maxVersion }] = await tx
.select({ maxVersion: sql`COALESCE(MAX("version"), 0)` })
.from(workflowDeploymentVersion)
.where(eq(workflowDeploymentVersion.workflowId, id))
const nextVersion = Number(maxVersion) + 1
await tx
.update(workflowDeploymentVersion)
.set({ isActive: false })
.where(
and(
eq(workflowDeploymentVersion.workflowId, id),
eq(workflowDeploymentVersion.isActive, true)
)
)
await tx.insert(workflowDeploymentVersion).values({
id: uuidv4(),
workflowId: id,
version: nextVersion,
state: currentState,
isActive: true,
createdAt: deployedAt,
createdBy: actorUserId,
})
const updateData: Record<string, unknown> = {
isDeployed: true,
deployedAt,
deployedState: currentState,
}
if (providedApiKey && matchedKey) {
updateData.pinnedApiKeyId = matchedKey.id
}
await tx.update(workflow).set(updateData).where(eq(workflow.id, id))
const deployResult = await deployWorkflow({
workflowId: id,
deployedBy: actorUserId,
pinnedApiKeyId: matchedKey?.id,
includeDeployedState: true,
workflowName: workflowData!.name,
})
if (!deployResult.success) {
return createErrorResponse(deployResult.error || 'Failed to deploy workflow', 500)
}
const deployedAt = deployResult.deployedAt!
if (matchedKey) {
try {
await db
@@ -313,31 +256,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
logger.info(`[${requestId}] Workflow deployed successfully: ${id}`)
// Track workflow deployment
try {
const { trackPlatformEvent } = await import('@/lib/telemetry/tracer')
// Aggregate block types to understand which blocks are being used
const blockTypeCounts: Record<string, number> = {}
for (const block of Object.values(currentState.blocks)) {
const blockType = (block as any).type || 'unknown'
blockTypeCounts[blockType] = (blockTypeCounts[blockType] || 0) + 1
}
trackPlatformEvent('platform.workflow.deployed', {
'workflow.id': id,
'workflow.name': workflowData!.name,
'workflow.blocks_count': Object.keys(currentState.blocks).length,
'workflow.edges_count': currentState.edges.length,
'workflow.has_loops': Object.keys(currentState.loops).length > 0,
'workflow.has_parallels': Object.keys(currentState.parallels).length > 0,
'workflow.api_key_type': keyInfo?.type || 'default',
'workflow.block_types': JSON.stringify(blockTypeCounts),
})
} catch (_e) {
// Silently fail
}
const responseApiKeyInfo = keyInfo ? `${keyInfo.name} (${keyInfo.type})` : 'Default key'
return createSuccessResponse({

View File

@@ -1,4 +1,4 @@
import { db, workflow, workflowDeploymentVersion } from '@sim/db'
import { apiKey, db, workflow, workflowDeploymentVersion } from '@sim/db'
import { and, eq } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { createLogger } from '@/lib/logs/console/logger'
@@ -19,7 +19,11 @@ export async function POST(
const { id, version } = await params
try {
const { error } = await validateWorkflowPermissions(id, requestId, 'admin')
const {
error,
session,
workflow: workflowData,
} = await validateWorkflowPermissions(id, requestId, 'admin')
if (error) {
return createErrorResponse(error.message, error.status)
}
@@ -29,6 +33,52 @@ export async function POST(
return createErrorResponse('Invalid version', 400)
}
let providedApiKey: string | null = null
try {
const parsed = await request.json()
if (parsed && typeof parsed.apiKey === 'string' && parsed.apiKey.trim().length > 0) {
providedApiKey = parsed.apiKey.trim()
}
} catch (_err) {}
let pinnedApiKeyId: string | null = null
if (providedApiKey) {
const currentUserId = session?.user?.id
if (currentUserId) {
const [personalKey] = await db
.select({ id: apiKey.id })
.from(apiKey)
.where(
and(
eq(apiKey.id, providedApiKey),
eq(apiKey.userId, currentUserId),
eq(apiKey.type, 'personal')
)
)
.limit(1)
if (personalKey) {
pinnedApiKeyId = personalKey.id
} else if (workflowData!.workspaceId) {
const [workspaceKey] = await db
.select({ id: apiKey.id })
.from(apiKey)
.where(
and(
eq(apiKey.id, providedApiKey),
eq(apiKey.workspaceId, workflowData!.workspaceId),
eq(apiKey.type, 'workspace')
)
)
.limit(1)
if (workspaceKey) {
pinnedApiKeyId = workspaceKey.id
}
}
}
}
const now = new Date()
await db.transaction(async (tx) => {
@@ -57,10 +107,16 @@ export async function POST(
throw new Error('Deployment version not found')
}
await tx
.update(workflow)
.set({ isDeployed: true, deployedAt: now })
.where(eq(workflow.id, id))
const updateData: Record<string, unknown> = {
isDeployed: true,
deployedAt: now,
}
if (pinnedApiKeyId) {
updateData.pinnedApiKeyId = pinnedApiKeyId
}
await tx.update(workflow).set(updateData).where(eq(workflow.id, id))
})
return createSuccessResponse({ success: true, deployedAt: now })

View File

@@ -14,6 +14,7 @@ import {
ChatMessageContainer,
EmailAuth,
PasswordAuth,
SSOAuth,
VoiceInterface,
} from '@/app/chat/components'
import { CHAT_ERROR_MESSAGES, CHAT_REQUEST_TIMEOUT_MS } from '@/app/chat/constants'
@@ -32,7 +33,7 @@ interface ChatConfig {
welcomeMessage?: string
headerText?: string
}
authType?: 'public' | 'password' | 'email'
authType?: 'public' | 'password' | 'email' | 'sso'
outputConfigs?: Array<{ blockId: string; path?: string }>
}
@@ -119,7 +120,7 @@ export default function ChatClient({ identifier }: { identifier: string }) {
const [userHasScrolled, setUserHasScrolled] = useState(false)
const isUserScrollingRef = useRef(false)
const [authRequired, setAuthRequired] = useState<'password' | 'email' | null>(null)
const [authRequired, setAuthRequired] = useState<'password' | 'email' | 'sso' | null>(null)
const [isVoiceFirstMode, setIsVoiceFirstMode] = useState(false)
const { isStreamingResponse, abortControllerRef, stopStreaming, handleStreamedResponse } =
@@ -222,6 +223,10 @@ export default function ChatClient({ identifier }: { identifier: string }) {
setAuthRequired('email')
return
}
if (errorData.error === 'auth_required_sso') {
setAuthRequired('sso')
return
}
}
throw new Error(`Failed to load chat configuration: ${response.status}`)
@@ -500,6 +505,16 @@ export default function ChatClient({ identifier }: { identifier: string }) {
/>
)
}
if (authRequired === 'sso') {
return (
<SSOAuth
identifier={identifier}
onAuthSuccess={handleAuthSuccess}
title={title}
primaryColor={primaryColor}
/>
)
}
}
// Loading state while fetching config using the extracted component

View File

@@ -0,0 +1,209 @@
'use client'
import { type KeyboardEvent, useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { quickValidateEmail } from '@/lib/email/validation'
import { createLogger } from '@/lib/logs/console/logger'
import { cn } from '@/lib/utils'
import Nav from '@/app/(landing)/components/nav/nav'
import { inter } from '@/app/fonts/inter'
import { soehne } from '@/app/fonts/soehne/soehne'
const logger = createLogger('SSOAuth')
interface SSOAuthProps {
identifier: string
onAuthSuccess: () => void
title?: string
primaryColor?: string
}
const validateEmailField = (emailValue: string): string[] => {
const errors: string[] = []
if (!emailValue || !emailValue.trim()) {
errors.push('Email is required.')
return errors
}
const validation = quickValidateEmail(emailValue.trim().toLowerCase())
if (!validation.isValid) {
errors.push(validation.reason || 'Please enter a valid email address.')
}
return errors
}
export default function SSOAuth({
identifier,
onAuthSuccess,
title = 'chat',
primaryColor = 'var(--brand-primary-hover-hex)',
}: SSOAuthProps) {
const router = useRouter()
const [email, setEmail] = useState('')
const [emailErrors, setEmailErrors] = useState<string[]>([])
const [showEmailValidationError, setShowEmailValidationError] = useState(false)
const [buttonClass, setButtonClass] = useState('auth-button-gradient')
const [isLoading, setIsLoading] = useState(false)
useEffect(() => {
const checkCustomBrand = () => {
const computedStyle = getComputedStyle(document.documentElement)
const brandAccent = computedStyle.getPropertyValue('--brand-accent-hex').trim()
if (brandAccent && brandAccent !== '#6f3dfa') {
setButtonClass('auth-button-custom')
} else {
setButtonClass('auth-button-gradient')
}
}
checkCustomBrand()
window.addEventListener('resize', checkCustomBrand)
const observer = new MutationObserver(checkCustomBrand)
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['style', 'class'],
})
return () => {
window.removeEventListener('resize', checkCustomBrand)
observer.disconnect()
}
}, [])
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault()
handleAuthenticate()
}
}
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newEmail = e.target.value
setEmail(newEmail)
setShowEmailValidationError(false)
setEmailErrors([])
}
const handleAuthenticate = async () => {
const emailValidationErrors = validateEmailField(email)
setEmailErrors(emailValidationErrors)
setShowEmailValidationError(emailValidationErrors.length > 0)
if (emailValidationErrors.length > 0) {
return
}
setIsLoading(true)
try {
const checkResponse = await fetch(`/api/chat/${identifier}`, {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
},
body: JSON.stringify({ email, checkSSOAccess: true }),
})
if (!checkResponse.ok) {
const errorData = await checkResponse.json()
setEmailErrors([errorData.error || 'Email not authorized for this chat'])
setShowEmailValidationError(true)
setIsLoading(false)
return
}
const callbackUrl = `/chat/${identifier}`
const ssoUrl = `/sso?email=${encodeURIComponent(email)}&callbackUrl=${encodeURIComponent(callbackUrl)}`
router.push(ssoUrl)
} catch (error) {
logger.error('SSO authentication error:', error)
setEmailErrors(['An error occurred during authentication'])
setShowEmailValidationError(true)
setIsLoading(false)
}
}
return (
<div className='bg-white'>
<Nav variant='auth' />
<div className='flex min-h-[calc(100vh-120px)] items-center justify-center px-4'>
<div className='w-full max-w-[410px]'>
<div className='flex flex-col items-center justify-center'>
{/* Header */}
<div className='space-y-1 text-center'>
<h1
className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}
>
SSO Authentication
</h1>
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
This chat requires SSO authentication
</p>
</div>
{/* Form */}
<form
onSubmit={(e) => {
e.preventDefault()
handleAuthenticate()
}}
className={`${inter.className} mt-8 w-full space-y-8`}
>
<div className='space-y-6'>
<div className='space-y-2'>
<div className='flex items-center justify-between'>
<Label htmlFor='email'>Work Email</Label>
</div>
<Input
id='email'
name='email'
required
type='email'
autoCapitalize='none'
autoComplete='email'
autoCorrect='off'
placeholder='Enter your work email'
value={email}
onChange={handleEmailChange}
onKeyDown={handleKeyDown}
className={cn(
'rounded-[10px] shadow-sm transition-colors focus:border-gray-400 focus:ring-2 focus:ring-gray-100',
showEmailValidationError &&
emailErrors.length > 0 &&
'border-red-500 focus:border-red-500 focus:ring-red-100 focus-visible:ring-red-500'
)}
autoFocus
/>
{showEmailValidationError && emailErrors.length > 0 && (
<div className='mt-1 space-y-1 text-red-400 text-xs'>
{emailErrors.map((error, index) => (
<p key={index}>{error}</p>
))}
</div>
)}
</div>
</div>
<Button
type='submit'
className={`${buttonClass} flex w-full items-center justify-center gap-2 rounded-[10px] border font-medium text-[15px] text-white transition-all duration-200`}
disabled={isLoading}
>
{isLoading ? 'Redirecting to SSO...' : 'Continue with SSO'}
</Button>
</form>
</div>
</div>
</div>
</div>
)
}

View File

@@ -1,5 +1,6 @@
export { default as EmailAuth } from './auth/email/email-auth'
export { default as PasswordAuth } from './auth/password/password-auth'
export { default as SSOAuth } from './auth/sso/sso-auth'
export { ChatErrorState } from './error-state/error-state'
export { ChatHeader } from './header/header'
export { ChatInput } from './input/input'

View File

@@ -0,0 +1,469 @@
'use client'
import { useEffect, useState } from 'react'
import { Check, Copy, Info, Loader2, Plus } from 'lucide-react'
import { useParams } from 'next/navigation'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
Button,
Input,
Label,
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui'
import { createLogger } from '@/lib/logs/console/logger'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
const logger = createLogger('ApiKeySelector')
export interface ApiKey {
id: string
name: string
key: string
displayKey?: string
lastUsed?: string
createdAt: string
expiresAt?: string
createdBy?: string
}
interface ApiKeysData {
workspace: ApiKey[]
personal: ApiKey[]
}
interface ApiKeySelectorProps {
value: string
onChange: (keyId: string) => void
disabled?: boolean
apiKeys?: ApiKey[]
onApiKeyCreated?: () => void
showLabel?: boolean
label?: string
isDeployed?: boolean
deployedApiKeyDisplay?: string
}
export function ApiKeySelector({
value,
onChange,
disabled = false,
apiKeys = [],
onApiKeyCreated,
showLabel = true,
label = 'API Key',
isDeployed = false,
deployedApiKeyDisplay,
}: ApiKeySelectorProps) {
const params = useParams()
const workspaceId = (params?.workspaceId as string) || ''
const userPermissions = useUserPermissionsContext()
const canCreateWorkspaceKeys = userPermissions.canEdit || userPermissions.canAdmin
const [apiKeysData, setApiKeysData] = useState<ApiKeysData | null>(null)
const [isCreatingKey, setIsCreatingKey] = useState(false)
const [newKeyName, setNewKeyName] = useState('')
const [keyType, setKeyType] = useState<'personal' | 'workspace'>('personal')
const [newKey, setNewKey] = useState<ApiKey | null>(null)
const [showNewKeyDialog, setShowNewKeyDialog] = useState(false)
const [copySuccess, setCopySuccess] = useState(false)
const [isSubmittingCreate, setIsSubmittingCreate] = useState(false)
const [keysLoaded, setKeysLoaded] = useState(false)
const [createError, setCreateError] = useState<string | null>(null)
const [justCreatedKeyId, setJustCreatedKeyId] = useState<string | null>(null)
useEffect(() => {
fetchApiKeys()
}, [workspaceId])
const fetchApiKeys = async () => {
try {
setKeysLoaded(false)
const [workspaceRes, personalRes] = await Promise.all([
fetch(`/api/workspaces/${workspaceId}/api-keys`),
fetch('/api/users/me/api-keys'),
])
const workspaceData = workspaceRes.ok ? await workspaceRes.json() : { keys: [] }
const personalData = personalRes.ok ? await personalRes.json() : { keys: [] }
setApiKeysData({
workspace: workspaceData.keys || [],
personal: personalData.keys || [],
})
setKeysLoaded(true)
} catch (error) {
logger.error('Error fetching API keys:', { error })
setKeysLoaded(true)
}
}
const handleCreateKey = async () => {
if (!newKeyName.trim()) {
setCreateError('Please enter a name for the API key')
return
}
try {
setIsSubmittingCreate(true)
setCreateError(null)
const endpoint =
keyType === 'workspace'
? `/api/workspaces/${workspaceId}/api-keys`
: '/api/users/me/api-keys'
const response = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newKeyName }),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || 'Failed to create API key')
}
const data = await response.json()
setNewKey(data.key)
setJustCreatedKeyId(data.key.id)
setShowNewKeyDialog(true)
setIsCreatingKey(false)
setNewKeyName('')
// Refresh API keys
await fetchApiKeys()
onApiKeyCreated?.()
} catch (error: any) {
setCreateError(error.message || 'Failed to create API key')
} finally {
setIsSubmittingCreate(false)
}
}
const handleCopyKey = async () => {
if (newKey?.key) {
await navigator.clipboard.writeText(newKey.key)
setCopySuccess(true)
setTimeout(() => setCopySuccess(false), 2000)
}
}
if (isDeployed && deployedApiKeyDisplay) {
return (
<div className='space-y-1.5'>
{showLabel && (
<div className='flex items-center gap-1.5'>
<Label className='font-medium text-sm'>{label}</Label>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Info className='h-3.5 w-3.5 text-muted-foreground' />
</TooltipTrigger>
<TooltipContent>
<p>Owner is billed for usage</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
)}
<div className='rounded-md border bg-background'>
<div className='flex items-center justify-between p-3'>
<pre className='flex-1 overflow-x-auto whitespace-pre-wrap font-mono text-xs'>
{(() => {
const match = deployedApiKeyDisplay.match(/^(.*?)\s+\(([^)]+)\)$/)
if (match) {
return match[1].trim()
}
return deployedApiKeyDisplay
})()}
</pre>
{(() => {
const match = deployedApiKeyDisplay.match(/^(.*?)\s+\(([^)]+)\)$/)
if (match) {
const type = match[2]
return (
<div className='ml-2 flex-shrink-0'>
<span className='inline-flex items-center rounded-md bg-muted px-2 py-1 font-medium text-muted-foreground text-xs capitalize'>
{type}
</span>
</div>
)
}
return null
})()}
</div>
</div>
</div>
)
}
return (
<>
<div className='space-y-2'>
{showLabel && (
<div className='flex items-center justify-between'>
<div className='flex items-center gap-1.5'>
<Label className='font-medium text-sm'>{label}</Label>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Info className='h-3.5 w-3.5 text-muted-foreground' />
</TooltipTrigger>
<TooltipContent>
<p>Key Owner is Billed</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
{!disabled && (
<Button
type='button'
variant='ghost'
size='sm'
className='h-7 gap-1 px-2 text-muted-foreground text-xs'
onClick={() => {
setIsCreatingKey(true)
setCreateError(null)
}}
>
<Plus className='h-3.5 w-3.5' />
<span>Create new</span>
</Button>
)}
</div>
)}
<Select value={value} onValueChange={onChange} disabled={disabled || !keysLoaded}>
<SelectTrigger className={!keysLoaded ? 'opacity-70' : ''}>
{!keysLoaded ? (
<div className='flex items-center space-x-2'>
<Loader2 className='h-3.5 w-3.5 animate-spin' />
<span>Loading API keys...</span>
</div>
) : (
<SelectValue placeholder='Select an API key' className='text-sm' />
)}
</SelectTrigger>
<SelectContent align='start' className='w-[var(--radix-select-trigger-width)] py-1'>
{apiKeysData && apiKeysData.workspace.length > 0 && (
<SelectGroup>
<SelectLabel className='px-3 py-1.5 font-medium text-muted-foreground text-xs uppercase tracking-wide'>
Workspace
</SelectLabel>
{apiKeysData.workspace.map((apiKey) => (
<SelectItem
key={apiKey.id}
value={apiKey.id}
className='my-0.5 flex cursor-pointer items-center rounded-sm px-3 py-2.5 data-[state=checked]:bg-muted [&>span.absolute]:hidden'
>
<div className='flex w-full items-center'>
<div className='flex w-full items-center justify-between'>
<span className='mr-2 truncate text-sm'>{apiKey.name}</span>
<span className='mt-[1px] flex-shrink-0 rounded bg-muted px-1.5 py-0.5 font-mono text-muted-foreground text-xs'>
{apiKey.displayKey || apiKey.key}
</span>
</div>
</div>
</SelectItem>
))}
</SelectGroup>
)}
{((apiKeysData && apiKeysData.personal.length > 0) ||
(!apiKeysData && apiKeys.length > 0)) && (
<SelectGroup>
<SelectLabel className='px-3 py-1.5 font-medium text-muted-foreground text-xs uppercase tracking-wide'>
Personal
</SelectLabel>
{(apiKeysData ? apiKeysData.personal : apiKeys).map((apiKey) => (
<SelectItem
key={apiKey.id}
value={apiKey.id}
className='my-0.5 flex cursor-pointer items-center rounded-sm px-3 py-2.5 data-[state=checked]:bg-muted [&>span.absolute]:hidden'
>
<div className='flex w-full items-center'>
<div className='flex w-full items-center justify-between'>
<span className='mr-2 truncate text-sm'>{apiKey.name}</span>
<span className='mt-[1px] flex-shrink-0 rounded bg-muted px-1.5 py-0.5 font-mono text-muted-foreground text-xs'>
{apiKey.displayKey || apiKey.key}
</span>
</div>
</div>
</SelectItem>
))}
</SelectGroup>
)}
{!apiKeysData && apiKeys.length === 0 && (
<div className='px-3 py-2 text-muted-foreground text-sm'>No API keys available</div>
)}
{apiKeysData &&
apiKeysData.workspace.length === 0 &&
apiKeysData.personal.length === 0 && (
<div className='px-3 py-2 text-muted-foreground text-sm'>No API keys available</div>
)}
</SelectContent>
</Select>
</div>
{/* Create Key Dialog */}
<AlertDialog open={isCreatingKey} onOpenChange={setIsCreatingKey}>
<AlertDialogContent className='rounded-[10px] sm:max-w-md'>
<AlertDialogHeader>
<AlertDialogTitle>Create new API key</AlertDialogTitle>
<AlertDialogDescription>
{keyType === 'workspace'
? "This key will have access to all workflows in this workspace. Make sure to copy it after creation as you won't be able to see it again."
: "This key will have access to your personal workflows. Make sure to copy it after creation as you won't be able to see it again."}
</AlertDialogDescription>
</AlertDialogHeader>
<div className='space-y-4 py-2'>
{canCreateWorkspaceKeys && (
<div className='space-y-2'>
<p className='font-[360] text-sm'>API Key Type</p>
<div className='flex gap-2'>
<Button
type='button'
variant={keyType === 'personal' ? 'default' : 'outline'}
size='sm'
onClick={() => {
setKeyType('personal')
if (createError) setCreateError(null)
}}
className='h-8 data-[variant=outline]:border-border data-[variant=outline]:bg-background data-[variant=outline]:text-foreground data-[variant=outline]:hover:bg-muted dark:data-[variant=outline]:border-border dark:data-[variant=outline]:bg-background dark:data-[variant=outline]:text-foreground dark:data-[variant=outline]:hover:bg-muted/80'
>
Personal
</Button>
<Button
type='button'
variant={keyType === 'workspace' ? 'default' : 'outline'}
size='sm'
onClick={() => {
setKeyType('workspace')
if (createError) setCreateError(null)
}}
className='h-8 data-[variant=outline]:border-border data-[variant=outline]:bg-background data-[variant=outline]:text-foreground data-[variant=outline]:hover:bg-muted dark:data-[variant=outline]:border-border dark:data-[variant=outline]:bg-background dark:data-[variant=outline]:text-foreground dark:data-[variant=outline]:hover:bg-muted/80'
>
Workspace
</Button>
</div>
</div>
)}
<div className='space-y-2'>
<Label htmlFor='new-key-name'>API Key Name</Label>
<Input
id='new-key-name'
placeholder='My API Key'
value={newKeyName}
onChange={(e) => {
setNewKeyName(e.target.value)
if (createError) setCreateError(null)
}}
disabled={isSubmittingCreate}
/>
{createError && <p className='text-destructive text-sm'>{createError}</p>}
</div>
</div>
<AlertDialogFooter>
<AlertDialogCancel
disabled={isSubmittingCreate}
onClick={() => {
setNewKeyName('')
setCreateError(null)
}}
>
Cancel
</AlertDialogCancel>
<AlertDialogAction
disabled={isSubmittingCreate || !newKeyName.trim()}
onClick={(e) => {
e.preventDefault()
handleCreateKey()
}}
>
{isSubmittingCreate ? (
<>
<Loader2 className='mr-1.5 h-3 w-3 animate-spin' />
Creating...
</>
) : (
'Create'
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* New Key Dialog */}
<AlertDialog open={showNewKeyDialog} onOpenChange={setShowNewKeyDialog}>
<AlertDialogContent className='rounded-[10px] sm:max-w-md'>
<AlertDialogHeader>
<AlertDialogTitle>API Key Created Successfully</AlertDialogTitle>
<AlertDialogDescription>
Your new API key has been created. Make sure to copy it now as you won't be able to
see it again.
</AlertDialogDescription>
</AlertDialogHeader>
<div className='space-y-2 py-2'>
<Label htmlFor='created-key'>API Key</Label>
<div className='flex gap-2'>
<Input
id='created-key'
value={newKey?.key || ''}
readOnly
className='font-mono text-sm'
/>
<Button
type='button'
variant='outline'
size='icon'
onClick={handleCopyKey}
className='flex-shrink-0'
>
{copySuccess ? <Check className='h-4 w-4' /> : <Copy className='h-4 w-4' />}
</Button>
</div>
</div>
<AlertDialogFooter>
<AlertDialogAction
onClick={() => {
setShowNewKeyDialog(false)
setNewKey(null)
setCopySuccess(false)
// Auto-select the newly created key
if (justCreatedKeyId) {
onChange(justCreatedKeyId)
setJustCreatedKeyId(null)
}
}}
>
Done
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}

View File

@@ -41,11 +41,12 @@ interface ChatDeployProps {
chatSubmitting: boolean
setChatSubmitting: (submitting: boolean) => void
onValidationChange?: (isValid: boolean) => void
onPreDeployWorkflow?: () => Promise<void>
showDeleteConfirmation?: boolean
setShowDeleteConfirmation?: (show: boolean) => void
onDeploymentComplete?: () => void
onDeployed?: () => void
onUndeploy?: () => Promise<void>
onVersionActivated?: () => void
}
interface ExistingChat {
@@ -69,11 +70,12 @@ export function ChatDeploy({
chatSubmitting,
setChatSubmitting,
onValidationChange,
onPreDeployWorkflow,
showDeleteConfirmation: externalShowDeleteConfirmation,
setShowDeleteConfirmation: externalSetShowDeleteConfirmation,
onDeploymentComplete,
onDeployed,
onUndeploy,
onVersionActivated,
}: ChatDeployProps) {
const [isLoading, setIsLoading] = useState(false)
const [existingChat, setExistingChat] = useState<ExistingChat | null>(null)
@@ -97,6 +99,7 @@ export function ChatDeploy({
const { deployedUrl, deployChat } = useChatDeployment()
const formRef = useRef<HTMLFormElement>(null)
const [isIdentifierValid, setIsIdentifierValid] = useState(false)
const isFormValid =
isIdentifierValid &&
Boolean(formData.title.trim()) &&
@@ -104,7 +107,7 @@ export function ChatDeploy({
(formData.authType !== 'password' ||
Boolean(formData.password.trim()) ||
Boolean(existingChat)) &&
(formData.authType !== 'email' || formData.emails.length > 0)
((formData.authType !== 'email' && formData.authType !== 'sso') || formData.emails.length > 0)
useEffect(() => {
onValidationChange?.(isFormValid)
@@ -148,7 +151,6 @@ export function ChatDeploy({
: [],
})
// Set image URL if it exists
if (chatDetail.customizations?.imageUrl) {
setImageUrl(chatDetail.customizations.imageUrl)
}
@@ -178,8 +180,6 @@ export function ChatDeploy({
setChatSubmitting(true)
try {
await onPreDeployWorkflow?.()
if (!validateForm()) {
setChatSubmitting(false)
return
@@ -191,14 +191,13 @@ export function ChatDeploy({
return
}
await deployChat(workflowId, formData, deploymentInfo, existingChat?.id, imageUrl)
await deployChat(workflowId, formData, null, existingChat?.id, imageUrl)
onChatExistsChange?.(true)
setShowSuccessView(true)
onDeployed?.()
onVersionActivated?.()
// Fetch the updated chat data immediately after deployment
// This ensures existingChat is available when switching back to edit mode
await fetchExistingChat()
} catch (error: any) {
if (error.message?.includes('identifier')) {
@@ -226,13 +225,15 @@ export function ChatDeploy({
throw new Error(error.error || 'Failed to delete chat')
}
// Update state
if (onUndeploy) {
await onUndeploy()
}
setExistingChat(null)
setImageUrl(null)
setImageUploadError(null)
onChatExistsChange?.(false)
// Notify parent of successful deletion
onDeploymentComplete?.()
} catch (error: any) {
logger.error('Failed to delete chat:', error)
@@ -268,8 +269,8 @@ export function ChatDeploy({
This will permanently delete your chat deployment at{' '}
<span className='font-mono text-destructive'>
{getEmailDomain()}/chat/{existingChat?.identifier}
</span>
.
</span>{' '}
and undeploy the workflow.
<span className='mt-2 block'>
All users will lose access immediately, and this action cannot be undone.
</span>
@@ -324,6 +325,7 @@ export function ChatDeploy({
onValidationChange={setIsIdentifierValid}
isEditingExisting={!!existingChat}
/>
<div className='space-y-2'>
<Label htmlFor='title' className='font-medium text-sm'>
Chat Title
@@ -403,14 +405,13 @@ export function ChatDeploy({
</p>
</div>
{/* Image Upload Section */}
<div className='space-y-2'>
<Label className='font-medium text-sm'>Chat Logo</Label>
<ImageUpload
value={imageUrl}
onUpload={(url) => {
setImageUrl(url)
setImageUploadError(null) // Clear error on successful upload
setImageUploadError(null)
}}
onError={setImageUploadError}
onUploadStart={setIsImageUploading}
@@ -427,7 +428,6 @@ export function ChatDeploy({
)}
</div>
{/* Hidden delete trigger button for modal footer */}
<button
type='button'
data-delete-trigger
@@ -437,7 +437,6 @@ export function ChatDeploy({
</div>
</form>
{/* Delete Confirmation Dialog */}
<AlertDialog open={showDeleteConfirmation} onOpenChange={setShowDeleteConfirmation}>
<AlertDialogContent>
<AlertDialogHeader>
@@ -446,8 +445,8 @@ export function ChatDeploy({
This will permanently delete your chat deployment at{' '}
<span className='font-mono text-destructive'>
{getEmailDomain()}/chat/{existingChat?.identifier}
</span>
.
</span>{' '}
and undeploy the workflow.
<span className='mt-2 block'>
All users will lose access immediately, and this action cannot be undone.
</span>

View File

@@ -1,6 +1,7 @@
import { useState } from 'react'
import { Check, Copy, Eye, EyeOff, Plus, RefreshCw, Trash2 } from 'lucide-react'
import { Button, Card, CardContent, Input, Label } from '@/components/ui'
import { getEnv, isTruthy } from '@/lib/env'
import { cn, generatePassword } from '@/lib/utils'
import type { AuthType } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/hooks/use-chat-form'
@@ -63,13 +64,20 @@ export function AuthSelector({
onEmailsChange(emails.filter((e) => e !== email))
}
const ssoEnabled = isTruthy(getEnv('NEXT_PUBLIC_SSO_ENABLED'))
const authOptions = ssoEnabled
? (['public', 'password', 'email', 'sso'] as const)
: (['public', 'password', 'email'] as const)
return (
<div className='space-y-2'>
<Label className='font-medium text-sm'>Access Control</Label>
{/* Auth Type Selection */}
<div className='grid grid-cols-1 gap-3 md:grid-cols-3'>
{(['public', 'password', 'email'] as const).map((type) => (
<div
className={cn('grid grid-cols-1 gap-3', ssoEnabled ? 'md:grid-cols-4' : 'md:grid-cols-3')}
>
{authOptions.map((type) => (
<Card
key={type}
className={cn(
@@ -92,11 +100,13 @@ export function AuthSelector({
{type === 'public' && 'Public Access'}
{type === 'password' && 'Password Protected'}
{type === 'email' && 'Email Access'}
{type === 'sso' && 'SSO Access'}
</h3>
<p className='text-muted-foreground text-xs'>
{type === 'public' && 'Anyone can access your chat'}
{type === 'password' && 'Secure with a single password'}
{type === 'email' && 'Restrict to specific emails'}
{type === 'sso' && 'Authenticate via SSO provider'}
</p>
</div>
</CardContent>
@@ -207,10 +217,12 @@ export function AuthSelector({
</Card>
)}
{authType === 'email' && (
{(authType === 'email' || authType === 'sso') && (
<Card className='rounded-[8px] shadow-none'>
<CardContent className='p-4'>
<h3 className='mb-2 font-medium text-sm'>Email Access Settings</h3>
<h3 className='mb-2 font-medium text-sm'>
{authType === 'email' ? 'Email Access Settings' : 'SSO Access Settings'}
</h3>
<div className='flex gap-2'>
<Input
@@ -264,7 +276,9 @@ export function AuthSelector({
)}
<p className='mt-2 text-muted-foreground text-xs'>
Add specific emails or entire domains (@example.com)
{authType === 'email'
? 'Add specific emails or entire domains (@example.com)'
: 'Add specific emails or entire domains (@example.com) that can access via SSO'}
</p>
</CardContent>
</Card>

View File

@@ -72,7 +72,6 @@ export function useChatDeployment() {
})
.filter(Boolean) as OutputConfig[]
// Create request payload
const payload = {
workflowId,
identifier: formData.identifier.trim(),
@@ -85,10 +84,11 @@ export function useChatDeployment() {
},
authType: formData.authType,
password: formData.authType === 'password' ? formData.password : undefined,
allowedEmails: formData.authType === 'email' ? formData.emails : [],
allowedEmails:
formData.authType === 'email' || formData.authType === 'sso' ? formData.emails : [],
outputConfigs,
apiKey: deploymentInfo?.apiKey,
deployApiEnabled: !existingChatId, // Only deploy API for new chats
deployApiEnabled: !existingChatId,
}
// Validate with Zod

View File

@@ -1,6 +1,6 @@
import { useCallback, useState } from 'react'
export type AuthType = 'public' | 'password' | 'email'
export type AuthType = 'public' | 'password' | 'email' | 'sso'
export interface ChatFormData {
identifier: string
@@ -85,6 +85,10 @@ export function useChatForm(initialData?: Partial<ChatFormData>) {
newErrors.emails = 'At least one email or domain is required when using email access control'
}
if (formData.authType === 'sso' && formData.emails.length === 0) {
newErrors.emails = 'At least one email or domain is required when using SSO access control'
}
if (formData.selectedOutputBlocks.length === 0) {
newErrors.outputBlocks = 'Please select at least one output block'
}

View File

@@ -1,61 +1,18 @@
'use client'
import { useEffect, useState } from 'react'
import { useEffect } from 'react'
import { zodResolver } from '@hookform/resolvers/zod'
import { Check, Copy, Loader2, Plus } from 'lucide-react'
import { useParams } from 'next/navigation'
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Form, FormField, FormItem, FormMessage } from '@/components/ui/form'
import { createLogger } from '@/lib/logs/console/logger'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import {
type ApiKey,
ApiKeySelector,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/api-key-selector/api-key-selector'
const logger = createLogger('DeployForm')
interface ApiKey {
id: string
name: string
key: string
displayKey?: string
lastUsed?: string
createdAt: string
expiresAt?: string
createdBy?: string
}
interface ApiKeysData {
workspace: ApiKey[]
personal: ApiKey[]
conflicts: string[]
}
// Form schema for API key selection or creation
const deployFormSchema = z.object({
apiKey: z.string().min(1, 'Please select an API key'),
@@ -65,213 +22,39 @@ const deployFormSchema = z.object({
type DeployFormValues = z.infer<typeof deployFormSchema>
interface DeployFormProps {
apiKeys: ApiKey[] // Legacy prop for backward compatibility
keysLoaded: boolean
apiKeys: ApiKey[]
selectedApiKeyId: string
onApiKeyChange: (keyId: string) => void
onSubmit: (data: DeployFormValues) => void
onApiKeyCreated?: () => void
// Optional id to bind an external submit button via the `form` attribute
formId?: string
isDeployed?: boolean
deployedApiKeyDisplay?: string
}
export function DeployForm({
apiKeys,
keysLoaded,
selectedApiKeyId,
onApiKeyChange,
onSubmit,
onApiKeyCreated,
formId,
isDeployed = false,
deployedApiKeyDisplay,
}: DeployFormProps) {
const params = useParams()
const workspaceId = (params?.workspaceId as string) || ''
const userPermissions = useUserPermissionsContext()
const canCreateWorkspaceKeys = userPermissions.canEdit || userPermissions.canAdmin
// State
const [apiKeysData, setApiKeysData] = useState<ApiKeysData | null>(null)
const [isCreatingKey, setIsCreatingKey] = useState(false)
const [newKeyName, setNewKeyName] = useState('')
const [keyType, setKeyType] = useState<'personal' | 'workspace'>('personal')
const [newKey, setNewKey] = useState<ApiKey | null>(null)
const [showNewKeyDialog, setShowNewKeyDialog] = useState(false)
const [copySuccess, setCopySuccess] = useState(false)
const [isSubmittingCreate, setIsSubmittingCreate] = useState(false)
const [keysLoaded2, setKeysLoaded2] = useState(false)
const [createError, setCreateError] = useState<string | null>(null)
const [justCreatedKeyId, setJustCreatedKeyId] = useState<string | null>(null)
// Get all available API keys (workspace + personal)
const allApiKeys = apiKeysData ? [...apiKeysData.workspace, ...apiKeysData.personal] : apiKeys
// Initialize form with react-hook-form
const form = useForm<DeployFormValues>({
resolver: zodResolver(deployFormSchema),
defaultValues: {
apiKey: allApiKeys.length > 0 ? allApiKeys[0].id : '',
apiKey: selectedApiKeyId || (apiKeys.length > 0 ? apiKeys[0].id : ''),
newKeyName: '',
},
})
// Fetch workspace and personal API keys
const fetchApiKeys = async () => {
if (!workspaceId) return
try {
setKeysLoaded2(false)
const [workspaceResponse, personalResponse] = await Promise.all([
fetch(`/api/workspaces/${workspaceId}/api-keys`),
fetch('/api/users/me/api-keys'),
])
let workspaceKeys: ApiKey[] = []
let personalKeys: ApiKey[] = []
if (workspaceResponse.ok) {
const workspaceData = await workspaceResponse.json()
workspaceKeys = workspaceData.keys || []
} else {
logger.error('Error fetching workspace API keys:', { status: workspaceResponse.status })
}
if (personalResponse.ok) {
const personalData = await personalResponse.json()
personalKeys = personalData.keys || []
} else {
logger.error('Error fetching personal API keys:', { status: personalResponse.status })
}
// Client-side conflict detection
const workspaceKeyNames = new Set(workspaceKeys.map((k) => k.name))
const conflicts = personalKeys
.filter((key) => workspaceKeyNames.has(key.name))
.map((key) => key.name)
setApiKeysData({
workspace: workspaceKeys,
personal: personalKeys,
conflicts,
})
setKeysLoaded2(true)
} catch (error) {
logger.error('Error fetching API keys:', { error })
setKeysLoaded2(true)
}
}
// Update on dependency changes beyond the initial load
useEffect(() => {
if (workspaceId) {
fetchApiKeys()
if (selectedApiKeyId) {
form.setValue('apiKey', selectedApiKeyId)
}
}, [workspaceId])
useEffect(() => {
if ((keysLoaded || keysLoaded2) && allApiKeys.length > 0) {
const currentValue = form.getValues().apiKey
// If we just created a key, prioritize selecting it
if (justCreatedKeyId && allApiKeys.find((key) => key.id === justCreatedKeyId)) {
form.setValue('apiKey', justCreatedKeyId)
setJustCreatedKeyId(null) // Clear after setting
}
// Otherwise, ensure form has a value if it doesn't already
else if (!currentValue || !allApiKeys.find((key) => key.id === currentValue)) {
form.setValue('apiKey', allApiKeys[0].id)
}
}
}, [keysLoaded, keysLoaded2, allApiKeys, form, justCreatedKeyId])
// Generate a new API key
const handleCreateKey = async () => {
if (!newKeyName.trim()) return
// Client-side duplicate check for immediate feedback
const trimmedName = newKeyName.trim()
const isDuplicate =
keyType === 'workspace'
? (apiKeysData?.workspace || []).some((k) => k.name === trimmedName)
: (apiKeysData?.personal || apiKeys || []).some((k) => k.name === trimmedName)
if (isDuplicate) {
setCreateError(
keyType === 'workspace'
? `A workspace API key named "${trimmedName}" already exists. Please choose a different name.`
: `A personal API key named "${trimmedName}" already exists. Please choose a different name.`
)
return
}
setIsSubmittingCreate(true)
setCreateError(null)
try {
const url =
keyType === 'workspace'
? `/api/workspaces/${workspaceId}/api-keys`
: '/api/users/me/api-keys'
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: newKeyName.trim(),
}),
})
if (response.ok) {
const data = await response.json()
// Show the new key dialog with the API key (only shown once)
setNewKey(data.key)
setShowNewKeyDialog(true)
// Reset form and close the create dialog ONLY on success
setNewKeyName('')
setKeyType('personal')
setCreateError(null)
setIsCreatingKey(false)
// Store the newly created key ID for auto-selection
setJustCreatedKeyId(data.key.id)
// Refresh the keys list - the useEffect will handle auto-selection
await fetchApiKeys()
// Trigger a refresh of the keys list in the parent component
if (onApiKeyCreated) {
onApiKeyCreated()
}
} else {
let errorData
try {
errorData = await response.json()
} catch (parseError) {
errorData = { error: 'Server error' }
}
// Check for duplicate name error and prefer server message
const serverMessage = typeof errorData?.error === 'string' ? errorData.error : null
if (response.status === 409 || serverMessage?.toLowerCase().includes('already exists')) {
setCreateError(
serverMessage ||
(keyType === 'workspace'
? `A workspace API key named "${trimmedName}" already exists. Please choose a different name.`
: `A personal API key named "${trimmedName}" already exists. Please choose a different name.`)
)
} else {
setCreateError(errorData.error || 'Failed to create API key. Please try again.')
}
logger.error('Failed to create API key:', errorData)
}
} catch (error) {
setCreateError('Failed to create API key. Please check your connection and try again.')
logger.error('Error creating API key:', { error })
} finally {
setIsSubmittingCreate(false)
}
}
// Copy API key to clipboard
const copyToClipboard = (key: string) => {
navigator.clipboard.writeText(key)
setCopySuccess(true)
setTimeout(() => setCopySuccess(false), 2000)
}
}, [selectedApiKeyId, form])
return (
<Form {...form}>
@@ -283,251 +66,28 @@ export function DeployForm({
}}
className='space-y-6'
>
{/* API Key selection */}
<FormField
control={form.control}
name='apiKey'
render={({ field }) => (
<FormItem className='space-y-1.5'>
<div className='flex items-center justify-between'>
<FormLabel className='font-medium text-sm'>Select API Key</FormLabel>
<Button
type='button'
variant='ghost'
size='sm'
className='h-7 gap-1 px-2 text-muted-foreground text-xs'
onClick={() => {
setIsCreatingKey(true)
setCreateError(null)
}}
>
<Plus className='h-3.5 w-3.5' />
<span>Create new</span>
</Button>
</div>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger className={!keysLoaded ? 'opacity-70' : ''}>
{!keysLoaded ? (
<div className='flex items-center space-x-2'>
<Loader2 className='h-3.5 w-3.5 animate-spin' />
<span>Loading API keys...</span>
</div>
) : (
<SelectValue placeholder='Select an API key' className='text-sm' />
)}
</SelectTrigger>
</FormControl>
<SelectContent align='start' className='w-[var(--radix-select-trigger-width)] py-1'>
{apiKeysData && apiKeysData.workspace.length > 0 && (
<SelectGroup>
<SelectLabel className='px-3 py-1.5 font-medium text-muted-foreground text-xs uppercase tracking-wide'>
Workspace
</SelectLabel>
{apiKeysData.workspace.map((apiKey) => (
<SelectItem
key={apiKey.id}
value={apiKey.id}
className='my-0.5 flex cursor-pointer items-center rounded-sm px-3 py-2.5 data-[state=checked]:bg-muted [&>span.absolute]:hidden'
>
<div className='flex w-full items-center'>
<div className='flex w-full items-center justify-between'>
<span className='mr-2 truncate text-sm'>{apiKey.name}</span>
<span className='mt-[1px] flex-shrink-0 rounded bg-muted px-1.5 py-0.5 font-mono text-muted-foreground text-xs'>
{apiKey.displayKey || apiKey.key}
</span>
</div>
</div>
</SelectItem>
))}
</SelectGroup>
)}
{((apiKeysData && apiKeysData.personal.length > 0) ||
(!apiKeysData && apiKeys.length > 0)) && (
<SelectGroup>
<SelectLabel className='px-3 py-1.5 font-medium text-muted-foreground text-xs uppercase tracking-wide'>
Personal
</SelectLabel>
{(apiKeysData ? apiKeysData.personal : apiKeys).map((apiKey) => (
<SelectItem
key={apiKey.id}
value={apiKey.id}
className='my-0.5 flex cursor-pointer items-center rounded-sm px-3 py-2.5 data-[state=checked]:bg-muted [&>span.absolute]:hidden'
>
<div className='flex w-full items-center'>
<div className='flex w-full items-center justify-between'>
<span className='mr-2 truncate text-sm'>{apiKey.name}</span>
<span className='mt-[1px] flex-shrink-0 rounded bg-muted px-1.5 py-0.5 font-mono text-muted-foreground text-xs'>
{apiKey.displayKey || apiKey.key}
</span>
</div>
</div>
</SelectItem>
))}
</SelectGroup>
)}
{!apiKeysData && apiKeys.length === 0 && (
<div className='px-3 py-2 text-muted-foreground text-sm'>
No API keys available
</div>
)}
{apiKeysData &&
apiKeysData.workspace.length === 0 &&
apiKeysData.personal.length === 0 && (
<div className='px-3 py-2 text-muted-foreground text-sm'>
No API keys available
</div>
)}
</SelectContent>
</Select>
<ApiKeySelector
value={field.value}
onChange={(keyId) => {
field.onChange(keyId)
onApiKeyChange(keyId)
}}
apiKeys={apiKeys}
onApiKeyCreated={onApiKeyCreated}
showLabel={true}
label='Select API Key'
isDeployed={isDeployed}
deployedApiKeyDisplay={deployedApiKeyDisplay}
/>
<FormMessage />
</FormItem>
)}
/>
{/* Create API Key Dialog */}
<AlertDialog open={isCreatingKey} onOpenChange={setIsCreatingKey}>
<AlertDialogContent className='rounded-[10px] sm:max-w-md'>
<AlertDialogHeader>
<AlertDialogTitle>Create new API key</AlertDialogTitle>
<AlertDialogDescription>
{keyType === 'workspace'
? "This key will have access to all workflows in this workspace. Make sure to copy it after creation as you won't be able to see it again."
: "This key will have access to your personal workflows. Make sure to copy it after creation as you won't be able to see it again."}
</AlertDialogDescription>
</AlertDialogHeader>
<div className='space-y-4 py-2'>
{canCreateWorkspaceKeys && (
<div className='space-y-2'>
<p className='font-[360] text-sm'>API Key Type</p>
<div className='flex gap-2'>
<Button
type='button'
variant={keyType === 'personal' ? 'default' : 'outline'}
size='sm'
onClick={() => {
setKeyType('personal')
if (createError) setCreateError(null)
}}
className='h-8 data-[variant=outline]:border-border data-[variant=outline]:bg-background data-[variant=outline]:text-foreground data-[variant=outline]:hover:bg-muted dark:data-[variant=outline]:border-border dark:data-[variant=outline]:bg-background dark:data-[variant=outline]:text-foreground dark:data-[variant=outline]:hover:bg-muted/80'
>
Personal
</Button>
<Button
type='button'
variant={keyType === 'workspace' ? 'default' : 'outline'}
size='sm'
onClick={() => {
setKeyType('workspace')
if (createError) setCreateError(null)
}}
className='h-8 data-[variant=outline]:border-border data-[variant=outline]:bg-background data-[variant=outline]:text-foreground data-[variant=outline]:hover:bg-muted dark:data-[variant=outline]:border-border dark:data-[variant=outline]:bg-background dark:data-[variant=outline]:text-foreground dark:data-[variant=outline]:hover:bg-muted/80'
>
Workspace
</Button>
</div>
</div>
)}
<div className='space-y-2'>
<p className='font-[360] text-sm'>
Enter a name for your API key to help you identify it later.
</p>
<Input
value={newKeyName}
onChange={(e) => {
setNewKeyName(e.target.value)
if (createError) setCreateError(null) // Clear error when user types
}}
placeholder='e.g., Development, Production'
className='h-9 rounded-[8px]'
autoFocus
/>
{createError && <div className='text-red-600 text-sm'>{createError}</div>}
</div>
</div>
<AlertDialogFooter className='flex'>
<AlertDialogCancel
className='h-9 w-full rounded-[8px] border-border bg-background text-foreground hover:bg-muted dark:border-border dark:bg-background dark:text-foreground dark:hover:bg-muted/80'
onClick={() => {
setNewKeyName('')
setKeyType('personal')
setCreateError(null)
}}
>
Cancel
</AlertDialogCancel>
<Button
type='button'
onClick={handleCreateKey}
className='h-9 w-full rounded-[8px] bg-primary text-white hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50'
disabled={
!newKeyName.trim() ||
isSubmittingCreate ||
(keyType === 'workspace' && !canCreateWorkspaceKeys)
}
>
{isSubmittingCreate ? (
<>
<Loader2 className='mr-1.5 h-3.5 w-3.5 animate-spin' />
Creating...
</>
) : (
`Create ${keyType === 'workspace' ? 'Workspace' : 'Personal'} Key`
)}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* New API Key Dialog */}
<AlertDialog
open={showNewKeyDialog}
onOpenChange={(open) => {
setShowNewKeyDialog(open)
if (!open) {
setNewKey(null)
setCopySuccess(false)
}
}}
>
<AlertDialogContent className='rounded-[10px] sm:max-w-md'>
<AlertDialogHeader>
<AlertDialogTitle>Your API key has been created</AlertDialogTitle>
<AlertDialogDescription>
This is the only time you will see your API key.{' '}
<span className='font-semibold'>Copy it now and store it securely.</span>
</AlertDialogDescription>
</AlertDialogHeader>
{newKey && (
<div className='relative'>
<div className='flex h-9 items-center rounded-[6px] border-none bg-muted px-3 pr-10'>
<code className='flex-1 truncate font-mono text-foreground text-sm'>
{newKey.key}
</code>
</div>
<Button
variant='ghost'
size='icon'
className='-translate-y-1/2 absolute top-1/2 right-1 h-7 w-7 rounded-[4px] text-muted-foreground hover:bg-muted hover:text-foreground'
onClick={() => copyToClipboard(newKey.key)}
>
{copySuccess ? (
<Check className='h-3.5 w-3.5' />
) : (
<Copy className='h-3.5 w-3.5' />
)}
<span className='sr-only'>Copy to clipboard</span>
</Button>
</div>
)}
</AlertDialogContent>
</AlertDialog>
</form>
</Form>
)

View File

@@ -118,9 +118,15 @@ export function DeployedWorkflowModal({
Active
</div>
) : (
<Button onClick={onActivateVersion} disabled={!!isActivating}>
{isActivating ? 'Activating…' : 'Activate'}
</Button>
<div className='flex items-center gap-0'>
<Button
variant='outline'
disabled={!!isActivating}
onClick={() => onActivateVersion?.()}
>
{isActivating ? 'Activating…' : 'Activate'}
</Button>
</div>
))}
</div>

View File

@@ -35,6 +35,7 @@ export function DeploymentControls({
const isDeployed = deploymentStatus?.isDeployed || false
const workflowNeedsRedeployment = needsRedeployment
const isPreviousVersionActive = isDeployed && workflowNeedsRedeployment
const [isDeploying, _setIsDeploying] = useState(false)
const [isModalOpen, setIsModalOpen] = useState(false)
@@ -93,7 +94,9 @@ export function DeploymentControls({
'h-12 w-12 rounded-[11px] border bg-card text-card-foreground shadow-xs',
'hover:border-[var(--brand-primary-hex)] hover:bg-[var(--brand-primary-hex)] hover:text-white',
'transition-all duration-200',
isDeployed && 'text-[var(--brand-primary-hover-hex)]',
isDeployed && !isPreviousVersionActive && 'text-[var(--brand-primary-hover-hex)]',
isPreviousVersionActive &&
'border-purple-500 bg-purple-500/10 text-purple-600 dark:text-purple-400',
isDisabled &&
'cursor-not-allowed opacity-50 hover:border hover:bg-card hover:text-card-foreground hover:shadow-xs'
)}

View File

@@ -432,8 +432,8 @@ IMPORTANT FORMATTING RULES:
<div
className={cn(
'group relative min-h-[100px] rounded-md border border-input bg-background font-mono text-sm transition-colors',
isConnecting && 'ring-2 ring-blue-500 ring-offset-2'
'group relative min-h-[100px] rounded-md bg-background font-mono text-sm transition-colors',
isConnecting ? 'ring-2 ring-blue-500' : 'border border-input'
)}
onDragOver={(e) => e.preventDefault()}
onDrop={handleDrop}

View File

@@ -38,11 +38,13 @@ const SCOPE_DESCRIPTIONS: Record<string, string> = {
'https://www.googleapis.com/auth/gmail.modify': 'View and manage your email messages',
// 'https://www.googleapis.com/auth/gmail.readonly': 'View and read your email messages',
// 'https://www.googleapis.com/auth/drive': 'View and manage your Google Drive files',
'https://www.googleapis.com/auth/drive.readonly': 'View and read your Google Drive files',
'https://www.googleapis.com/auth/drive.file': 'View and manage your Google Drive files',
// 'https://www.googleapis.com/auth/documents': 'View and manage your Google Docs',
'https://www.googleapis.com/auth/calendar': 'View and manage your calendar',
'https://www.googleapis.com/auth/userinfo.email': 'View your email address',
'https://www.googleapis.com/auth/userinfo.profile': 'View your basic profile info',
'https://www.googleapis.com/auth/forms.responses.readonly': 'View responses to your Google Forms',
'read:page:confluence': 'Read Confluence pages',
'write:page:confluence': 'Write Confluence pages',
'read:me': 'Read your profile information',

View File

@@ -1,4 +1,4 @@
import { useCallback } from 'react'
import { useCallback, useRef, useState } from 'react'
import { useParams } from 'next/navigation'
import { formatDisplayText } from '@/components/ui/formatted-text'
import { Input } from '@/components/ui/input'
@@ -12,19 +12,261 @@ import {
} from '@/components/ui/select'
import { Slider } from '@/components/ui/slider'
import { Switch } from '@/components/ui/switch'
import { checkTagTrigger, TagDropdown } from '@/components/ui/tag-dropdown'
import { Textarea } from '@/components/ui/textarea'
import { createLogger } from '@/lib/logs/console/logger'
import { cn } from '@/lib/utils'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-sub-block-value'
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
import { useMcpTools } from '@/hooks/use-mcp-tools'
import { formatParameterLabel } from '@/tools/params'
const logger = createLogger('McpDynamicArgs')
interface McpInputWithTagsProps {
value: string
onChange: (value: string) => void
placeholder?: string
disabled?: boolean
isPassword?: boolean
blockId: string
accessiblePrefixes?: Set<string>
isConnecting?: boolean
}
function McpInputWithTags({
value,
onChange,
placeholder,
disabled,
isPassword,
blockId,
accessiblePrefixes,
isConnecting = false,
}: McpInputWithTagsProps) {
const [showTags, setShowTags] = useState(false)
const [cursorPosition, setCursorPosition] = useState(0)
const [activeSourceBlockId, setActiveSourceBlockId] = useState<string | null>(null)
const inputRef = useRef<HTMLInputElement>(null)
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value
const newCursorPosition = e.target.selectionStart ?? 0
onChange(newValue)
setCursorPosition(newCursorPosition)
const tagTrigger = checkTagTrigger(newValue, newCursorPosition)
setShowTags(tagTrigger.show)
}
const handleDrop = (e: React.DragEvent<HTMLInputElement>) => {
e.preventDefault()
try {
const data = JSON.parse(e.dataTransfer.getData('application/json'))
if (data.type !== 'connectionBlock') return
const dropPosition = inputRef.current?.selectionStart ?? value.length ?? 0
const currentValue = value ?? ''
const newValue = `${currentValue.slice(0, dropPosition)}<${currentValue.slice(dropPosition)}`
onChange(newValue)
setCursorPosition(dropPosition + 1)
setShowTags(true)
if (data.connectionData?.sourceBlockId) {
setActiveSourceBlockId(data.connectionData.sourceBlockId)
}
setTimeout(() => {
if (inputRef.current) {
inputRef.current.selectionStart = dropPosition + 1
inputRef.current.selectionEnd = dropPosition + 1
}
}, 0)
} catch (error) {
logger.error('Failed to parse drop data:', { error })
}
}
const handleDragOver = (e: React.DragEvent<HTMLInputElement>) => {
e.preventDefault()
}
const handleTagSelect = (newValue: string) => {
onChange(newValue)
setShowTags(false)
setActiveSourceBlockId(null)
}
return (
<div className='relative'>
<div className='relative'>
<Input
ref={inputRef}
type={isPassword ? 'password' : 'text'}
value={value || ''}
onChange={handleChange}
onDrop={handleDrop}
onDragOver={handleDragOver}
placeholder={placeholder}
disabled={disabled}
className={cn(
!isPassword && 'text-transparent caret-foreground',
isConnecting && 'ring-2 ring-blue-500 ring-offset-2 focus-visible:ring-blue-500'
)}
/>
{!isPassword && (
<div className='pointer-events-none absolute inset-0 flex items-center overflow-hidden bg-transparent px-3 text-sm'>
<div className='whitespace-pre'>
{formatDisplayText(value?.toString() || '', {
accessiblePrefixes,
highlightAll: !accessiblePrefixes,
})}
</div>
</div>
)}
</div>
<TagDropdown
visible={showTags}
onSelect={handleTagSelect}
blockId={blockId}
activeSourceBlockId={activeSourceBlockId}
inputValue={value?.toString() ?? ''}
cursorPosition={cursorPosition}
onClose={() => {
setShowTags(false)
setActiveSourceBlockId(null)
}}
/>
</div>
)
}
interface McpTextareaWithTagsProps {
value: string
onChange: (value: string) => void
placeholder?: string
disabled?: boolean
blockId: string
accessiblePrefixes?: Set<string>
rows?: number
isConnecting?: boolean
}
function McpTextareaWithTags({
value,
onChange,
placeholder,
disabled,
blockId,
accessiblePrefixes,
rows = 4,
isConnecting = false,
}: McpTextareaWithTagsProps) {
const [showTags, setShowTags] = useState(false)
const [cursorPosition, setCursorPosition] = useState(0)
const [activeSourceBlockId, setActiveSourceBlockId] = useState<string | null>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newValue = e.target.value
const newCursorPosition = e.target.selectionStart ?? 0
onChange(newValue)
setCursorPosition(newCursorPosition)
// Check for tag trigger
const tagTrigger = checkTagTrigger(newValue, newCursorPosition)
setShowTags(tagTrigger.show)
}
const handleDrop = (e: React.DragEvent<HTMLTextAreaElement>) => {
e.preventDefault()
try {
const data = JSON.parse(e.dataTransfer.getData('application/json'))
if (data.type !== 'connectionBlock') return
const dropPosition = textareaRef.current?.selectionStart ?? value.length ?? 0
const currentValue = value ?? ''
const newValue = `${currentValue.slice(0, dropPosition)}<${currentValue.slice(dropPosition)}`
onChange(newValue)
setCursorPosition(dropPosition + 1)
setShowTags(true)
if (data.connectionData?.sourceBlockId) {
setActiveSourceBlockId(data.connectionData.sourceBlockId)
}
setTimeout(() => {
if (textareaRef.current) {
textareaRef.current.selectionStart = dropPosition + 1
textareaRef.current.selectionEnd = dropPosition + 1
}
}, 0)
} catch (error) {
logger.error('Failed to parse drop data:', { error })
}
}
const handleDragOver = (e: React.DragEvent<HTMLTextAreaElement>) => {
e.preventDefault()
}
const handleTagSelect = (newValue: string) => {
onChange(newValue)
setShowTags(false)
setActiveSourceBlockId(null)
}
return (
<div className='relative'>
<Textarea
ref={textareaRef}
value={value || ''}
onChange={handleChange}
onDrop={handleDrop}
onDragOver={handleDragOver}
placeholder={placeholder}
disabled={disabled}
rows={rows}
className={cn(
'min-h-[80px] resize-none text-transparent caret-foreground',
isConnecting && 'ring-2 ring-blue-500 ring-offset-2 focus-visible:ring-blue-500'
)}
/>
<div className='pointer-events-none absolute inset-0 overflow-auto whitespace-pre-wrap break-words p-3 text-sm'>
{formatDisplayText(value || '', {
accessiblePrefixes,
highlightAll: !accessiblePrefixes,
})}
</div>
<TagDropdown
visible={showTags}
onSelect={handleTagSelect}
blockId={blockId}
activeSourceBlockId={activeSourceBlockId}
inputValue={value?.toString() ?? ''}
cursorPosition={cursorPosition}
onClose={() => {
setShowTags(false)
setActiveSourceBlockId(null)
}}
/>
</div>
)
}
interface McpDynamicArgsProps {
blockId: string
subBlockId: string
disabled?: boolean
isPreview?: boolean
previewValue?: any
isConnecting?: boolean
}
export function McpDynamicArgs({
@@ -33,16 +275,18 @@ export function McpDynamicArgs({
disabled = false,
isPreview = false,
previewValue,
isConnecting = false,
}: McpDynamicArgsProps) {
const params = useParams()
const workspaceId = params.workspaceId as string
const { mcpTools } = useMcpTools(workspaceId)
const { mcpTools, isLoading } = useMcpTools(workspaceId)
const [selectedTool] = useSubBlockValue(blockId, 'tool')
const [cachedSchema] = useSubBlockValue(blockId, '_toolSchema')
const [toolArgs, setToolArgs] = useSubBlockValue(blockId, subBlockId)
const accessiblePrefixes = useAccessibleReferencePrefixes(blockId)
const selectedToolConfig = mcpTools.find((tool) => tool.id === selectedTool)
const toolSchema = selectedToolConfig?.inputSchema
const toolSchema = cachedSchema || selectedToolConfig?.inputSchema
const currentArgs = useCallback(() => {
if (isPreview && previewValue) {
@@ -68,14 +312,13 @@ export function McpDynamicArgs({
}, [toolArgs, previewValue, isPreview])
const updateParameter = useCallback(
(paramName: string, value: any, paramSchema?: any) => {
(paramName: string, value: any) => {
if (disabled) return
const current = currentArgs()
// Store the value as-is, without processing
// Store the value as-is, preserving types (number, boolean, etc.)
const updated = { ...current, [paramName]: value }
const jsonString = JSON.stringify(updated, null, 2)
setToolArgs(jsonString)
setToolArgs(updated)
},
[currentArgs, setToolArgs, disabled]
)
@@ -110,7 +353,7 @@ export function McpDynamicArgs({
<Switch
id={`${paramName}-switch`}
checked={!!value}
onCheckedChange={(checked) => updateParameter(paramName, checked, paramSchema)}
onCheckedChange={(checked) => updateParameter(paramName, checked)}
disabled={disabled}
/>
<Label
@@ -127,9 +370,7 @@ export function McpDynamicArgs({
<div key={`${paramName}-dropdown`}>
<Select
value={value || ''}
onValueChange={(selectedValue) =>
updateParameter(paramName, selectedValue, paramSchema)
}
onValueChange={(selectedValue) => updateParameter(paramName, selectedValue)}
disabled={disabled}
>
<SelectTrigger className='w-full'>
@@ -148,19 +389,23 @@ export function McpDynamicArgs({
</div>
)
case 'slider':
case 'slider': {
const minValue = paramSchema.minimum ?? 0
const maxValue = paramSchema.maximum ?? 100
const currentValue = value ?? minValue
const normalizedPosition = ((currentValue - minValue) / (maxValue - minValue)) * 100
return (
<div key={`${paramName}-slider`} className='relative pt-2 pb-6'>
<Slider
value={[value || paramSchema.minimum || 0]}
min={paramSchema.minimum || 0}
max={paramSchema.maximum || 100}
value={[currentValue]}
min={minValue}
max={maxValue}
step={paramSchema.type === 'integer' ? 1 : 0.1}
onValueChange={(newValue) =>
updateParameter(
paramName,
paramSchema.type === 'integer' ? Math.round(newValue[0]) : newValue[0],
paramSchema
paramSchema.type === 'integer' ? Math.round(newValue[0]) : newValue[0]
)
}
disabled={disabled}
@@ -169,41 +414,37 @@ export function McpDynamicArgs({
<div
className='absolute text-muted-foreground text-sm'
style={{
left: `clamp(0%, ${(((value || paramSchema.minimum || 0) - (paramSchema.minimum || 0)) / ((paramSchema.maximum || 100) - (paramSchema.minimum || 0))) * 100}%, 100%)`,
left: `clamp(0%, ${normalizedPosition}%, 100%)`,
transform: 'translateX(-50%)',
top: '24px',
}}
>
{paramSchema.type === 'integer'
? Math.round(value || paramSchema.minimum || 0).toString()
: Number(value || paramSchema.minimum || 0).toFixed(1)}
? Math.round(currentValue).toString()
: Number(currentValue).toFixed(1)}
</div>
</div>
)
}
case 'long-input':
return (
<div key={`${paramName}-long`} className='relative'>
<Textarea
value={value || ''}
onChange={(e) => updateParameter(paramName, e.target.value, paramSchema)}
placeholder={
paramSchema.type === 'array'
? `Enter JSON array, e.g. ["item1", "item2"] or comma-separated values`
: paramSchema.description ||
`Enter ${formatParameterLabel(paramName).toLowerCase()}`
}
disabled={disabled}
rows={4}
className='min-h-[80px] resize-none text-transparent caret-foreground'
/>
<div className='pointer-events-none absolute inset-0 overflow-auto whitespace-pre-wrap break-words p-3 text-sm'>
{formatDisplayText(value || '', {
accessiblePrefixes,
highlightAll: !accessiblePrefixes,
})}
</div>
</div>
<McpTextareaWithTags
key={`${paramName}-long`}
value={value || ''}
onChange={(newValue) => updateParameter(paramName, newValue)}
placeholder={
paramSchema.type === 'array'
? `Enter JSON array, e.g. ["item1", "item2"] or comma-separated values`
: paramSchema.description ||
`Enter ${formatParameterLabel(paramName).toLowerCase()}`
}
disabled={disabled}
blockId={blockId}
accessiblePrefixes={accessiblePrefixes}
rows={4}
isConnecting={isConnecting}
/>
)
default: {
@@ -212,48 +453,39 @@ export function McpDynamicArgs({
paramName.toLowerCase().includes('password') ||
paramName.toLowerCase().includes('token')
const isNumeric = paramSchema.type === 'number' || paramSchema.type === 'integer'
const isTextInput = !isPassword && !isNumeric
return (
<div key={`${paramName}-short`} className={isTextInput ? 'relative' : ''}>
<Input
type={isPassword ? 'password' : isNumeric ? 'number' : 'text'}
value={value || ''}
onChange={(e) => {
let processedValue: any = e.target.value
if (isNumeric && processedValue !== '') {
processedValue =
paramSchema.type === 'integer'
? Number.parseInt(processedValue)
: Number.parseFloat(processedValue)
<McpInputWithTags
key={`${paramName}-short`}
value={value?.toString() || ''}
onChange={(newValue) => {
let processedValue: any = newValue
const hasTag = newValue.includes('<') || newValue.includes('>')
if (Number.isNaN(processedValue)) {
processedValue = ''
return
}
if (isNumeric && processedValue !== '' && !hasTag) {
processedValue =
paramSchema.type === 'integer'
? Number.parseInt(processedValue)
: Number.parseFloat(processedValue)
if (Number.isNaN(processedValue)) {
processedValue = ''
}
updateParameter(paramName, processedValue, paramSchema)
}}
placeholder={
paramSchema.type === 'array'
? `Enter JSON array, e.g. ["item1", "item2"] or comma-separated values`
: paramSchema.description ||
`Enter ${formatParameterLabel(paramName).toLowerCase()}`
}
disabled={disabled}
className={isTextInput ? 'text-transparent caret-foreground' : ''}
/>
{isTextInput && (
<div className='pointer-events-none absolute inset-0 flex items-center overflow-hidden bg-transparent px-3 text-sm'>
<div className='whitespace-pre'>
{formatDisplayText(value?.toString() || '', {
accessiblePrefixes,
highlightAll: !accessiblePrefixes,
})}
</div>
</div>
)}
</div>
updateParameter(paramName, processedValue)
}}
placeholder={
paramSchema.type === 'array'
? `Enter JSON array, e.g. ["item1", "item2"] or comma-separated values`
: paramSchema.description ||
`Enter ${formatParameterLabel(paramName).toLowerCase()}`
}
disabled={disabled}
isPassword={isPassword}
blockId={blockId}
accessiblePrefixes={accessiblePrefixes}
isConnecting={isConnecting}
/>
)
}
}
@@ -267,6 +499,19 @@ export function McpDynamicArgs({
)
}
if (
selectedTool &&
!cachedSchema &&
!selectedToolConfig &&
(isLoading || mcpTools.length === 0)
) {
return (
<div className='rounded-lg border border-dashed p-8 text-center'>
<p className='text-muted-foreground text-sm'>Loading tool schema...</p>
</div>
)
}
if (!toolSchema?.properties || Object.keys(toolSchema.properties).length === 0) {
return (
<div className='rounded-lg border border-dashed p-8 text-center'>
@@ -277,27 +522,28 @@ export function McpDynamicArgs({
return (
<div className='space-y-4'>
{Object.entries(toolSchema.properties).map(([paramName, paramSchema]) => {
const inputType = getInputType(paramSchema as any)
const showLabel = inputType !== 'switch' // Switch component includes its own label
{toolSchema.properties &&
Object.entries(toolSchema.properties).map(([paramName, paramSchema]) => {
const inputType = getInputType(paramSchema as any)
const showLabel = inputType !== 'switch'
return (
<div key={paramName} className='space-y-2'>
{showLabel && (
<Label
className={cn(
'font-medium text-sm',
toolSchema.required?.includes(paramName) &&
'after:ml-1 after:text-red-500 after:content-["*"]'
)}
>
{formatParameterLabel(paramName)}
</Label>
)}
{renderParameterInput(paramName, paramSchema as any)}
</div>
)
})}
return (
<div key={paramName} className='space-y-2'>
{showLabel && (
<Label
className={cn(
'font-medium text-sm',
toolSchema.required?.includes(paramName) &&
'after:ml-1 after:text-red-500 after:content-["*"]'
)}
>
{formatParameterLabel(paramName)}
</Label>
)}
{renderParameterInput(paramName, paramSchema as any)}
</div>
)
})}
</div>
)
}

View File

@@ -39,6 +39,7 @@ export function McpToolSelector({
const { mcpTools, isLoading, error, refreshTools, getToolsByServer } = useMcpTools(workspaceId)
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlock.id)
const [, setSchemaCache] = useSubBlockValue(blockId, '_toolSchema')
const [serverValue] = useSubBlockValue(blockId, 'server')
@@ -82,6 +83,11 @@ export function McpToolSelector({
const handleSelect = (toolId: string) => {
if (!isPreview) {
setStoreValue(toolId)
const tool = availableTools.find((t) => t.id === toolId)
if (tool?.inputSchema) {
setSchemaCache(tool.inputSchema)
}
}
setOpen(false)
}

View File

@@ -287,7 +287,13 @@ export function ShortInput({
// Update all state in a single batch
Promise.resolve().then(() => {
setStoreValue(newValue)
// Update value through onChange if provided, otherwise use store
if (onChange) {
onChange(newValue)
} else if (!isPreview) {
setStoreValue(newValue)
}
setCursorPosition(dropPosition + 1)
setShowTags(true)

View File

@@ -13,6 +13,7 @@ interface McpTool {
serverName: string
icon: React.ComponentType<any>
bgColor: string
inputSchema?: any
}
interface StoredTool {
@@ -26,6 +27,7 @@ interface StoredTool {
}
isExpanded: boolean
usageControl: 'auto'
schema?: any
}
interface McpToolsListProps {
@@ -71,6 +73,7 @@ export function McpToolsList({
},
isExpanded: true,
usageControl: 'auto',
schema: mcpTool.inputSchema,
}
onToolSelect(newTool)

View File

@@ -58,6 +58,7 @@ const logger = createLogger('ToolInput')
interface ToolInputProps {
blockId: string
subBlockId: string
isConnecting: boolean
isPreview?: boolean
previewValue?: any
disabled?: boolean
@@ -280,6 +281,7 @@ function CodeSyncWrapper({
onChange,
uiComponent,
disabled,
isConnecting,
}: {
blockId: string
paramId: string
@@ -287,13 +289,14 @@ function CodeSyncWrapper({
onChange: (value: string) => void
uiComponent: any
disabled: boolean
isConnecting: boolean
}) {
return (
<GenericSyncWrapper blockId={blockId} paramId={paramId} value={value} onChange={onChange}>
<Code
blockId={blockId}
subBlockId={paramId}
isConnecting={false}
isConnecting={isConnecting}
language={uiComponent.language}
generationType={uiComponent.generationType}
disabled={disabled}
@@ -313,6 +316,7 @@ function ComboboxSyncWrapper({
onChange,
uiComponent,
disabled,
isConnecting,
}: {
blockId: string
paramId: string
@@ -320,6 +324,7 @@ function ComboboxSyncWrapper({
onChange: (value: string) => void
uiComponent: any
disabled: boolean
isConnecting: boolean
}) {
return (
<GenericSyncWrapper blockId={blockId} paramId={paramId} value={value} onChange={onChange}>
@@ -328,7 +333,7 @@ function ComboboxSyncWrapper({
subBlockId={paramId}
options={uiComponent.options || []}
placeholder={uiComponent.placeholder}
isConnecting={false}
isConnecting={isConnecting}
config={{
id: paramId,
type: 'combobox' as const,
@@ -414,6 +419,7 @@ function ChannelSelectorSyncWrapper({
export function ToolInput({
blockId,
subBlockId,
isConnecting,
isPreview = false,
previewValue,
disabled = false,
@@ -968,21 +974,25 @@ export function ToolInput({
toolIndex?: number,
currentToolParams?: Record<string, string>
) => {
// Create unique blockId for tool parameters to avoid conflicts with main block
const uniqueBlockId = toolIndex !== undefined ? `${blockId}-tool-${toolIndex}` : blockId
// Create unique subBlockId for tool parameters to avoid conflicts
// Use real blockId so tag dropdown and drag-drop work correctly
const uniqueSubBlockId =
toolIndex !== undefined
? `${subBlockId}-tool-${toolIndex}-${param.id}`
: `${subBlockId}-${param.id}`
const uiComponent = param.uiComponent
// If no UI component info, fall back to basic input
if (!uiComponent) {
return (
<ShortInput
blockId={uniqueBlockId}
subBlockId={`${subBlockId}-param`}
blockId={blockId}
subBlockId={uniqueSubBlockId}
placeholder={param.description}
password={isPasswordParameter(param.id)}
isConnecting={false}
isConnecting={isConnecting}
config={{
id: `${subBlockId}-param`,
id: uniqueSubBlockId,
type: 'short-input',
title: param.id,
}}
@@ -1024,12 +1034,12 @@ export function ToolInput({
case 'long-input':
return (
<LongInput
blockId={uniqueBlockId}
subBlockId={`${subBlockId}-param`}
blockId={blockId}
subBlockId={uniqueSubBlockId}
placeholder={uiComponent.placeholder || param.description}
isConnecting={false}
isConnecting={isConnecting}
config={{
id: `${subBlockId}-param`,
id: uniqueSubBlockId,
type: 'long-input',
title: param.id,
}}
@@ -1041,13 +1051,13 @@ export function ToolInput({
case 'short-input':
return (
<ShortInput
blockId={uniqueBlockId}
subBlockId={`${subBlockId}-param`}
blockId={blockId}
subBlockId={uniqueSubBlockId}
placeholder={uiComponent.placeholder || param.description}
password={uiComponent.password || isPasswordParameter(param.id)}
isConnecting={false}
isConnecting={isConnecting}
config={{
id: `${subBlockId}-param`,
id: uniqueSubBlockId,
type: 'short-input',
title: param.id,
}}
@@ -1134,14 +1144,15 @@ export function ToolInput({
onChange={onChange}
uiComponent={uiComponent}
disabled={disabled}
isConnecting={isConnecting}
/>
)
case 'slider':
return (
<SliderInputSyncWrapper
blockId={uniqueBlockId}
paramId={param.id}
blockId={blockId}
paramId={uniqueSubBlockId}
value={value}
onChange={onChange}
uiComponent={uiComponent}
@@ -1158,6 +1169,7 @@ export function ToolInput({
onChange={onChange}
uiComponent={uiComponent}
disabled={disabled}
isConnecting={isConnecting}
/>
)
@@ -1201,12 +1213,12 @@ export function ToolInput({
return (
<ShortInput
blockId={blockId}
subBlockId={`${subBlockId}-param`}
subBlockId={uniqueSubBlockId}
placeholder={uiComponent.placeholder || param.description}
password={uiComponent.password || isPasswordParameter(param.id)}
isConnecting={false}
isConnecting={isConnecting}
config={{
id: `${subBlockId}-param`,
id: uniqueSubBlockId,
type: 'short-input',
title: param.id,
}}
@@ -1410,15 +1422,17 @@ export function ToolInput({
: []
// For MCP tools, extract parameters from input schema
// Use cached schema from tool object if available, otherwise fetch from mcpTools
const mcpTool = isMcpTool ? mcpTools.find((t) => t.id === tool.toolId) : null
const mcpToolSchema = isMcpTool ? tool.schema || mcpTool?.inputSchema : null
const mcpToolParams =
isMcpTool && mcpTool?.inputSchema?.properties
? Object.entries(mcpTool.inputSchema.properties || {}).map(
isMcpTool && mcpToolSchema?.properties
? Object.entries(mcpToolSchema.properties || {}).map(
([paramId, param]: [string, any]) => ({
id: paramId,
type: param.type || 'string',
description: param.description || '',
visibility: (mcpTool.inputSchema.required?.includes(paramId)
visibility: (mcpToolSchema.required?.includes(paramId)
? 'user-or-llm'
: 'user-only') as 'user-or-llm' | 'user-only' | 'llm-only' | 'hidden',
})
@@ -1740,13 +1754,13 @@ export function ToolInput({
)
) : (
<ShortInput
blockId={`${blockId}-tool-${toolIndex}`}
subBlockId={`${subBlockId}-param`}
blockId={blockId}
subBlockId={`${subBlockId}-tool-${toolIndex}-${param.id}`}
placeholder={param.description}
password={isPasswordParameter(param.id)}
isConnecting={false}
isConnecting={isConnecting}
config={{
id: `${subBlockId}-param`,
id: `${subBlockId}-tool-${toolIndex}-${param.id}`,
type: 'short-input',
title: param.id,
}}

View File

@@ -230,6 +230,7 @@ export const SubBlock = memo(
<ToolInput
blockId={blockId}
subBlockId={config.id}
isConnecting={isConnecting}
isPreview={isPreview}
previewValue={previewValue}
disabled={allowExpandInPreview ? false : isDisabled}
@@ -522,6 +523,7 @@ export const SubBlock = memo(
disabled={isDisabled}
isPreview={isPreview}
previewValue={previewValue}
isConnecting={isConnecting}
/>
)
default:

View File

@@ -433,8 +433,25 @@ export const WorkflowBlock = memo(
)
)
// Memoized SubBlock layout management - only recalculate when dependencies change
const subBlockRows = useMemo(() => {
const getSubBlockStableKey = useCallback(
(subBlock: SubBlockConfig, stateToUse: Record<string, any>): string => {
if (subBlock.type === 'mcp-dynamic-args') {
const serverValue = stateToUse.server?.value || 'no-server'
const toolValue = stateToUse.tool?.value || 'no-tool'
return `${id}-${subBlock.id}-${serverValue}-${toolValue}`
}
if (subBlock.type === 'mcp-tool-selector') {
const serverValue = stateToUse.server?.value || 'no-server'
return `${id}-${subBlock.id}-${serverValue}`
}
return `${id}-${subBlock.id}`
},
[id]
)
const subBlockRowsData = useMemo(() => {
const rows: SubBlockConfig[][] = []
let currentRow: SubBlockConfig[] = []
let currentRowWidth = 0
@@ -542,7 +559,8 @@ export const WorkflowBlock = memo(
rows.push(currentRow)
}
return rows
// Return both rows and state for stable key generation
return { rows, stateToUse }
}, [
config.subBlocks,
id,
@@ -556,6 +574,10 @@ export const WorkflowBlock = memo(
activeWorkflowId,
])
// Extract rows and state from the memoized value
const subBlockRows = subBlockRowsData.rows
const subBlockState = subBlockRowsData.stateToUse
// Name editing handlers
const handleNameClick = (e: React.MouseEvent) => {
e.stopPropagation() // Prevent drag handler from interfering
@@ -1110,35 +1132,42 @@ export const WorkflowBlock = memo(
>
{subBlockRows.map((row, rowIndex) => (
<div key={`row-${rowIndex}`} className='flex gap-4'>
{row.map((subBlock, blockIndex) => (
<div
key={`${id}-${rowIndex}-${blockIndex}`}
className={cn('space-y-1', subBlock.layout === 'half' ? 'flex-1' : 'w-full')}
>
<SubBlock
blockId={id}
config={subBlock}
isConnecting={isConnecting}
isPreview={data.isPreview || currentWorkflow.isDiffMode}
subBlockValues={
data.subBlockValues ||
(currentWorkflow.isDiffMode && currentBlock
? (currentBlock as any).subBlocks
: undefined)
}
disabled={!userPermissions.canEdit}
fieldDiffStatus={
fieldDiff?.changed_fields?.includes(subBlock.id)
? 'changed'
: fieldDiff?.unchanged_fields?.includes(subBlock.id)
? 'unchanged'
: undefined
}
allowExpandInPreview={currentWorkflow.isDiffMode}
isWide={displayIsWide}
/>
</div>
))}
{row.map((subBlock) => {
const stableKey = getSubBlockStableKey(subBlock, subBlockState)
return (
<div
key={stableKey}
className={cn(
'space-y-1',
subBlock.layout === 'half' ? 'flex-1' : 'w-full'
)}
>
<SubBlock
blockId={id}
config={subBlock}
isConnecting={isConnecting}
isPreview={data.isPreview || currentWorkflow.isDiffMode}
subBlockValues={
data.subBlockValues ||
(currentWorkflow.isDiffMode && currentBlock
? (currentBlock as any).subBlocks
: undefined)
}
disabled={!userPermissions.canEdit}
fieldDiffStatus={
fieldDiff?.changed_fields?.includes(subBlock.id)
? 'changed'
: fieldDiff?.unchanged_fields?.includes(subBlock.id)
? 'unchanged'
: undefined
}
allowExpandInPreview={currentWorkflow.isDiffMode}
isWide={displayIsWide}
/>
</div>
)
})}
</div>
))}
</div>

View File

@@ -3,7 +3,7 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { Download, Search, Trash2 } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Input } from '@/components/ui'
import { Input, Progress, Skeleton } from '@/components/ui'
import { Button } from '@/components/ui/button'
import {
Table,
@@ -16,6 +16,7 @@ import {
import { createLogger } from '@/lib/logs/console/logger'
import { getFileExtension } from '@/lib/uploads/file-utils'
import type { WorkspaceFileRecord } from '@/lib/uploads/workspace-files'
import { cn } from '@/lib/utils'
import { getDocumentIcon } from '@/app/workspace/[workspaceId]/knowledge/components'
import { useUserPermissions } from '@/hooks/use-user-permissions'
import { useWorkspacePermissions } from '@/hooks/use-workspace-permissions'
@@ -38,6 +39,17 @@ const SUPPORTED_EXTENSIONS = [
] as const
const ACCEPT_ATTR = '.pdf,.csv,.doc,.docx,.txt,.md,.xlsx,.xls,.html,.htm,.pptx,.ppt'
interface StorageInfo {
usedBytes: number
limitBytes: number
percentUsed: number
}
interface UsageData {
plan: string
storage: StorageInfo
}
export function FileUploads() {
const params = useParams()
const workspaceId = params?.workspaceId as string
@@ -48,6 +60,9 @@ export function FileUploads() {
const [uploadError, setUploadError] = useState<string | null>(null)
const [uploadProgress, setUploadProgress] = useState({ completed: 0, total: 0 })
const fileInputRef = useRef<HTMLInputElement>(null)
const [storageInfo, setStorageInfo] = useState<StorageInfo | null>(null)
const [planName, setPlanName] = useState<string>('free')
const [storageLoading, setStorageLoading] = useState(true)
const { permissions: workspacePermissions, loading: permissionsLoading } =
useWorkspacePermissions(workspaceId)
@@ -71,8 +86,28 @@ export function FileUploads() {
}
}
const loadStorageInfo = async () => {
try {
setStorageLoading(true)
const response = await fetch('/api/users/me/usage-limits')
const data = await response.json()
if (data.success && data.storage) {
setStorageInfo(data.storage)
if (data.usage?.plan) {
setPlanName(data.usage.plan)
}
}
} catch (error) {
logger.error('Error loading storage info:', error)
} finally {
setStorageLoading(false)
}
}
useEffect(() => {
void loadFiles()
void loadStorageInfo()
}, [workspaceId])
const handleUploadClick = () => {
@@ -123,6 +158,7 @@ export function FileUploads() {
}
await loadFiles()
await loadStorageInfo()
if (unsupported.length) {
lastError = `Unsupported file type: ${unsupported.join(', ')}`
}
@@ -171,6 +207,7 @@ export function FileUploads() {
if (data.success) {
await loadFiles()
await loadStorageInfo()
}
} catch (error) {
logger.error('Error deleting file:', error)
@@ -206,6 +243,25 @@ export function FileUploads() {
return `${text.slice(0, start)}...${text.slice(-end)}`
}
const formatStorageSize = (bytes: number): string => {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`
}
const PLAN_NAMES = {
enterprise: 'Enterprise',
team: 'Team',
pro: 'Pro',
free: 'Free',
} as const
const displayPlanName = PLAN_NAMES[planName as keyof typeof PLAN_NAMES] || 'Free'
const GRADIENT_TEXT_STYLES =
'gradient-text bg-gradient-to-b from-gradient-primary via-gradient-secondary to-gradient-primary'
return (
<div className='relative flex h-full flex-col'>
{/* Header: search left, file count + Upload right */}
@@ -220,9 +276,31 @@ export function FileUploads() {
/>
</div>
<div className='flex items-center gap-3'>
<div className='text-muted-foreground text-sm'>
{files.length} {files.length === 1 ? 'file' : 'files'}
</div>
{storageLoading ? (
<Skeleton className='h-4 w-32' />
) : storageInfo ? (
<div className='flex flex-col items-end gap-1'>
<div className='flex items-center gap-2 text-sm'>
<span
className={cn(
'font-medium',
planName === 'free' ? 'text-foreground' : GRADIENT_TEXT_STYLES
)}
>
{displayPlanName}
</span>
<span className='text-muted-foreground tabular-nums'>
{formatStorageSize(storageInfo.usedBytes)} /{' '}
{formatStorageSize(storageInfo.limitBytes)}
</span>
</div>
<Progress
value={Math.min(storageInfo.percentUsed, 100)}
className='h-1 w-full'
indicatorClassName='bg-black dark:bg-white'
/>
</div>
) : null}
{userPermissions.canEdit && (
<div className='flex items-center'>
<input
@@ -265,7 +343,7 @@ export function FileUploads() {
) : (
<Table className='table-auto text-[13px]'>
<TableHeader>
<TableRow>
<TableRow className='hover:bg-transparent'>
<TableHead className='w-[56%] px-3 text-xs'>Name</TableHead>
<TableHead className='w-[14%] px-3 text-left text-xs'>Size</TableHead>
<TableHead className='w-[15%] px-3 text-left text-xs'>Uploaded</TableHead>

View File

@@ -12,7 +12,6 @@ import {
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { useSession, useSubscription } from '@/lib/auth-client'
import { createLogger } from '@/lib/logs/console/logger'
import { getBaseUrl } from '@/lib/urls/utils'
@@ -30,6 +29,7 @@ interface CancelSubscriptionProps {
}
subscriptionData?: {
periodEnd?: Date | null
cancelAtPeriodEnd?: boolean
}
}
@@ -127,35 +127,48 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub
const subscriptionStatus = getSubscriptionStatus()
const activeOrgId = activeOrganization?.id
// For team/enterprise plans, get the subscription ID from organization store
if ((subscriptionStatus.isTeam || subscriptionStatus.isEnterprise) && activeOrgId) {
const orgSubscription = useOrganizationStore.getState().subscriptionData
if (orgSubscription?.id && orgSubscription?.cancelAtPeriodEnd) {
// Restore the organization subscription
if (!betterAuthSubscription.restore) {
throw new Error('Subscription restore not available')
}
const result = await betterAuthSubscription.restore({
referenceId: activeOrgId,
subscriptionId: orgSubscription.id,
})
logger.info('Organization subscription restored successfully', result)
if (isCancelAtPeriodEnd) {
if (!betterAuthSubscription.restore) {
throw new Error('Subscription restore not available')
}
let referenceId: string
let subscriptionId: string | undefined
if ((subscriptionStatus.isTeam || subscriptionStatus.isEnterprise) && activeOrgId) {
const orgSubscription = useOrganizationStore.getState().subscriptionData
referenceId = activeOrgId
subscriptionId = orgSubscription?.id
} else {
// For personal subscriptions, use user ID and let better-auth find the subscription
referenceId = session.user.id
subscriptionId = undefined
}
logger.info('Restoring subscription', { referenceId, subscriptionId })
// Build restore params - only include subscriptionId if we have one (team/enterprise)
const restoreParams: any = { referenceId }
if (subscriptionId) {
restoreParams.subscriptionId = subscriptionId
}
const result = await betterAuthSubscription.restore(restoreParams)
logger.info('Subscription restored successfully', result)
}
// Refresh state and close
await refresh()
if (activeOrgId) {
await loadOrganizationSubscription(activeOrgId)
await refreshOrganization().catch(() => {})
}
setIsDialogOpen(false)
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to keep subscription'
const errorMessage = error instanceof Error ? error.message : 'Failed to restore subscription'
setError(errorMessage)
logger.error('Failed to keep subscription', { error })
logger.error('Failed to restore subscription', { error })
} finally {
setIsLoading(false)
}
@@ -190,19 +203,15 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub
const periodEndDate = getPeriodEndDate()
// Check if subscription is set to cancel at period end
const isCancelAtPeriodEnd = (() => {
const subscriptionStatus = getSubscriptionStatus()
if (subscriptionStatus.isTeam || subscriptionStatus.isEnterprise) {
return useOrganizationStore.getState().subscriptionData?.cancelAtPeriodEnd === true
}
return false
})()
const isCancelAtPeriodEnd = subscriptionData?.cancelAtPeriodEnd === true
return (
<>
<div className='flex items-center justify-between'>
<div>
<span className='font-medium text-sm'>Manage Subscription</span>
<span className='font-medium text-sm'>
{isCancelAtPeriodEnd ? 'Restore Subscription' : 'Manage Subscription'}
</span>
{isCancelAtPeriodEnd && (
<p className='mt-1 text-muted-foreground text-xs'>
You'll keep access until {formatDate(periodEndDate)}
@@ -217,10 +226,12 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub
'h-8 rounded-[8px] font-medium text-xs transition-all duration-200',
error
? 'border-red-500 text-red-500 dark:border-red-500 dark:text-red-500'
: 'text-muted-foreground hover:border-red-500 hover:bg-red-500 hover:text-white dark:hover:border-red-500 dark:hover:bg-red-500'
: isCancelAtPeriodEnd
? 'text-muted-foreground hover:border-green-500 hover:bg-green-500 hover:text-white dark:hover:border-green-500 dark:hover:bg-green-500'
: 'text-muted-foreground hover:border-red-500 hover:bg-red-500 hover:text-white dark:hover:border-red-500 dark:hover:bg-red-500'
)}
>
{error ? 'Error' : 'Manage'}
{error ? 'Error' : isCancelAtPeriodEnd ? 'Restore' : 'Manage'}
</Button>
</div>
@@ -228,11 +239,11 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{isCancelAtPeriodEnd ? 'Manage' : 'Cancel'} {subscription.plan} subscription?
{isCancelAtPeriodEnd ? 'Restore' : 'Cancel'} {subscription.plan} subscription?
</AlertDialogTitle>
<AlertDialogDescription>
{isCancelAtPeriodEnd
? 'Your subscription is set to cancel at the end of the billing period. You can reactivate it or manage other settings.'
? 'Your subscription is set to cancel at the end of the billing period. Would you like to keep your subscription active?'
: `You'll be redirected to Stripe to manage your subscription. You'll keep access until ${formatDate(
periodEndDate
)}, then downgrade to free plan.`}{' '}
@@ -260,38 +271,23 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub
<AlertDialogFooter className='flex'>
<AlertDialogCancel
className='h-9 w-full rounded-[8px]'
onClick={handleKeep}
onClick={isCancelAtPeriodEnd ? () => setIsDialogOpen(false) : handleKeep}
disabled={isLoading}
>
Keep Subscription
{isCancelAtPeriodEnd ? 'Cancel' : 'Keep Subscription'}
</AlertDialogCancel>
{(() => {
const subscriptionStatus = getSubscriptionStatus()
if (
subscriptionStatus.isPaid &&
(activeOrganization?.id
? useOrganizationStore.getState().subscriptionData?.cancelAtPeriodEnd
: false)
) {
if (subscriptionStatus.isPaid && isCancelAtPeriodEnd) {
return (
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<div className='w-full'>
<AlertDialogAction
disabled
className='h-9 w-full cursor-not-allowed rounded-[8px] bg-muted text-muted-foreground opacity-50'
>
Continue
</AlertDialogAction>
</div>
</TooltipTrigger>
<TooltipContent side='top'>
<p>Subscription will be cancelled at end of billing period</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<AlertDialogAction
onClick={handleKeep}
className='h-9 w-full rounded-[8px] bg-green-500 text-white transition-all duration-200 hover:bg-green-600 dark:bg-green-500 dark:hover:bg-green-600'
disabled={isLoading}
>
{isLoading ? 'Restoring...' : 'Restore Subscription'}
</AlertDialogAction>
)
}
return (

View File

@@ -523,6 +523,7 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
}}
subscriptionData={{
periodEnd: subscriptionData?.periodEnd || null,
cancelAtPeriodEnd: subscriptionData?.cancelAtPeriodEnd,
}}
/>
</div>

View File

@@ -38,10 +38,12 @@ Plain Text: Best for populating a table in free-form style.
title: 'Auth Token',
type: 'short-input',
layout: 'full',
placeholder: 'Enter your Clay Auth token',
placeholder: 'Enter your Clay webhook auth token',
password: true,
connectionDroppable: false,
required: true,
required: false,
description:
'Optional: If your Clay table has webhook authentication enabled, enter the auth token here. This will be sent in the x-clay-webhook-auth header.',
},
],
tools: {
@@ -53,6 +55,10 @@ Plain Text: Best for populating a table in free-form style.
data: { type: 'json', description: 'Data to populate' },
},
outputs: {
data: { type: 'json', description: 'Response data' },
data: { type: 'json', description: 'Response data from Clay webhook' },
metadata: {
type: 'json',
description: 'Webhook metadata including status, headers, timestamp, and content type',
},
},
}

View File

@@ -37,7 +37,10 @@ export const GoogleDocsBlock: BlockConfig<GoogleDocsResponse> = {
required: true,
provider: 'google-docs',
serviceId: 'google-docs',
requiredScopes: ['https://www.googleapis.com/auth/drive.file'],
requiredScopes: [
'https://www.googleapis.com/auth/drive.readonly',
'https://www.googleapis.com/auth/drive.file',
],
placeholder: 'Select Google account',
},
// Document selector (basic mode)

View File

@@ -37,7 +37,10 @@ export const GoogleDriveBlock: BlockConfig<GoogleDriveResponse> = {
required: true,
provider: 'google-drive',
serviceId: 'google-drive',
requiredScopes: ['https://www.googleapis.com/auth/drive.file'],
requiredScopes: [
'https://www.googleapis.com/auth/drive.readonly',
'https://www.googleapis.com/auth/drive.file',
],
placeholder: 'Select Google Drive account',
},
// Create/Upload File Fields
@@ -110,7 +113,10 @@ export const GoogleDriveBlock: BlockConfig<GoogleDriveResponse> = {
canonicalParamId: 'folderId',
provider: 'google-drive',
serviceId: 'google-drive',
requiredScopes: ['https://www.googleapis.com/auth/drive.file'],
requiredScopes: [
'https://www.googleapis.com/auth/drive.readonly',
'https://www.googleapis.com/auth/drive.file',
],
mimeType: 'application/vnd.google-apps.folder',
placeholder: 'Select a parent folder',
mode: 'basic',
@@ -186,7 +192,10 @@ export const GoogleDriveBlock: BlockConfig<GoogleDriveResponse> = {
canonicalParamId: 'folderId',
provider: 'google-drive',
serviceId: 'google-drive',
requiredScopes: ['https://www.googleapis.com/auth/drive.file'],
requiredScopes: [
'https://www.googleapis.com/auth/drive.readonly',
'https://www.googleapis.com/auth/drive.file',
],
mimeType: 'application/vnd.google-apps.folder',
placeholder: 'Select a parent folder',
mode: 'basic',
@@ -213,7 +222,10 @@ export const GoogleDriveBlock: BlockConfig<GoogleDriveResponse> = {
canonicalParamId: 'folderId',
provider: 'google-drive',
serviceId: 'google-drive',
requiredScopes: ['https://www.googleapis.com/auth/drive.file'],
requiredScopes: [
'https://www.googleapis.com/auth/drive.readonly',
'https://www.googleapis.com/auth/drive.file',
],
mimeType: 'application/vnd.google-apps.folder',
placeholder: 'Select a folder to list files from',
mode: 'basic',

View File

@@ -100,17 +100,17 @@ export const QdrantBlock: BlockConfig<QdrantResponse> = {
condition: { field: 'operation', value: 'search' },
},
{
id: 'with_payload',
title: 'With Payload',
type: 'switch',
layout: 'full',
condition: { field: 'operation', value: 'search' },
},
{
id: 'with_vector',
title: 'With Vector',
type: 'switch',
id: 'search_return_data',
title: 'Return Data',
type: 'dropdown',
layout: 'full',
options: [
{ label: 'Payload Only', id: 'payload_only' },
{ label: 'Vector Only', id: 'vector_only' },
{ label: 'Both Payload and Vector', id: 'both' },
{ label: 'None (IDs and scores only)', id: 'none' },
],
value: () => 'payload_only',
condition: { field: 'operation', value: 'search' },
},
// Fetch fields
@@ -142,17 +142,17 @@ export const QdrantBlock: BlockConfig<QdrantResponse> = {
required: true,
},
{
id: 'with_payload',
title: 'With Payload',
type: 'switch',
layout: 'full',
condition: { field: 'operation', value: 'fetch' },
},
{
id: 'with_vector',
title: 'With Vector',
type: 'switch',
id: 'fetch_return_data',
title: 'Return Data',
type: 'dropdown',
layout: 'full',
options: [
{ label: 'Payload Only', id: 'payload_only' },
{ label: 'Vector Only', id: 'vector_only' },
{ label: 'Both Payload and Vector', id: 'both' },
{ label: 'None (IDs only)', id: 'none' },
],
value: () => 'payload_only',
condition: { field: 'operation', value: 'fetch' },
},
{
@@ -194,6 +194,8 @@ export const QdrantBlock: BlockConfig<QdrantResponse> = {
limit: { type: 'number', description: 'Result limit' },
filter: { type: 'json', description: 'Search filter' },
ids: { type: 'json', description: 'Point identifiers' },
search_return_data: { type: 'string', description: 'Data to return from search' },
fetch_return_data: { type: 'string', description: 'Data to return from fetch' },
with_payload: { type: 'boolean', description: 'Include payload' },
with_vector: { type: 'boolean', description: 'Include vectors' },
},

View File

@@ -241,7 +241,7 @@ export class AgentBlockHandler implements BlockHandler {
}
private async createMcpTool(tool: ToolInput, context: ExecutionContext): Promise<any> {
const { serverId, toolName, params } = tool.params || {}
const { serverId, toolName, ...userProvidedParams } = tool.params || {}
if (!serverId || !toolName) {
logger.error('MCP tool missing required parameters:', { serverId, toolName })
@@ -294,11 +294,18 @@ export class AgentBlockHandler implements BlockHandler {
const toolId = createMcpToolId(serverId, toolName)
const { filterSchemaForLLM } = await import('@/tools/params')
const filteredSchema = filterSchemaForLLM(
mcpTool.inputSchema || { type: 'object', properties: {} },
userProvidedParams
)
return {
id: toolId,
name: toolName,
description: mcpTool.description || `MCP tool ${toolName} from ${mcpTool.serverName}`,
parameters: mcpTool.inputSchema || { type: 'object', properties: {} },
parameters: filteredSchema,
params: userProvidedParams,
usageControl: tool.usageControl || 'auto',
executeFunction: async (callParams: Record<string, any>) => {
logger.info(`Executing MCP tool ${toolName} on server ${serverId}`)
@@ -321,7 +328,7 @@ export class AgentBlockHandler implements BlockHandler {
body: JSON.stringify({
serverId,
toolName,
arguments: { ...params, ...callParams },
arguments: callParams,
workspaceId: context.workspaceId,
workflowId: context.workflowId,
}),

View File

@@ -426,6 +426,7 @@ export const auth = betterAuth({
scopes: [
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile',
'https://www.googleapis.com/auth/drive.readonly',
'https://www.googleapis.com/auth/drive.file',
],
prompt: 'consent',
@@ -440,6 +441,7 @@ export const auth = betterAuth({
scopes: [
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile',
'https://www.googleapis.com/auth/drive.readonly',
'https://www.googleapis.com/auth/drive.file',
],
prompt: 'consent',
@@ -454,6 +456,7 @@ export const auth = betterAuth({
scopes: [
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile',
'https://www.googleapis.com/auth/drive.readonly',
'https://www.googleapis.com/auth/drive.file',
],
prompt: 'consent',

View File

@@ -220,6 +220,7 @@ export async function getSimplifiedBillingSummary(
metadata: any
stripeSubscriptionId: string | null
periodEnd: Date | string | null
cancelAtPeriodEnd?: boolean
// Usage details
usage: {
current: number
@@ -318,6 +319,7 @@ export async function getSimplifiedBillingSummary(
metadata: subscription.metadata || null,
stripeSubscriptionId: subscription.stripeSubscriptionId || null,
periodEnd: subscription.periodEnd || null,
cancelAtPeriodEnd: subscription.cancelAtPeriodEnd || undefined,
// Usage details
usage: {
current: usageData.currentUsage,
@@ -393,6 +395,7 @@ export async function getSimplifiedBillingSummary(
metadata: subscription?.metadata || null,
stripeSubscriptionId: subscription?.stripeSubscriptionId || null,
periodEnd: subscription?.periodEnd || null,
cancelAtPeriodEnd: subscription?.cancelAtPeriodEnd || undefined,
// Usage details
usage: {
current: currentUsage,
@@ -450,5 +453,14 @@ function getDefaultBillingSummary(type: 'individual' | 'organization') {
lastPeriodCost: 0,
daysRemaining: 0,
},
...(type === 'organization' && {
organizationData: {
seatCount: 0,
memberCount: 0,
totalBasePrice: 0,
totalCurrentUsage: 0,
totalOverage: 0,
},
}),
}
}

View File

@@ -124,7 +124,10 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
providerId: 'google-drive',
icon: (props) => GoogleDriveIcon(props),
baseProviderIcon: (props) => GoogleIcon(props),
scopes: ['https://www.googleapis.com/auth/drive.file'],
scopes: [
'https://www.googleapis.com/auth/drive.readonly',
'https://www.googleapis.com/auth/drive.file',
],
},
'google-docs': {
id: 'google-docs',
@@ -133,7 +136,10 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
providerId: 'google-docs',
icon: (props) => GoogleDocsIcon(props),
baseProviderIcon: (props) => GoogleIcon(props),
scopes: ['https://www.googleapis.com/auth/drive.file'],
scopes: [
'https://www.googleapis.com/auth/drive.readonly',
'https://www.googleapis.com/auth/drive.file',
],
},
'google-sheets': {
id: 'google-sheets',
@@ -142,7 +148,10 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
providerId: 'google-sheets',
icon: (props) => GoogleSheetsIcon(props),
baseProviderIcon: (props) => GoogleIcon(props),
scopes: ['https://www.googleapis.com/auth/drive.file'],
scopes: [
'https://www.googleapis.com/auth/drive.readonly',
'https://www.googleapis.com/auth/drive.file',
],
},
'google-forms': {
id: 'google-forms',

View File

@@ -92,9 +92,34 @@ export async function uploadFile(
return uploadToS3(file, fileName, contentType, configOrSize)
}
throw new Error(
'No storage provider configured. Set Azure credentials (AZURE_CONNECTION_STRING or AZURE_ACCOUNT_NAME + AZURE_ACCOUNT_KEY) or configure AWS credentials for S3.'
)
logger.info(`Uploading file to local storage: ${fileName}`)
const { writeFile } = await import('fs/promises')
const { join } = await import('path')
const { v4: uuidv4 } = await import('uuid')
const { UPLOAD_DIR_SERVER } = await import('@/lib/uploads/setup.server')
const safeFileName = fileName.replace(/[^a-zA-Z0-9.-]/g, '_').replace(/\.\./g, '')
const uniqueKey = `${uuidv4()}-${safeFileName}`
const filePath = join(UPLOAD_DIR_SERVER, uniqueKey)
try {
await writeFile(filePath, file)
} catch (error) {
logger.error(`Failed to write file to local storage: ${fileName}`, error)
throw new Error(
`Failed to write file to local storage: ${error instanceof Error ? error.message : 'Unknown error'}`
)
}
const fileSize = typeof configOrSize === 'number' ? configOrSize : size || file.length
return {
path: `/api/files/serve/${uniqueKey}`,
key: uniqueKey,
name: fileName,
size: fileSize,
type: contentType,
}
}
/**
@@ -144,9 +169,28 @@ export async function downloadFile(
return downloadFromS3(key)
}
throw new Error(
'No storage provider configured. Set Azure credentials (AZURE_CONNECTION_STRING or AZURE_ACCOUNT_NAME + AZURE_ACCOUNT_KEY) or configure AWS credentials for S3.'
)
logger.info(`Downloading file from local storage: ${key}`)
const { readFile } = await import('fs/promises')
const { join, resolve, sep } = await import('path')
const { UPLOAD_DIR_SERVER } = await import('@/lib/uploads/setup.server')
const safeKey = key.replace(/\.\./g, '').replace(/[/\\]/g, '')
const filePath = join(UPLOAD_DIR_SERVER, safeKey)
const resolvedPath = resolve(filePath)
const allowedDir = resolve(UPLOAD_DIR_SERVER)
if (!resolvedPath.startsWith(allowedDir + sep) && resolvedPath !== allowedDir) {
throw new Error('Invalid file path')
}
try {
return await readFile(filePath)
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
throw new Error(`File not found: ${key}`)
}
throw error
}
}
/**
@@ -166,9 +210,29 @@ export async function deleteFile(key: string): Promise<void> {
return deleteFromS3(key)
}
throw new Error(
'No storage provider configured. Set Azure credentials (AZURE_CONNECTION_STRING or AZURE_ACCOUNT_NAME + AZURE_ACCOUNT_KEY) or configure AWS credentials for S3.'
)
logger.info(`Deleting file from local storage: ${key}`)
const { unlink } = await import('fs/promises')
const { join, resolve, sep } = await import('path')
const { UPLOAD_DIR_SERVER } = await import('@/lib/uploads/setup.server')
const safeKey = key.replace(/\.\./g, '').replace(/[/\\]/g, '')
const filePath = join(UPLOAD_DIR_SERVER, safeKey)
const resolvedPath = resolve(filePath)
const allowedDir = resolve(UPLOAD_DIR_SERVER)
if (!resolvedPath.startsWith(allowedDir + sep) && resolvedPath !== allowedDir) {
throw new Error('Invalid file path')
}
try {
await unlink(filePath)
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
logger.warn(`File not found during deletion: ${key}`)
return
}
throw error
}
}
/**
@@ -190,9 +254,8 @@ export async function getPresignedUrl(key: string, expiresIn = 3600): Promise<st
return getS3PresignedUrl(key, expiresIn)
}
throw new Error(
'No storage provider configured. Set Azure credentials (AZURE_CONNECTION_STRING or AZURE_ACCOUNT_NAME + AZURE_ACCOUNT_KEY) or configure AWS credentials for S3.'
)
logger.info(`Generating serve path for local storage: ${key}`)
return `/api/files/serve/${encodeURIComponent(key)}`
}
/**

View File

@@ -1,13 +1,15 @@
import {
db,
workflow,
workflowBlocks,
workflowDeploymentVersion,
workflowEdges,
workflowSubflows,
} from '@sim/db'
import type { InferSelectModel } from 'drizzle-orm'
import { and, desc, eq } from 'drizzle-orm'
import { and, desc, eq, sql } from 'drizzle-orm'
import type { Edge } from 'reactflow'
import { v4 as uuidv4 } from 'uuid'
import { createLogger } from '@/lib/logs/console/logger'
import { sanitizeAgentToolsInBlocks } from '@/lib/workflows/validation'
import type { BlockState, Loop, Parallel, WorkflowState } from '@/stores/workflows/workflow/types'
@@ -356,3 +358,131 @@ export async function migrateWorkflowToNormalizedTables(
}
}
}
/**
* Deploy a workflow by creating a new deployment version
*/
export async function deployWorkflow(params: {
workflowId: string
deployedBy: string // User ID of the person deploying
pinnedApiKeyId?: string
includeDeployedState?: boolean
workflowName?: string
}): Promise<{
success: boolean
version?: number
deployedAt?: Date
currentState?: any
error?: string
}> {
const {
workflowId,
deployedBy,
pinnedApiKeyId,
includeDeployedState = false,
workflowName,
} = params
try {
const normalizedData = await loadWorkflowFromNormalizedTables(workflowId)
if (!normalizedData) {
return { success: false, error: 'Failed to load workflow state' }
}
const currentState = {
blocks: normalizedData.blocks,
edges: normalizedData.edges,
loops: normalizedData.loops,
parallels: normalizedData.parallels,
lastSaved: Date.now(),
}
const now = new Date()
const deployedVersion = await db.transaction(async (tx) => {
// Get next version number
const [{ maxVersion }] = await tx
.select({ maxVersion: sql`COALESCE(MAX("version"), 0)` })
.from(workflowDeploymentVersion)
.where(eq(workflowDeploymentVersion.workflowId, workflowId))
const nextVersion = Number(maxVersion) + 1
// Deactivate all existing versions
await tx
.update(workflowDeploymentVersion)
.set({ isActive: false })
.where(eq(workflowDeploymentVersion.workflowId, workflowId))
// Create new deployment version
await tx.insert(workflowDeploymentVersion).values({
id: uuidv4(),
workflowId,
version: nextVersion,
state: currentState,
isActive: true,
createdBy: deployedBy,
createdAt: now,
})
// Update workflow to deployed
const updateData: Record<string, unknown> = {
isDeployed: true,
deployedAt: now,
}
if (includeDeployedState) {
updateData.deployedState = currentState
}
if (pinnedApiKeyId) {
updateData.pinnedApiKeyId = pinnedApiKeyId
}
await tx.update(workflow).set(updateData).where(eq(workflow.id, workflowId))
return nextVersion
})
logger.info(`Deployed workflow ${workflowId} as v${deployedVersion}`)
// Track deployment telemetry if workflow name is provided
if (workflowName) {
try {
const { trackPlatformEvent } = await import('@/lib/telemetry/tracer')
const blockTypeCounts: Record<string, number> = {}
for (const block of Object.values(currentState.blocks)) {
const blockType = (block as any).type || 'unknown'
blockTypeCounts[blockType] = (blockTypeCounts[blockType] || 0) + 1
}
trackPlatformEvent('platform.workflow.deployed', {
'workflow.id': workflowId,
'workflow.name': workflowName,
'workflow.blocks_count': Object.keys(currentState.blocks).length,
'workflow.edges_count': currentState.edges.length,
'workflow.loops_count': Object.keys(currentState.loops).length,
'workflow.parallels_count': Object.keys(currentState.parallels).length,
'workflow.block_types': JSON.stringify(blockTypeCounts),
'deployment.version': deployedVersion,
})
} catch (telemetryError) {
logger.warn(`Failed to track deployment telemetry for ${workflowId}`, telemetryError)
}
}
return {
success: true,
version: deployedVersion,
deployedAt: now,
currentState,
}
} catch (error) {
logger.error(`Error deploying workflow ${workflowId}:`, error)
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
}
}
}

View File

@@ -16,7 +16,13 @@ export interface StreamingConfig {
export interface StreamingResponseOptions {
requestId: string
workflow: { id: string; userId: string; workspaceId?: string | null; isDeployed?: boolean }
workflow: {
id: string
userId: string
workspaceId?: string | null
isDeployed?: boolean
variables?: Record<string, any>
}
input: any
executingUserId: string
streamConfig: StreamingConfig

View File

@@ -991,10 +991,10 @@ export function prepareToolExecution(
toolParams: Record<string, any>
executionParams: Record<string, any>
} {
// Only merge actual tool parameters for logging
// User-provided params take precedence over LLM-generated params
const toolParams = {
...tool.params,
...llmArgs,
...tool.params,
}
// Add system parameters for execution

View File

@@ -29,6 +29,7 @@ export interface SubscriptionData {
metadata: any | null
stripeSubscriptionId: string | null
periodEnd: Date | null
cancelAtPeriodEnd?: boolean
usage: UsageData
billingBlocked?: boolean
}

View File

@@ -23,19 +23,27 @@ export const clayPopulateTool: ToolConfig<ClayPopulateParams, ClayPopulateRespon
},
authToken: {
type: 'string',
required: true,
required: false,
visibility: 'user-only',
description: 'Auth token for Clay webhook authentication',
description:
'Optional auth token for Clay webhook authentication (most webhooks do not require this)',
},
},
request: {
url: (params: ClayPopulateParams) => params.webhookURL,
method: 'POST',
headers: (params: ClayPopulateParams) => ({
'Content-Type': 'application/json',
Authorization: `Bearer ${params.authToken}`,
}),
headers: (params: ClayPopulateParams) => {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
}
if (params.authToken && params.authToken.trim() !== '') {
headers['x-clay-webhook-auth'] = params.authToken
}
return headers
},
body: (params: ClayPopulateParams) => ({
data: params.data,
}),
@@ -43,27 +51,52 @@ export const clayPopulateTool: ToolConfig<ClayPopulateParams, ClayPopulateRespon
transformResponse: async (response: Response) => {
const contentType = response.headers.get('content-type')
let data
const timestamp = new Date().toISOString()
// Extract response headers
const headers: Record<string, string> = {}
response.headers.forEach((value, key) => {
headers[key] = value
})
// Parse response body
let responseData
if (contentType?.includes('application/json')) {
data = await response.json()
responseData = await response.json()
} else {
data = await response.text()
responseData = await response.text()
}
return {
success: true,
output: {
data: contentType?.includes('application/json') ? data : { message: data },
data: contentType?.includes('application/json') ? responseData : { message: responseData },
metadata: {
status: response.status,
statusText: response.statusText,
headers: headers,
timestamp: timestamp,
contentType: contentType || 'unknown',
},
},
}
},
outputs: {
success: { type: 'boolean', description: 'Operation success status' },
output: {
data: {
type: 'json',
description: 'Clay populate operation results including response data from Clay webhook',
description: 'Response data from Clay webhook',
},
metadata: {
type: 'object',
description: 'Webhook response metadata',
properties: {
status: { type: 'number', description: 'HTTP status code' },
statusText: { type: 'string', description: 'HTTP status text' },
headers: { type: 'object', description: 'Response headers from Clay' },
timestamp: { type: 'string', description: 'ISO timestamp when webhook was received' },
contentType: { type: 'string', description: 'Content type of the response' },
},
},
},
}

View File

@@ -9,5 +9,12 @@ export interface ClayPopulateParams {
export interface ClayPopulateResponse extends ToolResponse {
output: {
data: any
metadata: {
status: number
statusText: string
headers: Record<string, string>
timestamp: string
contentType: string
}
}
}

View File

@@ -51,6 +51,16 @@ export const elevenLabsTtsTool: ToolConfig<ElevenLabsTtsParams, ElevenLabsTtsRes
transformResponse: async (response: Response) => {
const data = await response.json()
if (!response.ok || data.error) {
return {
success: false,
error: data.error || 'Unknown error occurred',
output: {
audioUrl: '',
},
}
}
return {
success: true,
output: {

View File

@@ -13,7 +13,10 @@ export const createTool: ToolConfig<GoogleDocsToolParams, GoogleDocsCreateRespon
oauth: {
required: true,
provider: 'google-docs',
additionalScopes: ['https://www.googleapis.com/auth/drive.file'],
additionalScopes: [
'https://www.googleapis.com/auth/drive.readonly',
'https://www.googleapis.com/auth/drive.file',
],
},
params: {

View File

@@ -11,7 +11,10 @@ export const readTool: ToolConfig<GoogleDocsToolParams, GoogleDocsReadResponse>
oauth: {
required: true,
provider: 'google-docs',
additionalScopes: ['https://www.googleapis.com/auth/drive.file'],
additionalScopes: [
'https://www.googleapis.com/auth/drive.readonly',
'https://www.googleapis.com/auth/drive.file',
],
},
params: {

View File

@@ -9,7 +9,10 @@ export const writeTool: ToolConfig<GoogleDocsToolParams, GoogleDocsWriteResponse
oauth: {
required: true,
provider: 'google-docs',
additionalScopes: ['https://www.googleapis.com/auth/drive.file'],
additionalScopes: [
'https://www.googleapis.com/auth/drive.readonly',
'https://www.googleapis.com/auth/drive.file',
],
},
params: {
accessToken: {

View File

@@ -10,7 +10,10 @@ export const createFolderTool: ToolConfig<GoogleDriveToolParams, GoogleDriveUplo
oauth: {
required: true,
provider: 'google-drive',
additionalScopes: ['https://www.googleapis.com/auth/drive.file'],
additionalScopes: [
'https://www.googleapis.com/auth/drive.readonly',
'https://www.googleapis.com/auth/drive.file',
],
},
params: {

View File

@@ -18,7 +18,10 @@ export const getContentTool: ToolConfig<GoogleDriveToolParams, GoogleDriveGetCon
oauth: {
required: true,
provider: 'google-drive',
additionalScopes: ['https://www.googleapis.com/auth/drive.file'],
additionalScopes: [
'https://www.googleapis.com/auth/drive.readonly',
'https://www.googleapis.com/auth/drive.file',
],
},
params: {

View File

@@ -10,7 +10,10 @@ export const listTool: ToolConfig<GoogleDriveToolParams, GoogleDriveListResponse
oauth: {
required: true,
provider: 'google-drive',
additionalScopes: ['https://www.googleapis.com/auth/drive.file'],
additionalScopes: [
'https://www.googleapis.com/auth/drive.readonly',
'https://www.googleapis.com/auth/drive.file',
],
},
params: {

View File

@@ -18,7 +18,10 @@ export const uploadTool: ToolConfig<GoogleDriveToolParams, GoogleDriveUploadResp
oauth: {
required: true,
provider: 'google-drive',
additionalScopes: ['https://www.googleapis.com/auth/drive.file'],
additionalScopes: [
'https://www.googleapis.com/auth/drive.readonly',
'https://www.googleapis.com/auth/drive.file',
],
},
params: {

View File

@@ -31,7 +31,7 @@ const createMockExecutionContext = (overrides?: Partial<ExecutionContext>): Exec
})
describe('Tools Registry', () => {
it.concurrent('should include all expected built-in tools', () => {
it('should include all expected built-in tools', () => {
expect(Object.keys(tools).length).toBeGreaterThan(10)
// Check for existence of some core tools
@@ -45,7 +45,7 @@ describe('Tools Registry', () => {
expect(tools.serper_search).toBeDefined()
})
it.concurrent('getTool should return the correct tool by ID', () => {
it('getTool should return the correct tool by ID', () => {
const httpTool = getTool('http_request')
expect(httpTool).toBeDefined()
expect(httpTool?.id).toBe('http_request')
@@ -57,7 +57,7 @@ describe('Tools Registry', () => {
expect(gmailTool?.name).toBe('Gmail Read')
})
it.concurrent('getTool should return undefined for non-existent tool', () => {
it('getTool should return undefined for non-existent tool', () => {
const nonExistentTool = getTool('non_existent_tool')
expect(nonExistentTool).toBeUndefined()
})
@@ -133,7 +133,7 @@ describe('Custom Tools', () => {
vi.resetAllMocks()
})
it.concurrent('should get custom tool by ID', () => {
it('should get custom tool by ID', () => {
const customTool = getTool('custom_custom-tool-123')
expect(customTool).toBeDefined()
expect(customTool?.name).toBe('Custom Weather Tool')
@@ -142,7 +142,7 @@ describe('Custom Tools', () => {
expect(customTool?.params.location.required).toBe(true)
})
it.concurrent('should handle non-existent custom tool', () => {
it('should handle non-existent custom tool', () => {
const nonExistentTool = getTool('custom_non-existent')
expect(nonExistentTool).toBeUndefined()
})
@@ -193,7 +193,7 @@ describe('executeTool Function', () => {
cleanupEnvVars()
})
it.concurrent('should execute a tool successfully', async () => {
it('should execute a tool successfully', async () => {
const result = await executeTool(
'http_request',
{
@@ -241,7 +241,7 @@ describe('executeTool Function', () => {
)
})
it.concurrent('should handle non-existent tool', async () => {
it('should handle non-existent tool', async () => {
// Create the mock with a matching implementation
vi.spyOn(console, 'error').mockImplementation(() => {})
@@ -254,7 +254,7 @@ describe('executeTool Function', () => {
vi.restoreAllMocks()
})
it.concurrent('should handle errors from tools', async () => {
it('should handle errors from tools', async () => {
// Mock a failed response
global.fetch = Object.assign(
vi.fn().mockImplementation(async () => {
@@ -284,7 +284,7 @@ describe('executeTool Function', () => {
expect(result.timing).toBeDefined()
})
it.concurrent('should add timing information to results', async () => {
it('should add timing information to results', async () => {
const result = await executeTool(
'http_request',
{
@@ -315,58 +315,59 @@ describe('Automatic Internal Route Detection', () => {
cleanupEnvVars()
})
it.concurrent(
'should detect internal routes (URLs starting with /api/) and call them directly',
async () => {
// Mock a tool with an internal route
const mockTool = {
id: 'test_internal_tool',
name: 'Test Internal Tool',
description: 'A test tool with internal route',
version: '1.0.0',
params: {},
request: {
url: '/api/test/endpoint',
method: 'POST',
headers: () => ({ 'Content-Type': 'application/json' }),
},
transformResponse: vi.fn().mockResolvedValue({
success: true,
output: { result: 'Internal route success' },
}),
}
// Mock the tool registry to include our test tool
const originalTools = { ...tools }
;(tools as any).test_internal_tool = mockTool
// Mock fetch for the internal API call
global.fetch = Object.assign(
vi.fn().mockImplementation(async (url) => {
// Should call the internal API directly, not the proxy
expect(url).toBe('http://localhost:3000/api/test/endpoint')
return {
ok: true,
status: 200,
json: () => Promise.resolve({ success: true, data: 'test' }),
clone: vi.fn().mockReturnThis(),
}
}),
{ preconnect: vi.fn() }
) as typeof fetch
const result = await executeTool('test_internal_tool', {}, false)
expect(result.success).toBe(true)
expect(result.output.result).toBe('Internal route success')
expect(mockTool.transformResponse).toHaveBeenCalled()
// Restore original tools
Object.assign(tools, originalTools)
it('should detect internal routes (URLs starting with /api/) and call them directly', async () => {
// Mock a tool with an internal route
const mockTool = {
id: 'test_internal_tool',
name: 'Test Internal Tool',
description: 'A test tool with internal route',
version: '1.0.0',
params: {},
request: {
url: '/api/test/endpoint',
method: 'POST',
headers: () => ({ 'Content-Type': 'application/json' }),
},
transformResponse: vi.fn().mockResolvedValue({
success: true,
output: { result: 'Internal route success' },
}),
}
)
it.concurrent('should detect external routes (full URLs) and use proxy', async () => {
// Mock the tool registry to include our test tool
const originalTools = { ...tools }
;(tools as any).test_internal_tool = mockTool
// Mock fetch for the internal API call
global.fetch = Object.assign(
vi.fn().mockImplementation(async (url) => {
// Should call the internal API directly, not the proxy
expect(url).toBe('http://localhost:3000/api/test/endpoint')
const responseData = { success: true, data: 'test' }
return {
ok: true,
status: 200,
statusText: 'OK',
headers: new Headers(),
json: () => Promise.resolve(responseData),
text: () => Promise.resolve(JSON.stringify(responseData)),
clone: vi.fn().mockReturnThis(),
}
}),
{ preconnect: vi.fn() }
) as typeof fetch
const result = await executeTool('test_internal_tool', {}, false)
expect(result.success).toBe(true)
expect(result.output.result).toBe('Internal route success')
expect(mockTool.transformResponse).toHaveBeenCalled()
// Restore original tools
Object.assign(tools, originalTools)
})
it('should detect external routes (full URLs) and use proxy', async () => {
// Mock a tool with an external route
const mockTool = {
id: 'test_external_tool',
@@ -390,14 +391,17 @@ describe('Automatic Internal Route Detection', () => {
vi.fn().mockImplementation(async (url) => {
// Should call the proxy, not the external API directly
expect(url).toBe('http://localhost:3000/api/proxy')
const responseData = {
success: true,
output: { result: 'External route via proxy' },
}
return {
ok: true,
status: 200,
json: () =>
Promise.resolve({
success: true,
output: { result: 'External route via proxy' },
}),
statusText: 'OK',
headers: new Headers(),
json: () => Promise.resolve(responseData),
text: () => Promise.resolve(JSON.stringify(responseData)),
}
}),
{ preconnect: vi.fn() }
@@ -412,7 +416,7 @@ describe('Automatic Internal Route Detection', () => {
Object.assign(tools, originalTools)
})
it.concurrent('should handle dynamic URLs that resolve to internal routes', async () => {
it('should handle dynamic URLs that resolve to internal routes', async () => {
// Mock a tool with a dynamic URL function that returns internal route
const mockTool = {
id: 'test_dynamic_internal',
@@ -442,10 +446,14 @@ describe('Automatic Internal Route Detection', () => {
vi.fn().mockImplementation(async (url) => {
// Should call the internal API directly with the resolved dynamic URL
expect(url).toBe('http://localhost:3000/api/resources/123')
const responseData = { success: true, data: 'test' }
return {
ok: true,
status: 200,
json: () => Promise.resolve({ success: true, data: 'test' }),
statusText: 'OK',
headers: new Headers(),
json: () => Promise.resolve(responseData),
text: () => Promise.resolve(JSON.stringify(responseData)),
clone: vi.fn().mockReturnThis(),
}
}),
@@ -461,7 +469,7 @@ describe('Automatic Internal Route Detection', () => {
Object.assign(tools, originalTools)
})
it.concurrent('should handle dynamic URLs that resolve to external routes', async () => {
it('should handle dynamic URLs that resolve to external routes', async () => {
// Mock a tool with a dynamic URL function that returns external route
const mockTool = {
id: 'test_dynamic_external',
@@ -487,14 +495,17 @@ describe('Automatic Internal Route Detection', () => {
vi.fn().mockImplementation(async (url) => {
// Should call the proxy, not the external API directly
expect(url).toBe('http://localhost:3000/api/proxy')
const responseData = {
success: true,
output: { result: 'Dynamic external route via proxy' },
}
return {
ok: true,
status: 200,
json: () =>
Promise.resolve({
success: true,
output: { result: 'Dynamic external route via proxy' },
}),
statusText: 'OK',
headers: new Headers(),
json: () => Promise.resolve(responseData),
text: () => Promise.resolve(JSON.stringify(responseData)),
}
}),
{ preconnect: vi.fn() }
@@ -509,51 +520,48 @@ describe('Automatic Internal Route Detection', () => {
Object.assign(tools, originalTools)
})
it.concurrent(
'should respect skipProxy parameter and call internal routes directly even for external URLs',
async () => {
const mockTool = {
id: 'test_skip_proxy',
name: 'Test Skip Proxy Tool',
description: 'A test tool to verify skipProxy behavior',
version: '1.0.0',
params: {},
request: {
url: 'https://api.example.com/endpoint',
method: 'GET',
headers: () => ({ 'Content-Type': 'application/json' }),
},
transformResponse: vi.fn().mockResolvedValue({
success: true,
output: { result: 'Skipped proxy, called directly' },
}),
}
const originalTools = { ...tools }
;(tools as any).test_skip_proxy = mockTool
global.fetch = Object.assign(
vi.fn().mockImplementation(async (url) => {
expect(url).toBe('https://api.example.com/endpoint')
return {
ok: true,
status: 200,
json: () => Promise.resolve({ success: true, data: 'test' }),
clone: vi.fn().mockReturnThis(),
}
}),
{ preconnect: vi.fn() }
) as typeof fetch
const result = await executeTool('test_skip_proxy', {}, true) // skipProxy = true
expect(result.success).toBe(true)
expect(result.output.result).toBe('Skipped proxy, called directly')
expect(mockTool.transformResponse).toHaveBeenCalled()
Object.assign(tools, originalTools)
it('should respect skipProxy parameter and call internal routes directly even for external URLs', async () => {
const mockTool = {
id: 'test_skip_proxy',
name: 'Test Skip Proxy Tool',
description: 'A test tool to verify skipProxy behavior',
version: '1.0.0',
params: {},
request: {
url: 'https://api.example.com/endpoint',
method: 'GET',
headers: () => ({ 'Content-Type': 'application/json' }),
},
transformResponse: vi.fn().mockResolvedValue({
success: true,
output: { result: 'Skipped proxy, called directly' },
}),
}
)
const originalTools = { ...tools }
;(tools as any).test_skip_proxy = mockTool
global.fetch = Object.assign(
vi.fn().mockImplementation(async (url) => {
expect(url).toBe('https://api.example.com/endpoint')
return {
ok: true,
status: 200,
json: () => Promise.resolve({ success: true, data: 'test' }),
clone: vi.fn().mockReturnThis(),
}
}),
{ preconnect: vi.fn() }
) as typeof fetch
const result = await executeTool('test_skip_proxy', {}, true) // skipProxy = true
expect(result.success).toBe(true)
expect(result.output.result).toBe('Skipped proxy, called directly')
expect(mockTool.transformResponse).toHaveBeenCalled()
Object.assign(tools, originalTools)
})
})
describe('Centralized Error Handling', () => {
@@ -600,7 +608,7 @@ describe('Centralized Error Handling', () => {
expect(result.error).toBe(expectedError)
}
it.concurrent('should extract GraphQL error format (Linear API)', async () => {
it('should extract GraphQL error format (Linear API)', async () => {
await testErrorFormat(
'GraphQL',
{ errors: [{ message: 'Invalid query field' }] },
@@ -608,7 +616,7 @@ describe('Centralized Error Handling', () => {
)
})
it.concurrent('should extract X/Twitter API error format', async () => {
it('should extract X/Twitter API error format', async () => {
await testErrorFormat(
'X/Twitter',
{ errors: [{ detail: 'Rate limit exceeded' }] },
@@ -616,15 +624,15 @@ describe('Centralized Error Handling', () => {
)
})
it.concurrent('should extract Hunter API error format', async () => {
it('should extract Hunter API error format', async () => {
await testErrorFormat('Hunter', { errors: [{ details: 'Invalid API key' }] }, 'Invalid API key')
})
it.concurrent('should extract direct errors array (string)', async () => {
it('should extract direct errors array (string)', async () => {
await testErrorFormat('Direct string array', { errors: ['Network timeout'] }, 'Network timeout')
})
it.concurrent('should extract direct errors array (object)', async () => {
it('should extract direct errors array (object)', async () => {
await testErrorFormat(
'Direct object array',
{ errors: [{ message: 'Validation failed' }] },
@@ -632,11 +640,11 @@ describe('Centralized Error Handling', () => {
)
})
it.concurrent('should extract OAuth error description', async () => {
it('should extract OAuth error description', async () => {
await testErrorFormat('OAuth', { error_description: 'Invalid grant' }, 'Invalid grant')
})
it.concurrent('should extract SOAP fault error', async () => {
it('should extract SOAP fault error', async () => {
await testErrorFormat(
'SOAP fault',
{ fault: { faultstring: 'Server unavailable' } },
@@ -644,7 +652,7 @@ describe('Centralized Error Handling', () => {
)
})
it.concurrent('should extract simple SOAP faultstring', async () => {
it('should extract simple SOAP faultstring', async () => {
await testErrorFormat(
'Simple SOAP',
{ faultstring: 'Authentication failed' },
@@ -652,11 +660,11 @@ describe('Centralized Error Handling', () => {
)
})
it.concurrent('should extract Notion/Discord message format', async () => {
it('should extract Notion/Discord message format', async () => {
await testErrorFormat('Notion/Discord', { message: 'Page not found' }, 'Page not found')
})
it.concurrent('should extract Airtable error object format', async () => {
it('should extract Airtable error object format', async () => {
await testErrorFormat(
'Airtable',
{ error: { message: 'Invalid table ID' } },
@@ -664,7 +672,7 @@ describe('Centralized Error Handling', () => {
)
})
it.concurrent('should extract simple error string format', async () => {
it('should extract simple error string format', async () => {
await testErrorFormat(
'Simple string',
{ error: 'Simple error message' },
@@ -672,7 +680,7 @@ describe('Centralized Error Handling', () => {
)
})
it.concurrent('should fall back to HTTP status when JSON parsing fails', async () => {
it('should fall back to HTTP status when JSON parsing fails', async () => {
global.fetch = Object.assign(
vi.fn().mockImplementation(async () => ({
ok: false,
@@ -701,7 +709,7 @@ describe('Centralized Error Handling', () => {
expect(result.error).toBe('Failed to parse response from function_execute: Error: Invalid JSON')
})
it.concurrent('should handle complex nested error objects', async () => {
it('should handle complex nested error objects', async () => {
await testErrorFormat(
'Complex nested',
{ error: { code: 400, message: 'Complex validation error', details: 'Field X is invalid' } },
@@ -709,7 +717,7 @@ describe('Centralized Error Handling', () => {
)
})
it.concurrent('should handle error arrays with multiple entries (take first)', async () => {
it('should handle error arrays with multiple entries (take first)', async () => {
await testErrorFormat(
'Multiple errors',
{ errors: [{ message: 'First error' }, { message: 'Second error' }] },
@@ -717,7 +725,7 @@ describe('Centralized Error Handling', () => {
)
})
it.concurrent('should stringify complex error objects when no message found', async () => {
it('should stringify complex error objects when no message found', async () => {
const complexError = { code: 500, type: 'ServerError', context: { requestId: '123' } }
await testErrorFormat(
'Complex object stringify',
@@ -742,7 +750,7 @@ describe('MCP Tool Execution', () => {
cleanupEnvVars()
})
it.concurrent('should execute MCP tool with valid tool ID', async () => {
it('should execute MCP tool with valid tool ID', async () => {
global.fetch = Object.assign(
vi.fn().mockImplementation(async (url, options) => {
expect(url).toBe('http://localhost:3000/api/mcp/tools/execute')
@@ -787,7 +795,7 @@ describe('MCP Tool Execution', () => {
expect(result.timing).toBeDefined()
})
it.concurrent('should handle MCP tool ID parsing correctly', async () => {
it('should handle MCP tool ID parsing correctly', async () => {
global.fetch = Object.assign(
vi.fn().mockImplementation(async (url, options) => {
const body = JSON.parse(options?.body as string)
@@ -818,7 +826,7 @@ describe('MCP Tool Execution', () => {
)
})
it.concurrent('should handle MCP block arguments format', async () => {
it('should handle MCP block arguments format', async () => {
global.fetch = Object.assign(
vi.fn().mockImplementation(async (url, options) => {
const body = JSON.parse(options?.body as string)
@@ -852,7 +860,7 @@ describe('MCP Tool Execution', () => {
)
})
it.concurrent('should handle agent block MCP arguments format', async () => {
it('should handle agent block MCP arguments format', async () => {
global.fetch = Object.assign(
vi.fn().mockImplementation(async (url, options) => {
const body = JSON.parse(options?.body as string)
@@ -890,7 +898,7 @@ describe('MCP Tool Execution', () => {
)
})
it.concurrent('should handle MCP tool execution errors', async () => {
it('should handle MCP tool execution errors', async () => {
global.fetch = Object.assign(
vi.fn().mockImplementation(async () => ({
ok: false,
@@ -920,14 +928,14 @@ describe('MCP Tool Execution', () => {
expect(result.timing).toBeDefined()
})
it.concurrent('should require workspaceId for MCP tools', async () => {
it('should require workspaceId for MCP tools', async () => {
const result = await executeTool('mcp-123-test_tool', { param: 'value' })
expect(result.success).toBe(false)
expect(result.error).toContain('Missing workspaceId in execution context for MCP tool')
})
it.concurrent('should handle invalid MCP tool ID format', async () => {
it('should handle invalid MCP tool ID format', async () => {
const mockContext6 = createMockExecutionContext()
const result = await executeTool(
@@ -942,7 +950,7 @@ describe('MCP Tool Execution', () => {
expect(result.error).toContain('Tool not found')
})
it.concurrent('should handle MCP API network errors', async () => {
it('should handle MCP API network errors', async () => {
global.fetch = Object.assign(vi.fn().mockRejectedValue(new Error('Network error')), {
preconnect: vi.fn(),
}) as typeof fetch

View File

@@ -408,6 +408,38 @@ function isErrorResponse(
return { isError: false }
}
/**
* Add internal authentication token to headers if running on server
* @param headers - Headers object to modify
* @param isInternalRoute - Whether the target URL is an internal route
* @param requestId - Request ID for logging
* @param context - Context string for logging (e.g., toolId or 'proxy')
*/
async function addInternalAuthIfNeeded(
headers: Headers | Record<string, string>,
isInternalRoute: boolean,
requestId: string,
context: string
): Promise<void> {
if (typeof window === 'undefined') {
if (isInternalRoute) {
try {
const internalToken = await generateInternalToken()
if (headers instanceof Headers) {
headers.set('Authorization', `Bearer ${internalToken}`)
} else {
headers.Authorization = `Bearer ${internalToken}`
}
logger.info(`[${requestId}] Added internal auth token for ${context}`)
} catch (error) {
logger.error(`[${requestId}] Failed to generate internal token for ${context}:`, error)
}
} else {
logger.info(`[${requestId}] Skipping internal auth token for external URL: ${context}`)
}
}
}
/**
* Handle an internal/direct tool request
*/
@@ -448,19 +480,7 @@ async function handleInternalRequest(
}
const headers = new Headers(requestParams.headers)
if (typeof window === 'undefined') {
if (isInternalRoute) {
try {
const internalToken = await generateInternalToken()
headers.set('Authorization', `Bearer ${internalToken}`)
logger.info(`[${requestId}] Added internal auth token for ${toolId}`)
} catch (error) {
logger.error(`[${requestId}] Failed to generate internal token for ${toolId}:`, error)
}
} else {
logger.info(`[${requestId}] Skipping internal auth token for external URL: ${endpointUrl}`)
}
}
await addInternalAuthIfNeeded(headers, isInternalRoute, requestId, toolId)
// Prepare request options
const requestOptions = {
@@ -652,9 +672,12 @@ async function handleProxyRequest(
const proxyUrl = new URL('/api/proxy', baseUrl).toString()
try {
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
await addInternalAuthIfNeeded(headers, true, requestId, `proxy:${toolId}`)
const response = await fetch(proxyUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers,
body: JSON.stringify({ toolId, params, executionContext }),
})
@@ -669,9 +692,7 @@ async function handleProxyRequest(
let errorMessage = `HTTP error ${response.status}: ${response.statusText}`
try {
// Try to parse as JSON for more details
const errorJson = JSON.parse(errorText)
// Enhanced error extraction to match internal API patterns
errorMessage =
// Primary error patterns
errorJson.errors?.[0]?.message ||

View File

@@ -127,10 +127,23 @@ export const imageTool: ToolConfig = {
const proxyUrl = new URL('/api/proxy/image', baseUrl)
proxyUrl.searchParams.append('url', imageUrl)
const headers: Record<string, string> = {
Accept: 'image/*, */*',
}
if (typeof window === 'undefined') {
const { generateInternalToken } = await import('@/lib/auth/internal')
try {
const token = await generateInternalToken()
headers.Authorization = `Bearer ${token}`
logger.info('Added internal auth token for image proxy request')
} catch (error) {
logger.error('Failed to generate internal token for image proxy:', error)
}
}
const imageResponse = await fetch(proxyUrl.toString(), {
headers: {
Accept: 'image/*, */*',
},
headers,
cache: 'no-store',
})

View File

@@ -32,6 +32,12 @@ export const fetchPointsTool: ToolConfig<QdrantFetchParams, QdrantResponse> = {
visibility: 'user-only',
description: 'Array of point IDs to fetch',
},
fetch_return_data: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'Data to return from fetch',
},
with_payload: {
type: 'boolean',
required: false,
@@ -53,11 +59,38 @@ export const fetchPointsTool: ToolConfig<QdrantFetchParams, QdrantResponse> = {
'Content-Type': 'application/json',
...(params.apiKey ? { 'api-key': params.apiKey } : {}),
}),
body: (params) => ({
ids: params.ids,
with_payload: params.with_payload,
with_vector: params.with_vector,
}),
body: (params) => {
// Calculate with_payload and with_vector from fetch_return_data if provided
let withPayload = params.with_payload ?? false
let withVector = params.with_vector ?? false
if (params.fetch_return_data) {
switch (params.fetch_return_data) {
case 'payload_only':
withPayload = true
withVector = false
break
case 'vector_only':
withPayload = false
withVector = true
break
case 'both':
withPayload = true
withVector = true
break
case 'none':
withPayload = false
withVector = false
break
}
}
return {
ids: params.ids,
with_payload: withPayload,
with_vector: withVector,
}
},
},
transformResponse: async (response) => {

View File

@@ -44,6 +44,12 @@ export const searchVectorTool: ToolConfig<QdrantSearchParams, QdrantResponse> =
visibility: 'user-only',
description: 'Filter to apply to the search',
},
search_return_data: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'Data to return from search',
},
with_payload: {
type: 'boolean',
required: false,
@@ -66,13 +72,40 @@ export const searchVectorTool: ToolConfig<QdrantSearchParams, QdrantResponse> =
'Content-Type': 'application/json',
...(params.apiKey ? { 'api-key': params.apiKey } : {}),
}),
body: (params) => ({
query: params.vector,
limit: params.limit ? Number.parseInt(params.limit.toString()) : 10,
filter: params.filter,
with_payload: params.with_payload,
with_vector: params.with_vector,
}),
body: (params) => {
// Calculate with_payload and with_vector from search_return_data if provided
let withPayload = params.with_payload ?? false
let withVector = params.with_vector ?? false
if (params.search_return_data) {
switch (params.search_return_data) {
case 'payload_only':
withPayload = true
withVector = false
break
case 'vector_only':
withPayload = false
withVector = true
break
case 'both':
withPayload = true
withVector = true
break
case 'none':
withPayload = false
withVector = false
break
}
}
return {
query: params.vector,
limit: params.limit ? Number.parseInt(params.limit.toString()) : 10,
filter: params.filter,
with_payload: withPayload,
with_vector: withVector,
}
},
},
transformResponse: async (response) => {

View File

@@ -20,12 +20,14 @@ export interface QdrantSearchParams extends QdrantBaseParams {
vector: number[]
limit?: number
filter?: Record<string, any>
search_return_data?: string
with_payload?: boolean
with_vector?: boolean
}
export interface QdrantFetchParams extends QdrantBaseParams {
ids: string[]
fetch_return_data?: string
with_payload?: boolean
with_vector?: boolean
}

View File

@@ -648,9 +648,9 @@ export const chat = pgTable(
customizations: json('customizations').default('{}'), // For UI customization options
// Authentication options
authType: text('auth_type').notNull().default('public'), // 'public', 'password', 'email'
authType: text('auth_type').notNull().default('public'), // 'public', 'password', 'email', 'sso'
password: text('password'), // Stored hashed, populated when authType is 'password'
allowedEmails: json('allowed_emails').default('[]'), // Array of allowed emails or domains when authType is 'email'
allowedEmails: json('allowed_emails').default('[]'), // Array of allowed emails or domains when authType is 'email' or 'sso'
// Output configuration
outputConfigs: json('output_configs').default('[]'), // Array of {blockId, path} objects