mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-10 15:38:00 -05:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a02016e247 | ||
|
|
8620ab255a | ||
|
|
47ddfb639e | ||
|
|
5d48c2780c | ||
|
|
38614fad79 | ||
|
|
6f32aea96b | ||
|
|
98e98496e8 | ||
|
|
659b46fa2f | ||
|
|
fb3d6d4c88 | ||
|
|
ec2cc82b72 | ||
|
|
274d5e3afc | ||
|
|
c552bb9c5f | ||
|
|
ad7b791242 | ||
|
|
ce4893a53c | ||
|
|
7f1ff7fd86 | ||
|
|
517f1a91b6 |
@@ -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={{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
42
apps/docs/components/ui/copy-page-button.tsx
Normal file
42
apps/docs/components/ui/copy-page-button.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
@@ -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 | Sí | 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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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 | いいえ | レスポンスにベクトルを含める |
|
||||
|
||||
|
||||
@@ -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 响应元数据 |
|
||||
|
||||
## 注意事项
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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 | 否 | 在响应中包含向量 |
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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' }
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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
|
||||
|
||||
209
apps/sim/app/chat/components/auth/sso/sso-auth.tsx
Normal file
209
apps/sim/app/chat/components/auth/sso/sso-auth.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
|
||||
|
||||
@@ -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'
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -523,6 +523,7 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
|
||||
}}
|
||||
subscriptionData={{
|
||||
periodEnd: subscriptionData?.periodEnd || null,
|
||||
cancelAtPeriodEnd: subscriptionData?.cancelAtPeriodEnd,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' },
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)}`
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -29,6 +29,7 @@ export interface SubscriptionData {
|
||||
metadata: any | null
|
||||
stripeSubscriptionId: string | null
|
||||
periodEnd: Date | null
|
||||
cancelAtPeriodEnd?: boolean
|
||||
usage: UsageData
|
||||
billingBlocked?: boolean
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user