mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-11 07:58:06 -05:00
Compare commits
5 Commits
v0.5.29
...
fix/chat-t
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f67caf0798 | ||
|
|
0b853a7d95 | ||
|
|
4f31560a0e | ||
|
|
8b5027f2a6 | ||
|
|
c6c658a6e1 |
@@ -243,9 +243,6 @@ export async function generateMetadata(props: {
|
||||
const baseUrl = 'https://docs.sim.ai'
|
||||
const fullUrl = `${baseUrl}${page.url}`
|
||||
|
||||
const description = page.data.description || ''
|
||||
const ogImageUrl = `${baseUrl}/api/og?title=${encodeURIComponent(page.data.title)}&category=DOCUMENTATION${description ? `&description=${encodeURIComponent(description)}` : ''}`
|
||||
|
||||
return {
|
||||
title: page.data.title,
|
||||
description:
|
||||
@@ -275,23 +272,12 @@ export async function generateMetadata(props: {
|
||||
alternateLocale: ['en', 'es', 'fr', 'de', 'ja', 'zh']
|
||||
.filter((lang) => lang !== params.lang)
|
||||
.map((lang) => (lang === 'en' ? 'en_US' : `${lang}_${lang.toUpperCase()}`)),
|
||||
images: [
|
||||
{
|
||||
url: ogImageUrl,
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: page.data.title,
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
card: 'summary',
|
||||
title: page.data.title,
|
||||
description:
|
||||
page.data.description || 'Sim visual workflow builder for AI applications documentation',
|
||||
images: [ogImageUrl],
|
||||
creator: '@simdotai',
|
||||
site: '@simdotai',
|
||||
},
|
||||
robots: {
|
||||
index: true,
|
||||
|
||||
@@ -1,173 +0,0 @@
|
||||
import { ImageResponse } from 'next/og'
|
||||
import type { NextRequest } from 'next/server'
|
||||
|
||||
export const runtime = 'edge'
|
||||
|
||||
const TITLE_FONT_SIZE = {
|
||||
large: 64,
|
||||
medium: 56,
|
||||
small: 48,
|
||||
} as const
|
||||
|
||||
function getTitleFontSize(title: string): number {
|
||||
if (title.length > 45) return TITLE_FONT_SIZE.small
|
||||
if (title.length > 30) return TITLE_FONT_SIZE.medium
|
||||
return TITLE_FONT_SIZE.large
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a Google Font dynamically by fetching the CSS and extracting the font URL.
|
||||
*/
|
||||
async function loadGoogleFont(font: string, weights: string, text: string): Promise<ArrayBuffer> {
|
||||
const url = `https://fonts.googleapis.com/css2?family=${font}:wght@${weights}&text=${encodeURIComponent(text)}`
|
||||
const css = await (await fetch(url)).text()
|
||||
const resource = css.match(/src: url\((.+)\) format\('(opentype|truetype)'\)/)
|
||||
|
||||
if (resource) {
|
||||
const response = await fetch(resource[1])
|
||||
if (response.status === 200) {
|
||||
return await response.arrayBuffer()
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Failed to load font data')
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates dynamic Open Graph images for documentation pages.
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const title = searchParams.get('title') || 'Documentation'
|
||||
const category = searchParams.get('category') || 'DOCUMENTATION'
|
||||
const description = searchParams.get('description') || ''
|
||||
|
||||
const baseUrl = new URL(request.url).origin
|
||||
const backgroundImageUrl = `${baseUrl}/static/og-background.png`
|
||||
|
||||
const allText = `${title}${category}${description}docs.sim.ai`
|
||||
const fontData = await loadGoogleFont('Geist', '400;500;600', allText)
|
||||
|
||||
return new ImageResponse(
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: 'linear-gradient(315deg, #1e1e3f 0%, #1a1a2e 40%, #0f0f0f 100%)',
|
||||
position: 'relative',
|
||||
fontFamily: 'Geist',
|
||||
}}
|
||||
>
|
||||
{/* Background texture */}
|
||||
<img
|
||||
src={backgroundImageUrl}
|
||||
alt=''
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
opacity: 0.04,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Subtle purple glow from bottom right */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
width: '50%',
|
||||
height: '100%',
|
||||
background:
|
||||
'radial-gradient(ellipse at bottom right, rgba(112, 31, 252, 0.1) 0%, transparent 50%)',
|
||||
display: 'flex',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
padding: '56px 72px',
|
||||
height: '100%',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
{/* Logo */}
|
||||
<img src={`${baseUrl}/static/logo.png`} alt='sim' height={32} />
|
||||
|
||||
{/* Category + Title + Description */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 15,
|
||||
fontWeight: 600,
|
||||
color: '#802fff',
|
||||
letterSpacing: '0.02em',
|
||||
}}
|
||||
>
|
||||
{category}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: getTitleFontSize(title),
|
||||
fontWeight: 600,
|
||||
color: '#ffffff',
|
||||
lineHeight: 1.1,
|
||||
letterSpacing: '-0.02em',
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
{description && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 18,
|
||||
fontWeight: 400,
|
||||
color: '#a1a1aa',
|
||||
lineHeight: 1.4,
|
||||
marginTop: 4,
|
||||
}}
|
||||
>
|
||||
{description.length > 100 ? `${description.slice(0, 100)}...` : description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<span
|
||||
style={{
|
||||
fontSize: 15,
|
||||
fontWeight: 500,
|
||||
color: '#52525b',
|
||||
}}
|
||||
>
|
||||
docs.sim.ai
|
||||
</span>
|
||||
</div>
|
||||
</div>,
|
||||
{
|
||||
width: 1200,
|
||||
height: 630,
|
||||
fonts: [
|
||||
{
|
||||
name: 'Geist',
|
||||
data: fontData,
|
||||
style: 'normal',
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -56,14 +56,6 @@ export const metadata = {
|
||||
title: 'Sim Documentation - Visual Workflow Builder for AI Applications',
|
||||
description:
|
||||
'Comprehensive documentation for Sim - the visual workflow builder for AI applications. Create powerful AI agents, automation workflows, and data processing pipelines.',
|
||||
images: [
|
||||
{
|
||||
url: 'https://docs.sim.ai/api/og?title=Sim%20Documentation&category=DOCUMENTATION',
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: 'Sim Documentation',
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
@@ -72,7 +64,7 @@ export const metadata = {
|
||||
'Comprehensive documentation for Sim - the visual workflow builder for AI applications.',
|
||||
creator: '@simdotai',
|
||||
site: '@simdotai',
|
||||
images: ['https://docs.sim.ai/api/og?title=Sim%20Documentation&category=DOCUMENTATION'],
|
||||
images: ['/og-image.png'],
|
||||
},
|
||||
robots: {
|
||||
index: true,
|
||||
|
||||
@@ -39,10 +39,9 @@ Alle Elemente aus einer Webflow CMS-Sammlung auflisten
|
||||
|
||||
| Parameter | Typ | Erforderlich | Beschreibung |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `siteId` | string | Ja | ID der Webflow-Website |
|
||||
| `collectionId` | string | Ja | ID der Sammlung |
|
||||
| `offset` | number | Nein | Offset für Paginierung (optional) |
|
||||
| `limit` | number | Nein | Maximale Anzahl der zurückzugebenden Elemente (optional, Standard: 100) |
|
||||
| `offset` | number | Nein | Offset für Paginierung \(optional\) |
|
||||
| `limit` | number | Nein | Maximale Anzahl der zurückzugebenden Elemente \(optional, Standard: 100\) |
|
||||
|
||||
#### Ausgabe
|
||||
|
||||
@@ -59,7 +58,6 @@ Ein einzelnes Element aus einer Webflow CMS-Sammlung abrufen
|
||||
|
||||
| Parameter | Typ | Erforderlich | Beschreibung |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `siteId` | string | Ja | ID der Webflow-Website |
|
||||
| `collectionId` | string | Ja | ID der Sammlung |
|
||||
| `itemId` | string | Ja | ID des abzurufenden Elements |
|
||||
|
||||
@@ -78,9 +76,8 @@ Ein neues Element in einer Webflow CMS-Sammlung erstellen
|
||||
|
||||
| Parameter | Typ | Erforderlich | Beschreibung |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `siteId` | string | Ja | ID der Webflow-Website |
|
||||
| `collectionId` | string | Ja | ID der Sammlung |
|
||||
| `fieldData` | json | Ja | Felddaten für das neue Element als JSON-Objekt. Schlüssel sollten mit den Sammlungsfeldnamen übereinstimmen. |
|
||||
| `fieldData` | json | Ja | Felddaten für das neue Element als JSON-Objekt. Die Schlüssel sollten mit den Feldnamen der Sammlung übereinstimmen. |
|
||||
|
||||
#### Ausgabe
|
||||
|
||||
@@ -97,7 +94,6 @@ Ein vorhandenes Element in einer Webflow CMS-Sammlung aktualisieren
|
||||
|
||||
| Parameter | Typ | Erforderlich | Beschreibung |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `siteId` | string | Ja | ID der Webflow-Website |
|
||||
| `collectionId` | string | Ja | ID der Sammlung |
|
||||
| `itemId` | string | Ja | ID des zu aktualisierenden Elements |
|
||||
| `fieldData` | json | Ja | Zu aktualisierende Felddaten als JSON-Objekt. Nur Felder einschließen, die geändert werden sollen. |
|
||||
@@ -117,7 +113,6 @@ Ein Element aus einer Webflow CMS-Sammlung löschen
|
||||
|
||||
| Parameter | Typ | Erforderlich | Beschreibung |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `siteId` | string | Ja | ID der Webflow-Website |
|
||||
| `collectionId` | string | Ja | ID der Sammlung |
|
||||
| `itemId` | string | Ja | ID des zu löschenden Elements |
|
||||
|
||||
|
||||
@@ -42,7 +42,6 @@ List all items from a Webflow CMS collection
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `siteId` | string | Yes | ID of the Webflow site |
|
||||
| `collectionId` | string | Yes | ID of the collection |
|
||||
| `offset` | number | No | Offset for pagination \(optional\) |
|
||||
| `limit` | number | No | Maximum number of items to return \(optional, default: 100\) |
|
||||
@@ -62,7 +61,6 @@ Get a single item from a Webflow CMS collection
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `siteId` | string | Yes | ID of the Webflow site |
|
||||
| `collectionId` | string | Yes | ID of the collection |
|
||||
| `itemId` | string | Yes | ID of the item to retrieve |
|
||||
|
||||
@@ -81,7 +79,6 @@ Create a new item in a Webflow CMS collection
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `siteId` | string | Yes | ID of the Webflow site |
|
||||
| `collectionId` | string | Yes | ID of the collection |
|
||||
| `fieldData` | json | Yes | Field data for the new item as a JSON object. Keys should match collection field names. |
|
||||
|
||||
@@ -100,7 +97,6 @@ Update an existing item in a Webflow CMS collection
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `siteId` | string | Yes | ID of the Webflow site |
|
||||
| `collectionId` | string | Yes | ID of the collection |
|
||||
| `itemId` | string | Yes | ID of the item to update |
|
||||
| `fieldData` | json | Yes | Field data to update as a JSON object. Only include fields you want to change. |
|
||||
@@ -120,7 +116,6 @@ Delete an item from a Webflow CMS collection
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `siteId` | string | Yes | ID of the Webflow site |
|
||||
| `collectionId` | string | Yes | ID of the collection |
|
||||
| `itemId` | string | Yes | ID of the item to delete |
|
||||
|
||||
|
||||
@@ -39,7 +39,6 @@ Listar todos los elementos de una colección del CMS de Webflow
|
||||
|
||||
| Parámetro | Tipo | Obligatorio | Descripción |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `siteId` | string | Sí | ID del sitio de Webflow |
|
||||
| `collectionId` | string | Sí | ID de la colección |
|
||||
| `offset` | number | No | Desplazamiento para paginación \(opcional\) |
|
||||
| `limit` | number | No | Número máximo de elementos a devolver \(opcional, predeterminado: 100\) |
|
||||
@@ -59,7 +58,6 @@ Obtener un solo elemento de una colección del CMS de Webflow
|
||||
|
||||
| Parámetro | Tipo | Obligatorio | Descripción |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `siteId` | string | Sí | ID del sitio de Webflow |
|
||||
| `collectionId` | string | Sí | ID de la colección |
|
||||
| `itemId` | string | Sí | ID del elemento a recuperar |
|
||||
|
||||
@@ -78,7 +76,6 @@ Crear un nuevo elemento en una colección del CMS de Webflow
|
||||
|
||||
| Parámetro | Tipo | Obligatorio | Descripción |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `siteId` | string | Sí | ID del sitio de Webflow |
|
||||
| `collectionId` | string | Sí | ID de la colección |
|
||||
| `fieldData` | json | Sí | Datos de campo para el nuevo elemento como objeto JSON. Las claves deben coincidir con los nombres de campo de la colección. |
|
||||
|
||||
@@ -97,7 +94,6 @@ Actualizar un elemento existente en una colección CMS de Webflow
|
||||
|
||||
| Parámetro | Tipo | Obligatorio | Descripción |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `siteId` | string | Sí | ID del sitio de Webflow |
|
||||
| `collectionId` | string | Sí | ID de la colección |
|
||||
| `itemId` | string | Sí | ID del elemento a actualizar |
|
||||
| `fieldData` | json | Sí | Datos de campo para actualizar como objeto JSON. Solo incluye los campos que quieres cambiar. |
|
||||
@@ -117,7 +113,6 @@ Eliminar un elemento de una colección CMS de Webflow
|
||||
|
||||
| Parámetro | Tipo | Obligatorio | Descripción |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `siteId` | string | Sí | ID del sitio de Webflow |
|
||||
| `collectionId` | string | Sí | ID de la colección |
|
||||
| `itemId` | string | Sí | ID del elemento a eliminar |
|
||||
|
||||
|
||||
@@ -38,8 +38,7 @@ Lister tous les éléments d'une collection CMS Webflow
|
||||
#### Entrée
|
||||
|
||||
| Paramètre | Type | Obligatoire | Description |
|
||||
| --------- | ---- | ---------- | ----------- |
|
||||
| `siteId` | string | Oui | ID du site Webflow |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `collectionId` | string | Oui | ID de la collection |
|
||||
| `offset` | number | Non | Décalage pour la pagination \(facultatif\) |
|
||||
| `limit` | number | Non | Nombre maximum d'éléments à retourner \(facultatif, par défaut : 100\) |
|
||||
@@ -58,8 +57,7 @@ Obtenir un seul élément d'une collection CMS Webflow
|
||||
#### Entrée
|
||||
|
||||
| Paramètre | Type | Obligatoire | Description |
|
||||
| --------- | ---- | ---------- | ----------- |
|
||||
| `siteId` | string | Oui | ID du site Webflow |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `collectionId` | string | Oui | ID de la collection |
|
||||
| `itemId` | string | Oui | ID de l'élément à récupérer |
|
||||
|
||||
@@ -78,9 +76,8 @@ Créer un nouvel élément dans une collection CMS Webflow
|
||||
|
||||
| Paramètre | Type | Obligatoire | Description |
|
||||
| --------- | ---- | ---------- | ----------- |
|
||||
| `siteId` | string | Oui | ID du site Webflow |
|
||||
| `collectionId` | string | Oui | ID de la collection |
|
||||
| `fieldData` | json | Oui | Données des champs pour le nouvel élément sous forme d'objet JSON. Les clés doivent correspondre aux noms des champs de la collection. |
|
||||
| `fieldData` | json | Oui | Données de champ pour le nouvel élément sous forme d'objet JSON. Les clés doivent correspondre aux noms des champs de la collection. |
|
||||
|
||||
#### Sortie
|
||||
|
||||
@@ -97,10 +94,9 @@ Mettre à jour un élément existant dans une collection CMS Webflow
|
||||
|
||||
| Paramètre | Type | Obligatoire | Description |
|
||||
| --------- | ---- | ---------- | ----------- |
|
||||
| `siteId` | string | Oui | ID du site Webflow |
|
||||
| `collectionId` | string | Oui | ID de la collection |
|
||||
| `itemId` | string | Oui | ID de l'élément à mettre à jour |
|
||||
| `fieldData` | json | Oui | Données des champs à mettre à jour sous forme d'objet JSON. N'incluez que les champs que vous souhaitez modifier. |
|
||||
| `fieldData` | json | Oui | Données de champ à mettre à jour sous forme d'objet JSON. N'incluez que les champs que vous souhaitez modifier. |
|
||||
|
||||
#### Sortie
|
||||
|
||||
@@ -117,7 +113,6 @@ Supprimer un élément d'une collection CMS Webflow
|
||||
|
||||
| Paramètre | Type | Obligatoire | Description |
|
||||
| --------- | ---- | ---------- | ----------- |
|
||||
| `siteId` | string | Oui | ID du site Webflow |
|
||||
| `collectionId` | string | Oui | ID de la collection |
|
||||
| `itemId` | string | Oui | ID de l'élément à supprimer |
|
||||
|
||||
|
||||
@@ -39,10 +39,9 @@ Webflow CMSコレクションからすべてのアイテムを一覧表示する
|
||||
|
||||
| パラメータ | 型 | 必須 | 説明 |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `siteId` | string | はい | WebflowサイトのID |
|
||||
| `collectionId` | string | はい | コレクションのID |
|
||||
| `offset` | number | いいえ | ページネーション用のオフセット(オプション) |
|
||||
| `limit` | number | いいえ | 返す最大アイテム数(オプション、デフォルト:100) |
|
||||
| `limit` | number | いいえ | 返すアイテムの最大数(オプション、デフォルト:100) |
|
||||
|
||||
#### 出力
|
||||
|
||||
@@ -59,7 +58,6 @@ Webflow CMSコレクションから単一のアイテムを取得する
|
||||
|
||||
| パラメータ | 型 | 必須 | 説明 |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `siteId` | string | はい | WebflowサイトのID |
|
||||
| `collectionId` | string | はい | コレクションのID |
|
||||
| `itemId` | string | はい | 取得するアイテムのID |
|
||||
|
||||
@@ -78,9 +76,8 @@ Webflow CMSコレクションに新しいアイテムを作成する
|
||||
|
||||
| パラメータ | 型 | 必須 | 説明 |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `siteId` | string | はい | WebflowサイトのID |
|
||||
| `collectionId` | string | はい | コレクションのID |
|
||||
| `fieldData` | json | はい | 新しいアイテムのフィールドデータ(JSONオブジェクト)。キーはコレクションフィールド名と一致する必要があります。 |
|
||||
| `fieldData` | json | はい | 新しいアイテムのフィールドデータ(JSONオブジェクト形式)。キーはコレクションのフィールド名と一致する必要があります。 |
|
||||
|
||||
#### 出力
|
||||
|
||||
@@ -97,10 +94,9 @@ Webflow CMSコレクション内の既存アイテムを更新する
|
||||
|
||||
| パラメータ | 型 | 必須 | 説明 |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `siteId` | string | はい | WebflowサイトのID |
|
||||
| `collectionId` | string | はい | コレクションのID |
|
||||
| `itemId` | string | はい | 更新するアイテムのID |
|
||||
| `fieldData` | json | はい | 更新するフィールドデータ(JSONオブジェクト)。変更したいフィールドのみを含めてください。 |
|
||||
| `fieldData` | json | はい | 更新するフィールドデータ(JSONオブジェクト形式)。変更したいフィールドのみを含めてください。 |
|
||||
|
||||
#### 出力
|
||||
|
||||
@@ -117,7 +113,6 @@ Webflow CMSコレクションからアイテムを削除する
|
||||
|
||||
| パラメータ | 型 | 必須 | 説明 |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `siteId` | string | はい | WebflowサイトのID |
|
||||
| `collectionId` | string | はい | コレクションのID |
|
||||
| `itemId` | string | はい | 削除するアイテムのID |
|
||||
|
||||
|
||||
@@ -38,10 +38,9 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
| 参数 | 类型 | 必需 | 描述 |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `siteId` | string | 是 | Webflow 网站的 ID |
|
||||
| `collectionId` | string | 是 | 集合的 ID |
|
||||
| `offset` | number | 否 | 分页的偏移量(可选) |
|
||||
| `limit` | number | 否 | 要返回的最大项目数(可选,默认值:100) |
|
||||
| `offset` | number | 否 | 分页偏移量(可选) |
|
||||
| `limit` | number | 否 | 返回的最大项目数(可选,默认值:100) |
|
||||
|
||||
#### 输出
|
||||
|
||||
@@ -58,9 +57,8 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
| 参数 | 类型 | 必需 | 描述 |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `siteId` | string | 是 | Webflow 网站的 ID |
|
||||
| `collectionId` | string | 是 | 集合的 ID |
|
||||
| `itemId` | string | 是 | 要检索项目的 ID |
|
||||
| `itemId` | string | 是 | 要检索的项目 ID |
|
||||
|
||||
#### 输出
|
||||
|
||||
@@ -77,9 +75,8 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
| 参数 | 类型 | 必需 | 描述 |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `siteId` | string | 是 | Webflow 网站的 ID |
|
||||
| `collectionId` | string | 是 | 集合的 ID |
|
||||
| `fieldData` | json | 是 | 新项目的字段数据,格式为 JSON 对象。键名应与集合字段名匹配。 |
|
||||
| `fieldData` | json | 是 | 新项目的字段数据,格式为 JSON 对象。键名应与集合字段名称匹配。 |
|
||||
|
||||
#### 输出
|
||||
|
||||
@@ -96,10 +93,9 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
| 参数 | 类型 | 必需 | 描述 |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `siteId` | string | 是 | Webflow 网站的 ID |
|
||||
| `collectionId` | string | 是 | 集合的 ID |
|
||||
| `itemId` | string | 是 | 要更新项目的 ID |
|
||||
| `fieldData` | json | 是 | 要更新的字段数据,格式为 JSON 对象。仅包含需要更改的字段。 |
|
||||
| `fieldData` | json | 是 | 要更新的字段数据,格式为 JSON 对象。仅包含您想更改的字段。 |
|
||||
|
||||
#### 输出
|
||||
|
||||
@@ -116,7 +112,6 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
| 参数 | 类型 | 必需 | 描述 |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `siteId` | string | 是 | Webflow 网站的 ID |
|
||||
| `collectionId` | string | 是 | 集合的 ID |
|
||||
| `itemId` | string | 是 | 要删除项目的 ID |
|
||||
|
||||
|
||||
@@ -5973,31 +5973,31 @@ checksums:
|
||||
content/9: 5914baadfaf2ca26d54130a36dd5ed29
|
||||
content/10: 25507380ac7d9c7f8cf9f5256c6a0dbb
|
||||
content/11: 371d0e46b4bd2c23f559b8bc112f6955
|
||||
content/12: e034523b05e8c7bd1723ef0ba96c5332
|
||||
content/12: e7fb612c3323c1e6b05eacfcea360d34
|
||||
content/13: bcadfc362b69078beee0088e5936c98b
|
||||
content/14: e5f830d6049ff79a318110098e5e0130
|
||||
content/15: 711e90714806b91f93923018e82ad2e9
|
||||
content/16: 0f3f7d9699d7397cb3a094c3229329ee
|
||||
content/17: 371d0e46b4bd2c23f559b8bc112f6955
|
||||
content/18: 4b0c581b30f4449b0bfa3cdd4af69e02
|
||||
content/18: c53b5b8f901066e63fe159ad2fa5e6e0
|
||||
content/19: bcadfc362b69078beee0088e5936c98b
|
||||
content/20: 5f2afdd49c3ac13381401c69d1eca22a
|
||||
content/21: cc4baa9096fafa4c6276f6136412ba66
|
||||
content/22: 676f76e8a7154a576d7fa20b245cef70
|
||||
content/23: 371d0e46b4bd2c23f559b8bc112f6955
|
||||
content/24: d26dd24c5398fd036d1f464ba3789002
|
||||
content/24: c67c387eb7e274ee7c07b7e1748afce1
|
||||
content/25: bcadfc362b69078beee0088e5936c98b
|
||||
content/26: a6ffebda549ad5b903a66c7d9ac03a20
|
||||
content/27: 0dadd51cde48d6ea75b29ec3ee4ade56
|
||||
content/28: cdc74f6483a0b4e9933ecdd92ed7480f
|
||||
content/29: 371d0e46b4bd2c23f559b8bc112f6955
|
||||
content/30: cec3953ee52d1d3c8b1a495f9684d35b
|
||||
content/30: 4cda10aa374e1a46d60ad14eeaa79100
|
||||
content/31: bcadfc362b69078beee0088e5936c98b
|
||||
content/32: 5f221421953a0e760ead7388cbf66561
|
||||
content/33: a3c0372590cef72d5d983dbc8dbbc2cb
|
||||
content/34: 1402e53c08bdd8a741f44b2d66fcd003
|
||||
content/35: 371d0e46b4bd2c23f559b8bc112f6955
|
||||
content/36: db921b05a9e5ddceb28a4f3f1af2a377
|
||||
content/36: 028e579a28e55def4fbc59f39f4610b7
|
||||
content/37: bcadfc362b69078beee0088e5936c98b
|
||||
content/38: 4fe4260da2f137679ce2fa42cffcf56a
|
||||
content/39: b3f310d5ef115bea5a8b75bf25d7ea9a
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 583 KiB |
@@ -4,13 +4,10 @@ DATABASE_URL="postgresql://postgres:password@localhost:5432/postgres"
|
||||
# PostgreSQL Port (Optional) - defaults to 5432 if not specified
|
||||
# POSTGRES_PORT=5432
|
||||
|
||||
# Authentication (Required unless DISABLE_AUTH=true)
|
||||
# Authentication (Required)
|
||||
BETTER_AUTH_SECRET=your_secret_key # Use `openssl rand -hex 32` to generate, or visit https://www.better-auth.com/docs/installation
|
||||
BETTER_AUTH_URL=http://localhost:3000
|
||||
|
||||
# Authentication Bypass (Optional - for self-hosted deployments behind private networks)
|
||||
# DISABLE_AUTH=true # Uncomment to bypass authentication entirely. Creates an anonymous session for all requests.
|
||||
|
||||
# NextJS (Required)
|
||||
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use server'
|
||||
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { isProd } from '@/lib/core/config/feature-flags'
|
||||
import { isProd } from '@/lib/core/config/environment'
|
||||
|
||||
export async function getOAuthProviderStatus() {
|
||||
const githubAvailable = !!(env.GITHUB_CLIENT_ID && env.GITHUB_CLIENT_SECRET)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { getOAuthProviderStatus } from '@/app/(auth)/components/oauth-provider-checker'
|
||||
import LoginForm from '@/app/(auth)/login/login-form'
|
||||
|
||||
// Force dynamic rendering to avoid prerender errors with search params
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default async function LoginPage() {
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { isRegistrationDisabled } from '@/lib/core/config/feature-flags'
|
||||
import { env, isTruthy } from '@/lib/core/config/env'
|
||||
import { getOAuthProviderStatus } from '@/app/(auth)/components/oauth-provider-checker'
|
||||
import SignupForm from '@/app/(auth)/signup/signup-form'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default async function SignupPage() {
|
||||
if (isRegistrationDisabled) {
|
||||
const { githubAvailable, googleAvailable, isProduction } = await getOAuthProviderStatus()
|
||||
|
||||
if (isTruthy(env.DISABLE_REGISTRATION)) {
|
||||
return <div>Registration is disabled, please contact your admin.</div>
|
||||
}
|
||||
|
||||
const { githubAvailable, googleAvailable, isProduction } = await getOAuthProviderStatus()
|
||||
|
||||
return (
|
||||
<SignupForm
|
||||
githubAvailable={githubAvailable}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { isEmailVerificationEnabled, isProd } from '@/lib/core/config/feature-flags'
|
||||
import { isEmailVerificationEnabled, isProd } from '@/lib/core/config/environment'
|
||||
import { hasEmailService } from '@/lib/messaging/email/mailer'
|
||||
import { VerifyContent } from '@/app/(auth)/verify/verify-content'
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { isHosted } from '@/lib/core/config/feature-flags'
|
||||
import { isHosted } from '@/lib/core/config/environment'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { quickValidateEmail } from '@/lib/messaging/email/validation'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { isHosted } from '@/lib/core/config/feature-flags'
|
||||
import { isHosted } from '@/lib/core/config/environment'
|
||||
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
|
||||
import Footer from '@/app/(landing)/components/footer/footer'
|
||||
import Nav from '@/app/(landing)/components/nav/nav'
|
||||
|
||||
@@ -7,7 +7,7 @@ import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { GithubIcon } from '@/components/icons'
|
||||
import { useBrandConfig } from '@/lib/branding/branding'
|
||||
import { isHosted } from '@/lib/core/config/feature-flags'
|
||||
import { isHosted } from '@/lib/core/config/environment'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
|
||||
import { getFormattedGitHubStars } from '@/app/(landing)/actions/github'
|
||||
|
||||
@@ -1,23 +1,6 @@
|
||||
import { toNextJsHandler } from 'better-auth/next-js'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { createAnonymousSession, ensureAnonymousUserExists } from '@/lib/auth/anonymous'
|
||||
import { isAuthDisabled } from '@/lib/core/config/feature-flags'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const { GET: betterAuthGET, POST: betterAuthPOST } = toNextJsHandler(auth.handler)
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const url = new URL(request.url)
|
||||
const path = url.pathname.replace('/api/auth/', '')
|
||||
|
||||
if (path === 'get-session' && isAuthDisabled) {
|
||||
await ensureAnonymousUserExists()
|
||||
return NextResponse.json(createAnonymousSession())
|
||||
}
|
||||
|
||||
return betterAuthGET(request)
|
||||
}
|
||||
|
||||
export const POST = betterAuthPOST
|
||||
export const { GET, POST } = toNextJsHandler(auth.handler)
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
import { headers } from 'next/headers'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { isAuthDisabled } from '@/lib/core/config/feature-flags'
|
||||
|
||||
export async function POST() {
|
||||
try {
|
||||
if (isAuthDisabled) {
|
||||
return NextResponse.json({ token: 'anonymous-socket-token' })
|
||||
}
|
||||
|
||||
const hdrs = await headers()
|
||||
const response = await auth.api.generateOneTimeToken({
|
||||
headers: hdrs,
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { db, ssoProvider } from '@sim/db'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('SSO-Providers')
|
||||
|
||||
export async function GET() {
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const session = await getSession()
|
||||
const session = await auth.api.getSession({ headers: req.headers })
|
||||
|
||||
let providers
|
||||
if (session?.user?.id) {
|
||||
@@ -38,6 +38,8 @@ export async function GET() {
|
||||
: ('oidc' as 'oidc' | 'saml'),
|
||||
}))
|
||||
} else {
|
||||
// Unauthenticated users can only see basic info (domain only)
|
||||
// This is needed for SSO login flow to check if a domain has SSO enabled
|
||||
const results = await db
|
||||
.select({
|
||||
domain: ssoProvider.domain,
|
||||
|
||||
@@ -5,7 +5,7 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkAndBillOverageThreshold } from '@/lib/billing/threshold-billing'
|
||||
import { checkInternalApiKey } from '@/lib/copilot/utils'
|
||||
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
|
||||
import { isBillingEnabled } from '@/lib/core/config/environment'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
|
||||
@@ -6,12 +6,6 @@ import { NextRequest } from 'next/server'
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/lib/core/config/feature-flags', () => ({
|
||||
isDev: true,
|
||||
isHosted: false,
|
||||
isProd: false,
|
||||
}))
|
||||
|
||||
describe('Chat Edit API Route', () => {
|
||||
const mockSelect = vi.fn()
|
||||
const mockFrom = vi.fn()
|
||||
@@ -30,6 +24,7 @@ describe('Chat Edit API Route', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
|
||||
// Set default return values
|
||||
mockLimit.mockResolvedValue([])
|
||||
mockSelect.mockReturnValue({ from: mockFrom })
|
||||
mockFrom.mockReturnValue({ where: mockWhere })
|
||||
@@ -82,6 +77,10 @@ describe('Chat Edit API Route', () => {
|
||||
getEmailDomain: vi.fn().mockReturnValue('localhost:3000'),
|
||||
}))
|
||||
|
||||
vi.doMock('@/lib/core/config/environment', () => ({
|
||||
isDev: true,
|
||||
}))
|
||||
|
||||
vi.doMock('@/app/api/chat/utils', () => ({
|
||||
checkChatAccess: mockCheckChatAccess,
|
||||
}))
|
||||
@@ -255,6 +254,7 @@ describe('Chat Edit API Route', () => {
|
||||
|
||||
mockCheckChatAccess.mockResolvedValue({ hasAccess: true, chat: mockChat })
|
||||
|
||||
// Reset and reconfigure mockLimit to return the conflict
|
||||
mockLimit.mockReset()
|
||||
mockLimit.mockResolvedValue([{ id: 'other-chat-id', identifier: 'new-identifier' }])
|
||||
mockWhere.mockReturnValue({ limit: mockLimit })
|
||||
@@ -291,7 +291,7 @@ describe('Chat Edit API Route', () => {
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ authType: 'password' }),
|
||||
body: JSON.stringify({ authType: 'password' }), // No password provided
|
||||
})
|
||||
const { PATCH } = await import('@/app/api/chat/manage/[id]/route')
|
||||
const response = await PATCH(req, { params: Promise.resolve({ id: 'chat-123' }) })
|
||||
@@ -316,8 +316,9 @@ describe('Chat Edit API Route', () => {
|
||||
workflowId: 'workflow-123',
|
||||
}
|
||||
|
||||
// User doesn't own chat but has workspace admin access
|
||||
mockCheckChatAccess.mockResolvedValue({ hasAccess: true, chat: mockChat })
|
||||
mockLimit.mockResolvedValueOnce([])
|
||||
mockLimit.mockResolvedValueOnce([]) // No identifier conflict
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', {
|
||||
method: 'PATCH',
|
||||
@@ -398,6 +399,7 @@ describe('Chat Edit API Route', () => {
|
||||
}),
|
||||
}))
|
||||
|
||||
// User doesn't own chat but has workspace admin access
|
||||
mockCheckChatAccess.mockResolvedValue({ hasAccess: true })
|
||||
mockWhere.mockResolvedValue(undefined)
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { eq } from 'drizzle-orm'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { isDev } from '@/lib/core/config/feature-flags'
|
||||
import { isDev } from '@/lib/core/config/environment'
|
||||
import { encryptSecret } from '@/lib/core/security/encryption'
|
||||
import { getEmailDomain } from '@/lib/core/utils/urls'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { NextRequest } from 'next/server'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { isDev } from '@/lib/core/config/feature-flags'
|
||||
import { isDev } from '@/lib/core/config/environment'
|
||||
import { encryptSecret } from '@/lib/core/security/encryption'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
@@ -44,12 +44,6 @@ vi.mock('@/lib/core/utils/request', () => ({
|
||||
generateRequestId: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/config/feature-flags', () => ({
|
||||
isDev: true,
|
||||
isHosted: false,
|
||||
isProd: false,
|
||||
}))
|
||||
|
||||
describe('Chat API Utils', () => {
|
||||
beforeEach(() => {
|
||||
vi.doMock('@/lib/logs/console/logger', () => ({
|
||||
@@ -68,6 +62,11 @@ describe('Chat API Utils', () => {
|
||||
NODE_ENV: 'development',
|
||||
},
|
||||
})
|
||||
|
||||
vi.doMock('@/lib/core/config/environment', () => ({
|
||||
isDev: true,
|
||||
isHosted: false,
|
||||
}))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { db } from '@sim/db'
|
||||
import { chat, workflow } from '@sim/db/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import type { NextRequest, NextResponse } from 'next/server'
|
||||
import { isDev } from '@/lib/core/config/feature-flags'
|
||||
import { isDev } from '@/lib/core/config/environment'
|
||||
import { decryptSecret } from '@/lib/core/security/encryption'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { hasAdminPermission } from '@/lib/workspaces/permissions/utils'
|
||||
@@ -282,8 +282,8 @@ export async function validateChatAuth(
|
||||
return { authorized: false, error: 'Email not authorized for SSO access' }
|
||||
}
|
||||
|
||||
const { getSession } = await import('@/lib/auth')
|
||||
const session = await getSession()
|
||||
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' }
|
||||
|
||||
@@ -2,7 +2,7 @@ import { db } from '@sim/db'
|
||||
import { settings } from '@sim/db/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('CopilotAutoAllowedToolsAPI')
|
||||
@@ -10,9 +10,9 @@ const logger = createLogger('CopilotAutoAllowedToolsAPI')
|
||||
/**
|
||||
* GET - Fetch user's auto-allowed integration tools
|
||||
*/
|
||||
export async function GET() {
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const session = await getSession()
|
||||
const session = await auth.api.getSession({ headers: request.headers })
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
@@ -31,6 +31,7 @@ export async function GET() {
|
||||
return NextResponse.json({ autoAllowedTools })
|
||||
}
|
||||
|
||||
// If no settings record exists, create one with empty array
|
||||
await db.insert(settings).values({
|
||||
id: userId,
|
||||
userId,
|
||||
@@ -49,7 +50,7 @@ export async function GET() {
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await getSession()
|
||||
const session = await auth.api.getSession({ headers: request.headers })
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
@@ -64,11 +65,13 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
const toolId = body.toolId
|
||||
|
||||
// Get existing settings
|
||||
const [existing] = await db.select().from(settings).where(eq(settings.userId, userId)).limit(1)
|
||||
|
||||
if (existing) {
|
||||
const currentTools = (existing.copilotAutoAllowedTools as string[]) || []
|
||||
|
||||
// Add tool if not already present
|
||||
if (!currentTools.includes(toolId)) {
|
||||
const updatedTools = [...currentTools, toolId]
|
||||
await db
|
||||
@@ -86,6 +89,7 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ success: true, autoAllowedTools: currentTools })
|
||||
}
|
||||
|
||||
// Create new settings record with the tool
|
||||
await db.insert(settings).values({
|
||||
id: userId,
|
||||
userId,
|
||||
@@ -105,7 +109,7 @@ export async function POST(request: NextRequest) {
|
||||
*/
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const session = await getSession()
|
||||
const session = await auth.api.getSession({ headers: request.headers })
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
@@ -119,6 +123,7 @@ export async function DELETE(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'toolId query parameter is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Get existing settings
|
||||
const [existing] = await db.select().from(settings).where(eq(settings.userId, userId)).limit(1)
|
||||
|
||||
if (existing) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { db } from '@/../../packages/db'
|
||||
import { settings } from '@/../../packages/db/schema'
|
||||
@@ -32,7 +32,7 @@ const DEFAULT_ENABLED_MODELS: Record<string, boolean> = {
|
||||
// GET - Fetch user's enabled models
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const session = await getSession()
|
||||
const session = await auth.api.getSession({ headers: request.headers })
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
@@ -40,6 +40,7 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
const userId = session.user.id
|
||||
|
||||
// Try to fetch existing settings record
|
||||
const [userSettings] = await db
|
||||
.select()
|
||||
.from(settings)
|
||||
@@ -49,11 +50,13 @@ export async function GET(request: NextRequest) {
|
||||
if (userSettings) {
|
||||
const userModelsMap = (userSettings.copilotEnabledModels as Record<string, boolean>) || {}
|
||||
|
||||
// Merge: start with defaults, then override with user's existing preferences
|
||||
const mergedModels = { ...DEFAULT_ENABLED_MODELS }
|
||||
for (const [modelId, enabled] of Object.entries(userModelsMap)) {
|
||||
mergedModels[modelId] = enabled
|
||||
}
|
||||
|
||||
// If we added any new models, update the database
|
||||
const hasNewModels = Object.keys(DEFAULT_ENABLED_MODELS).some(
|
||||
(key) => !(key in userModelsMap)
|
||||
)
|
||||
@@ -73,6 +76,7 @@ export async function GET(request: NextRequest) {
|
||||
})
|
||||
}
|
||||
|
||||
// If no settings record exists, create one with defaults
|
||||
await db.insert(settings).values({
|
||||
id: userId,
|
||||
userId,
|
||||
@@ -93,7 +97,7 @@ export async function GET(request: NextRequest) {
|
||||
// PUT - Update user's enabled models
|
||||
export async function PUT(request: NextRequest) {
|
||||
try {
|
||||
const session = await getSession()
|
||||
const session = await auth.api.getSession({ headers: request.headers })
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
@@ -106,9 +110,11 @@ export async function PUT(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'enabledModels must be an object' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Check if settings record exists
|
||||
const [existing] = await db.select().from(settings).where(eq(settings.userId, userId)).limit(1)
|
||||
|
||||
if (existing) {
|
||||
// Update existing record
|
||||
await db
|
||||
.update(settings)
|
||||
.set({
|
||||
@@ -117,6 +123,7 @@ export async function PUT(request: NextRequest) {
|
||||
})
|
||||
.where(eq(settings.userId, userId))
|
||||
} else {
|
||||
// Create new settings record
|
||||
await db.insert(settings).values({
|
||||
id: userId,
|
||||
userId,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createContext, Script } from 'vm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { isE2bEnabled } from '@/lib/core/config/feature-flags'
|
||||
import { env, isTruthy } from '@/lib/core/config/env'
|
||||
import { validateProxyUrl } from '@/lib/core/security/input-validation'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { executeInE2B } from '@/lib/execution/e2b'
|
||||
@@ -701,6 +701,7 @@ export async function POST(req: NextRequest) {
|
||||
resolvedCode = codeResolution.resolvedCode
|
||||
const contextVariables = codeResolution.contextVariables
|
||||
|
||||
const e2bEnabled = isTruthy(env.E2B_ENABLED)
|
||||
const lang = isValidCodeLanguage(language) ? language : DEFAULT_CODE_LANGUAGE
|
||||
|
||||
// Extract imports once for JavaScript code (reuse later to avoid double extraction)
|
||||
@@ -721,14 +722,14 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
|
||||
// Python always requires E2B
|
||||
if (lang === CodeLanguage.Python && !isE2bEnabled) {
|
||||
if (lang === CodeLanguage.Python && !e2bEnabled) {
|
||||
throw new Error(
|
||||
'Python execution requires E2B to be enabled. Please contact your administrator to enable E2B, or use JavaScript instead.'
|
||||
)
|
||||
}
|
||||
|
||||
// JavaScript with imports requires E2B
|
||||
if (lang === CodeLanguage.JavaScript && hasImports && !isE2bEnabled) {
|
||||
if (lang === CodeLanguage.JavaScript && hasImports && !e2bEnabled) {
|
||||
throw new Error(
|
||||
'JavaScript code with import statements requires E2B to be enabled. Please remove the import statements, or contact your administrator to enable E2B.'
|
||||
)
|
||||
@@ -739,13 +740,13 @@ export async function POST(req: NextRequest) {
|
||||
// - Not a custom tool AND
|
||||
// - (Python OR JavaScript with imports)
|
||||
const useE2B =
|
||||
isE2bEnabled &&
|
||||
e2bEnabled &&
|
||||
!isCustomTool &&
|
||||
(lang === CodeLanguage.Python || (lang === CodeLanguage.JavaScript && hasImports))
|
||||
|
||||
if (useE2B) {
|
||||
logger.info(`[${requestId}] E2B status`, {
|
||||
enabled: isE2bEnabled,
|
||||
enabled: e2bEnabled,
|
||||
hasApiKey: Boolean(process.env.E2B_API_KEY),
|
||||
language: lang,
|
||||
})
|
||||
|
||||
@@ -6,7 +6,7 @@ import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { getPlanPricing } from '@/lib/billing/core/billing'
|
||||
import { requireStripeClient } from '@/lib/billing/stripe-client'
|
||||
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
|
||||
import { isBillingEnabled } from '@/lib/core/config/environment'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('OrganizationSeatsAPI')
|
||||
|
||||
@@ -3,7 +3,7 @@ import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { generateInternalToken } from '@/lib/auth/internal'
|
||||
import { isDev } from '@/lib/core/config/feature-flags'
|
||||
import { isDev } from '@/lib/core/config/environment'
|
||||
import { createPinnedUrl, validateUrlWithDNS } from '@/lib/core/security/input-validation'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { StorageService } from '@/lib/uploads'
|
||||
@@ -148,10 +147,6 @@ export async function POST(request: NextRequest) {
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
const voiceIdValidation = validateAlphanumericId(body.voiceId, 'voiceId')
|
||||
if (!voiceIdValidation.isValid) {
|
||||
return NextResponse.json({ error: voiceIdValidation.error }, { status: 400 })
|
||||
}
|
||||
const result = await synthesizeWithElevenLabs({
|
||||
text,
|
||||
apiKey,
|
||||
|
||||
@@ -42,11 +42,11 @@ describe('Scheduled Workflow Execution API Route', () => {
|
||||
executeScheduleJob: mockExecuteScheduleJob,
|
||||
}))
|
||||
|
||||
vi.doMock('@/lib/core/config/feature-flags', () => ({
|
||||
isTriggerDevEnabled: false,
|
||||
isHosted: false,
|
||||
isProd: false,
|
||||
isDev: true,
|
||||
vi.doMock('@/lib/core/config/env', () => ({
|
||||
env: {
|
||||
TRIGGER_DEV_ENABLED: false,
|
||||
},
|
||||
isTruthy: vi.fn(() => false),
|
||||
}))
|
||||
|
||||
vi.doMock('drizzle-orm', () => ({
|
||||
@@ -119,11 +119,11 @@ describe('Scheduled Workflow Execution API Route', () => {
|
||||
},
|
||||
}))
|
||||
|
||||
vi.doMock('@/lib/core/config/feature-flags', () => ({
|
||||
isTriggerDevEnabled: true,
|
||||
isHosted: false,
|
||||
isProd: false,
|
||||
isDev: true,
|
||||
vi.doMock('@/lib/core/config/env', () => ({
|
||||
env: {
|
||||
TRIGGER_DEV_ENABLED: true,
|
||||
},
|
||||
isTruthy: vi.fn(() => true),
|
||||
}))
|
||||
|
||||
vi.doMock('drizzle-orm', () => ({
|
||||
@@ -191,11 +191,11 @@ describe('Scheduled Workflow Execution API Route', () => {
|
||||
executeScheduleJob: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
vi.doMock('@/lib/core/config/feature-flags', () => ({
|
||||
isTriggerDevEnabled: false,
|
||||
isHosted: false,
|
||||
isProd: false,
|
||||
isDev: true,
|
||||
vi.doMock('@/lib/core/config/env', () => ({
|
||||
env: {
|
||||
TRIGGER_DEV_ENABLED: false,
|
||||
},
|
||||
isTruthy: vi.fn(() => false),
|
||||
}))
|
||||
|
||||
vi.doMock('drizzle-orm', () => ({
|
||||
@@ -250,11 +250,11 @@ describe('Scheduled Workflow Execution API Route', () => {
|
||||
executeScheduleJob: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
vi.doMock('@/lib/core/config/feature-flags', () => ({
|
||||
isTriggerDevEnabled: false,
|
||||
isHosted: false,
|
||||
isProd: false,
|
||||
isDev: true,
|
||||
vi.doMock('@/lib/core/config/env', () => ({
|
||||
env: {
|
||||
TRIGGER_DEV_ENABLED: false,
|
||||
},
|
||||
isTruthy: vi.fn(() => false),
|
||||
}))
|
||||
|
||||
vi.doMock('drizzle-orm', () => ({
|
||||
|
||||
@@ -3,7 +3,7 @@ import { tasks } from '@trigger.dev/sdk'
|
||||
import { and, eq, isNull, lt, lte, not, or } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { verifyCronAuth } from '@/lib/auth/internal'
|
||||
import { isTriggerDevEnabled } from '@/lib/core/config/feature-flags'
|
||||
import { env, isTruthy } from '@/lib/core/config/env'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { executeScheduleJob } from '@/background/schedule-execution'
|
||||
@@ -54,7 +54,9 @@ export async function GET(request: NextRequest) {
|
||||
logger.debug(`[${requestId}] Successfully queried schedules: ${dueSchedules.length} found`)
|
||||
logger.info(`[${requestId}] Processing ${dueSchedules.length} due scheduled workflows`)
|
||||
|
||||
if (isTriggerDevEnabled) {
|
||||
const useTrigger = isTruthy(env.TRIGGER_DEV_ENABLED)
|
||||
|
||||
if (useTrigger) {
|
||||
const triggerPromises = dueSchedules.map(async (schedule) => {
|
||||
const queueTime = schedule.lastQueuedAt ?? queuedAt
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { isProd } from '@/lib/core/config/feature-flags'
|
||||
import { isProd } from '@/lib/core/config/environment'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('TelemetryAPI')
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { validateNumericId } from '@/lib/core/security/input-validation'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils'
|
||||
@@ -42,17 +41,6 @@ export async function POST(request: NextRequest) {
|
||||
const body = await request.json()
|
||||
const validatedData = DiscordSendMessageSchema.parse(body)
|
||||
|
||||
const channelIdValidation = validateNumericId(validatedData.channelId, 'channelId')
|
||||
if (!channelIdValidation.isValid) {
|
||||
logger.warn(`[${requestId}] Invalid channelId format`, {
|
||||
error: channelIdValidation.error,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ success: false, error: channelIdValidation.error },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Sending Discord message`, {
|
||||
channelId: validatedData.channelId,
|
||||
hasFiles: !!(validatedData.files && validatedData.files.length > 0),
|
||||
|
||||
@@ -1,55 +1,32 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
import { getOAuthToken } from '@/app/api/auth/oauth/utils'
|
||||
|
||||
const logger = createLogger('WebflowCollectionsAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export async function POST(request: Request) {
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const requestId = generateRequestId()
|
||||
const body = await request.json()
|
||||
const { credential, workflowId, siteId } = body
|
||||
|
||||
if (!credential) {
|
||||
logger.error('Missing credential in request')
|
||||
return NextResponse.json({ error: 'Credential is required' }, { status: 400 })
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const siteIdValidation = validateAlphanumericId(siteId, 'siteId')
|
||||
if (!siteIdValidation.isValid) {
|
||||
logger.error('Invalid siteId', { error: siteIdValidation.error })
|
||||
return NextResponse.json({ error: siteIdValidation.error }, { status: 400 })
|
||||
const { searchParams } = new URL(request.url)
|
||||
const siteId = searchParams.get('siteId')
|
||||
|
||||
if (!siteId) {
|
||||
return NextResponse.json({ error: 'Missing siteId parameter' }, { status: 400 })
|
||||
}
|
||||
|
||||
const authz = await authorizeCredentialUse(request as any, {
|
||||
credentialId: credential,
|
||||
workflowId,
|
||||
})
|
||||
if (!authz.ok || !authz.credentialOwnerUserId) {
|
||||
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
|
||||
}
|
||||
const accessToken = await getOAuthToken(session.user.id, 'webflow')
|
||||
|
||||
const accessToken = await refreshAccessTokenIfNeeded(
|
||||
credential,
|
||||
authz.credentialOwnerUserId,
|
||||
requestId
|
||||
)
|
||||
if (!accessToken) {
|
||||
logger.error('Failed to get access token', {
|
||||
credentialId: credential,
|
||||
userId: authz.credentialOwnerUserId,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Could not retrieve access token',
|
||||
authRequired: true,
|
||||
},
|
||||
{ status: 401 }
|
||||
{ error: 'No Webflow access token found. Please connect your Webflow account.' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -81,11 +58,11 @@ export async function POST(request: Request) {
|
||||
name: collection.displayName || collection.slug || collection.id,
|
||||
}))
|
||||
|
||||
return NextResponse.json({ collections: formattedCollections })
|
||||
} catch (error) {
|
||||
logger.error('Error processing Webflow collections request:', error)
|
||||
return NextResponse.json({ collections: formattedCollections }, { status: 200 })
|
||||
} catch (error: any) {
|
||||
logger.error('Error fetching Webflow collections', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to retrieve Webflow collections', details: (error as Error).message },
|
||||
{ error: 'Internal server error', details: error.message },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
|
||||
const logger = createLogger('WebflowItemsAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const requestId = generateRequestId()
|
||||
const body = await request.json()
|
||||
const { credential, workflowId, collectionId, search } = body
|
||||
|
||||
if (!credential) {
|
||||
logger.error('Missing credential in request')
|
||||
return NextResponse.json({ error: 'Credential is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const collectionIdValidation = validateAlphanumericId(collectionId, 'collectionId')
|
||||
if (!collectionIdValidation.isValid) {
|
||||
logger.error('Invalid collectionId', { error: collectionIdValidation.error })
|
||||
return NextResponse.json({ error: collectionIdValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
const authz = await authorizeCredentialUse(request as any, {
|
||||
credentialId: credential,
|
||||
workflowId,
|
||||
})
|
||||
if (!authz.ok || !authz.credentialOwnerUserId) {
|
||||
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
|
||||
}
|
||||
|
||||
const accessToken = await refreshAccessTokenIfNeeded(
|
||||
credential,
|
||||
authz.credentialOwnerUserId,
|
||||
requestId
|
||||
)
|
||||
if (!accessToken) {
|
||||
logger.error('Failed to get access token', {
|
||||
credentialId: credential,
|
||||
userId: authz.credentialOwnerUserId,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Could not retrieve access token',
|
||||
authRequired: true,
|
||||
},
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`https://api.webflow.com/v2/collections/${collectionId}/items?limit=100`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
accept: 'application/json',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
logger.error('Failed to fetch Webflow items', {
|
||||
status: response.status,
|
||||
error: errorData,
|
||||
collectionId,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch Webflow items', details: errorData },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const items = data.items || []
|
||||
|
||||
let formattedItems = items.map((item: any) => {
|
||||
const fieldData = item.fieldData || {}
|
||||
const name = fieldData.name || fieldData.title || fieldData.slug || item.id
|
||||
return {
|
||||
id: item.id,
|
||||
name,
|
||||
}
|
||||
})
|
||||
|
||||
if (search) {
|
||||
const searchLower = search.toLowerCase()
|
||||
formattedItems = formattedItems.filter((item: { id: string; name: string }) =>
|
||||
item.name.toLowerCase().includes(searchLower)
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({ items: formattedItems })
|
||||
} catch (error) {
|
||||
logger.error('Error processing Webflow items request:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to retrieve Webflow items', details: (error as Error).message },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,48 +1,25 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
import { getOAuthToken } from '@/app/api/auth/oauth/utils'
|
||||
|
||||
const logger = createLogger('WebflowSitesAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export async function POST(request: Request) {
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const requestId = generateRequestId()
|
||||
const body = await request.json()
|
||||
const { credential, workflowId } = body
|
||||
|
||||
if (!credential) {
|
||||
logger.error('Missing credential in request')
|
||||
return NextResponse.json({ error: 'Credential is required' }, { status: 400 })
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const authz = await authorizeCredentialUse(request as any, {
|
||||
credentialId: credential,
|
||||
workflowId,
|
||||
})
|
||||
if (!authz.ok || !authz.credentialOwnerUserId) {
|
||||
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
|
||||
}
|
||||
const accessToken = await getOAuthToken(session.user.id, 'webflow')
|
||||
|
||||
const accessToken = await refreshAccessTokenIfNeeded(
|
||||
credential,
|
||||
authz.credentialOwnerUserId,
|
||||
requestId
|
||||
)
|
||||
if (!accessToken) {
|
||||
logger.error('Failed to get access token', {
|
||||
credentialId: credential,
|
||||
userId: authz.credentialOwnerUserId,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Could not retrieve access token',
|
||||
authRequired: true,
|
||||
},
|
||||
{ status: 401 }
|
||||
{ error: 'No Webflow access token found. Please connect your Webflow account.' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -73,11 +50,11 @@ export async function POST(request: Request) {
|
||||
name: site.displayName || site.shortName || site.id,
|
||||
}))
|
||||
|
||||
return NextResponse.json({ sites: formattedSites })
|
||||
} catch (error) {
|
||||
logger.error('Error processing Webflow sites request:', error)
|
||||
return NextResponse.json({ sites: formattedSites }, { status: 200 })
|
||||
} catch (error: any) {
|
||||
logger.error('Error fetching Webflow sites', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to retrieve Webflow sites', details: (error as Error).message },
|
||||
{ error: 'Internal server error', details: error.message },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -35,18 +35,19 @@
|
||||
* GET /api/v1/admin/organizations/:id - Get organization details
|
||||
* PATCH /api/v1/admin/organizations/:id - Update organization
|
||||
* GET /api/v1/admin/organizations/:id/members - List organization members
|
||||
* POST /api/v1/admin/organizations/:id/members - Add/update member (validates seat availability)
|
||||
* POST /api/v1/admin/organizations/:id/members - Add/update member in organization
|
||||
* GET /api/v1/admin/organizations/:id/members/:mid - Get member details
|
||||
* PATCH /api/v1/admin/organizations/:id/members/:mid - Update member role
|
||||
* DELETE /api/v1/admin/organizations/:id/members/:mid - Remove member
|
||||
* GET /api/v1/admin/organizations/:id/billing - Get org billing summary
|
||||
* PATCH /api/v1/admin/organizations/:id/billing - Update org usage limit
|
||||
* GET /api/v1/admin/organizations/:id/seats - Get seat analytics
|
||||
* PATCH /api/v1/admin/organizations/:id/seats - Update seat count
|
||||
*
|
||||
* Subscriptions:
|
||||
* GET /api/v1/admin/subscriptions - List all subscriptions
|
||||
* GET /api/v1/admin/subscriptions/:id - Get subscription details
|
||||
* DELETE /api/v1/admin/subscriptions/:id - Cancel subscription (?atPeriodEnd=true for scheduled)
|
||||
* PATCH /api/v1/admin/subscriptions/:id - Update subscription
|
||||
*/
|
||||
|
||||
export type { AdminAuthFailure, AdminAuthResult, AdminAuthSuccess } from '@/app/api/v1/admin/auth'
|
||||
|
||||
@@ -12,9 +12,6 @@
|
||||
* POST /api/v1/admin/organizations/[id]/members
|
||||
*
|
||||
* Add a user to an organization with full billing logic.
|
||||
* Validates seat availability before adding (uses same logic as invitation flow):
|
||||
* - Team plans: checks seats column
|
||||
* - Enterprise plans: checks metadata.seats
|
||||
* Handles Pro usage snapshot and subscription cancellation like the invitation flow.
|
||||
* If user is already a member, updates their role if different.
|
||||
*
|
||||
@@ -32,7 +29,6 @@ import { db } from '@sim/db'
|
||||
import { member, organization, user, userStats } from '@sim/db/schema'
|
||||
import { count, eq } from 'drizzle-orm'
|
||||
import { addUserToOrganization } from '@/lib/billing/organizations/membership'
|
||||
import { requireStripeClient } from '@/lib/billing/stripe-client'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
|
||||
import {
|
||||
@@ -227,29 +223,6 @@ export const POST = withAdminAuthParams<RouteParams>(async (request, context) =>
|
||||
return badRequestResponse(result.error || 'Failed to add member')
|
||||
}
|
||||
|
||||
// Sync Pro subscription cancellation with Stripe (same as invitation flow)
|
||||
if (result.billingActions.proSubscriptionToCancel?.stripeSubscriptionId) {
|
||||
try {
|
||||
const stripe = requireStripeClient()
|
||||
await stripe.subscriptions.update(
|
||||
result.billingActions.proSubscriptionToCancel.stripeSubscriptionId,
|
||||
{ cancel_at_period_end: true }
|
||||
)
|
||||
logger.info('Admin API: Synced Pro cancellation with Stripe', {
|
||||
userId: body.userId,
|
||||
subscriptionId: result.billingActions.proSubscriptionToCancel.subscriptionId,
|
||||
stripeSubscriptionId: result.billingActions.proSubscriptionToCancel.stripeSubscriptionId,
|
||||
})
|
||||
} catch (stripeError) {
|
||||
logger.error('Admin API: Failed to sync Pro cancellation with Stripe', {
|
||||
userId: body.userId,
|
||||
subscriptionId: result.billingActions.proSubscriptionToCancel.subscriptionId,
|
||||
stripeSubscriptionId: result.billingActions.proSubscriptionToCancel.stripeSubscriptionId,
|
||||
error: stripeError,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const data: AdminMember = {
|
||||
id: result.memberId!,
|
||||
userId: body.userId,
|
||||
|
||||
@@ -4,12 +4,26 @@
|
||||
* Get organization seat analytics including member activity.
|
||||
*
|
||||
* Response: AdminSingleResponse<AdminSeatAnalytics>
|
||||
*
|
||||
* PATCH /api/v1/admin/organizations/[id]/seats
|
||||
*
|
||||
* Update organization seat count with Stripe sync (matches user flow).
|
||||
*
|
||||
* Body:
|
||||
* - seats: number - New seat count (positive integer)
|
||||
*
|
||||
* Response: AdminSingleResponse<{ success: true, seats: number, plan: string, stripeUpdated?: boolean }>
|
||||
*/
|
||||
|
||||
import { db } from '@sim/db'
|
||||
import { organization, subscription } from '@sim/db/schema'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { requireStripeClient } from '@/lib/billing/stripe-client'
|
||||
import { getOrganizationSeatAnalytics } from '@/lib/billing/validation/seat-management'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
|
||||
import {
|
||||
badRequestResponse,
|
||||
internalErrorResponse,
|
||||
notFoundResponse,
|
||||
singleResponse,
|
||||
@@ -61,3 +75,122 @@ export const GET = withAdminAuthParams<RouteParams>(async (_, context) => {
|
||||
return internalErrorResponse('Failed to get organization seats')
|
||||
}
|
||||
})
|
||||
|
||||
export const PATCH = withAdminAuthParams<RouteParams>(async (request, context) => {
|
||||
const { id: organizationId } = await context.params
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
|
||||
if (typeof body.seats !== 'number' || body.seats < 1 || !Number.isInteger(body.seats)) {
|
||||
return badRequestResponse('seats must be a positive integer')
|
||||
}
|
||||
|
||||
const [orgData] = await db
|
||||
.select({ id: organization.id })
|
||||
.from(organization)
|
||||
.where(eq(organization.id, organizationId))
|
||||
.limit(1)
|
||||
|
||||
if (!orgData) {
|
||||
return notFoundResponse('Organization')
|
||||
}
|
||||
|
||||
const [subData] = await db
|
||||
.select()
|
||||
.from(subscription)
|
||||
.where(and(eq(subscription.referenceId, organizationId), eq(subscription.status, 'active')))
|
||||
.limit(1)
|
||||
|
||||
if (!subData) {
|
||||
return notFoundResponse('Subscription')
|
||||
}
|
||||
|
||||
const newSeatCount = body.seats
|
||||
let stripeUpdated = false
|
||||
|
||||
if (subData.plan === 'enterprise') {
|
||||
const currentMetadata = (subData.metadata as Record<string, unknown>) || {}
|
||||
const newMetadata = {
|
||||
...currentMetadata,
|
||||
seats: newSeatCount,
|
||||
}
|
||||
|
||||
await db
|
||||
.update(subscription)
|
||||
.set({ metadata: newMetadata })
|
||||
.where(eq(subscription.id, subData.id))
|
||||
|
||||
logger.info(`Admin API: Updated enterprise seats for organization ${organizationId}`, {
|
||||
seats: newSeatCount,
|
||||
})
|
||||
} else if (subData.plan === 'team') {
|
||||
if (subData.stripeSubscriptionId) {
|
||||
const stripe = requireStripeClient()
|
||||
|
||||
const stripeSubscription = await stripe.subscriptions.retrieve(subData.stripeSubscriptionId)
|
||||
|
||||
if (stripeSubscription.status !== 'active') {
|
||||
return badRequestResponse('Stripe subscription is not active')
|
||||
}
|
||||
|
||||
const subscriptionItem = stripeSubscription.items.data[0]
|
||||
if (!subscriptionItem) {
|
||||
return internalErrorResponse('No subscription item found in Stripe subscription')
|
||||
}
|
||||
|
||||
const currentSeats = subData.seats || 1
|
||||
|
||||
logger.info('Admin API: Updating Stripe subscription quantity', {
|
||||
organizationId,
|
||||
stripeSubscriptionId: subData.stripeSubscriptionId,
|
||||
subscriptionItemId: subscriptionItem.id,
|
||||
currentSeats,
|
||||
newSeatCount,
|
||||
})
|
||||
|
||||
await stripe.subscriptions.update(subData.stripeSubscriptionId, {
|
||||
items: [
|
||||
{
|
||||
id: subscriptionItem.id,
|
||||
quantity: newSeatCount,
|
||||
},
|
||||
],
|
||||
proration_behavior: 'create_prorations',
|
||||
})
|
||||
|
||||
stripeUpdated = true
|
||||
}
|
||||
|
||||
await db
|
||||
.update(subscription)
|
||||
.set({ seats: newSeatCount })
|
||||
.where(eq(subscription.id, subData.id))
|
||||
|
||||
logger.info(`Admin API: Updated team seats for organization ${organizationId}`, {
|
||||
seats: newSeatCount,
|
||||
stripeUpdated,
|
||||
})
|
||||
} else {
|
||||
await db
|
||||
.update(subscription)
|
||||
.set({ seats: newSeatCount })
|
||||
.where(eq(subscription.id, subData.id))
|
||||
|
||||
logger.info(`Admin API: Updated seats for organization ${organizationId}`, {
|
||||
seats: newSeatCount,
|
||||
plan: subData.plan,
|
||||
})
|
||||
}
|
||||
|
||||
return singleResponse({
|
||||
success: true,
|
||||
seats: newSeatCount,
|
||||
plan: subData.plan,
|
||||
stripeUpdated,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Admin API: Failed to update organization seats', { error, organizationId })
|
||||
return internalErrorResponse('Failed to update organization seats')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -5,28 +5,28 @@
|
||||
*
|
||||
* Response: AdminSingleResponse<AdminSubscription>
|
||||
*
|
||||
* DELETE /api/v1/admin/subscriptions/[id]
|
||||
* PATCH /api/v1/admin/subscriptions/[id]
|
||||
*
|
||||
* Cancel a subscription by triggering Stripe cancellation.
|
||||
* The Stripe webhook handles all cleanup (same as platform cancellation):
|
||||
* - Updates subscription status to canceled
|
||||
* - Bills final period overages
|
||||
* - Resets usage
|
||||
* - Restores member Pro subscriptions (for team/enterprise)
|
||||
* - Deletes organization (for team/enterprise)
|
||||
* - Syncs usage limits to free tier
|
||||
* Update subscription details with optional side effects.
|
||||
*
|
||||
* Query Parameters:
|
||||
* - atPeriodEnd?: boolean - Schedule cancellation at period end instead of immediate (default: false)
|
||||
* - reason?: string - Reason for cancellation (for audit logging)
|
||||
* Body:
|
||||
* - plan?: string - New plan (free, pro, team, enterprise)
|
||||
* - status?: string - New status (active, canceled, etc.)
|
||||
* - seats?: number - Seat count (for team plans)
|
||||
* - metadata?: object - Subscription metadata (for enterprise)
|
||||
* - periodStart?: string - Period start (ISO date)
|
||||
* - periodEnd?: string - Period end (ISO date)
|
||||
* - cancelAtPeriodEnd?: boolean - Cancel at period end flag
|
||||
* - syncLimits?: boolean - Sync usage limits for affected users (default: false)
|
||||
* - reason?: string - Reason for the change (for audit logging)
|
||||
*
|
||||
* Response: { success: true, message: string, subscriptionId: string, atPeriodEnd: boolean }
|
||||
* Response: AdminSingleResponse<AdminSubscription & { sideEffects }>
|
||||
*/
|
||||
|
||||
import { db } from '@sim/db'
|
||||
import { subscription } from '@sim/db/schema'
|
||||
import { member, subscription } from '@sim/db/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { requireStripeClient } from '@/lib/billing/stripe-client'
|
||||
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
|
||||
import {
|
||||
@@ -43,6 +43,9 @@ interface RouteParams {
|
||||
id: string
|
||||
}
|
||||
|
||||
const VALID_PLANS = ['free', 'pro', 'team', 'enterprise']
|
||||
const VALID_STATUSES = ['active', 'canceled', 'past_due', 'unpaid', 'trialing', 'incomplete']
|
||||
|
||||
export const GET = withAdminAuthParams<RouteParams>(async (_, context) => {
|
||||
const { id: subscriptionId } = await context.params
|
||||
|
||||
@@ -66,13 +69,14 @@ export const GET = withAdminAuthParams<RouteParams>(async (_, context) => {
|
||||
}
|
||||
})
|
||||
|
||||
export const DELETE = withAdminAuthParams<RouteParams>(async (request, context) => {
|
||||
export const PATCH = withAdminAuthParams<RouteParams>(async (request, context) => {
|
||||
const { id: subscriptionId } = await context.params
|
||||
const url = new URL(request.url)
|
||||
const atPeriodEnd = url.searchParams.get('atPeriodEnd') === 'true'
|
||||
const reason = url.searchParams.get('reason') || 'Admin cancellation (no reason provided)'
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const syncLimits = body.syncLimits === true
|
||||
const reason = body.reason || 'Admin update (no reason provided)'
|
||||
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(subscription)
|
||||
@@ -83,70 +87,150 @@ export const DELETE = withAdminAuthParams<RouteParams>(async (request, context)
|
||||
return notFoundResponse('Subscription')
|
||||
}
|
||||
|
||||
if (existing.status === 'canceled') {
|
||||
return badRequestResponse('Subscription is already canceled')
|
||||
const updateData: Record<string, unknown> = {}
|
||||
const warnings: string[] = []
|
||||
|
||||
if (body.plan !== undefined) {
|
||||
if (!VALID_PLANS.includes(body.plan)) {
|
||||
return badRequestResponse(`plan must be one of: ${VALID_PLANS.join(', ')}`)
|
||||
}
|
||||
if (body.plan !== existing.plan) {
|
||||
warnings.push(
|
||||
`Plan change from ${existing.plan} to ${body.plan}. This does NOT update Stripe - manual sync required.`
|
||||
)
|
||||
}
|
||||
updateData.plan = body.plan
|
||||
}
|
||||
|
||||
if (!existing.stripeSubscriptionId) {
|
||||
return badRequestResponse('Subscription has no Stripe subscription ID')
|
||||
if (body.status !== undefined) {
|
||||
if (!VALID_STATUSES.includes(body.status)) {
|
||||
return badRequestResponse(`status must be one of: ${VALID_STATUSES.join(', ')}`)
|
||||
}
|
||||
if (body.status !== existing.status) {
|
||||
warnings.push(
|
||||
`Status change from ${existing.status} to ${body.status}. This does NOT update Stripe - manual sync required.`
|
||||
)
|
||||
}
|
||||
updateData.status = body.status
|
||||
}
|
||||
|
||||
const stripe = requireStripeClient()
|
||||
|
||||
if (atPeriodEnd) {
|
||||
// Schedule cancellation at period end
|
||||
await stripe.subscriptions.update(existing.stripeSubscriptionId, {
|
||||
cancel_at_period_end: true,
|
||||
})
|
||||
|
||||
// Update DB (webhooks don't sync cancelAtPeriodEnd)
|
||||
await db
|
||||
.update(subscription)
|
||||
.set({ cancelAtPeriodEnd: true })
|
||||
.where(eq(subscription.id, subscriptionId))
|
||||
|
||||
logger.info('Admin API: Scheduled subscription cancellation at period end', {
|
||||
subscriptionId,
|
||||
stripeSubscriptionId: existing.stripeSubscriptionId,
|
||||
plan: existing.plan,
|
||||
referenceId: existing.referenceId,
|
||||
periodEnd: existing.periodEnd,
|
||||
reason,
|
||||
})
|
||||
|
||||
return singleResponse({
|
||||
success: true,
|
||||
message: 'Subscription scheduled to cancel at period end.',
|
||||
subscriptionId,
|
||||
stripeSubscriptionId: existing.stripeSubscriptionId,
|
||||
atPeriodEnd: true,
|
||||
periodEnd: existing.periodEnd?.toISOString() ?? null,
|
||||
})
|
||||
if (body.seats !== undefined) {
|
||||
if (typeof body.seats !== 'number' || body.seats < 1 || !Number.isInteger(body.seats)) {
|
||||
return badRequestResponse('seats must be a positive integer')
|
||||
}
|
||||
updateData.seats = body.seats
|
||||
}
|
||||
|
||||
// Immediate cancellation
|
||||
await stripe.subscriptions.cancel(existing.stripeSubscriptionId, {
|
||||
prorate: true,
|
||||
invoice_now: true,
|
||||
})
|
||||
if (body.metadata !== undefined) {
|
||||
if (typeof body.metadata !== 'object' || body.metadata === null) {
|
||||
return badRequestResponse('metadata must be an object')
|
||||
}
|
||||
updateData.metadata = {
|
||||
...((existing.metadata as Record<string, unknown>) || {}),
|
||||
...body.metadata,
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('Admin API: Triggered immediate subscription cancellation on Stripe', {
|
||||
subscriptionId,
|
||||
stripeSubscriptionId: existing.stripeSubscriptionId,
|
||||
plan: existing.plan,
|
||||
referenceId: existing.referenceId,
|
||||
if (body.periodStart !== undefined) {
|
||||
const date = new Date(body.periodStart)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return badRequestResponse('periodStart must be a valid ISO date')
|
||||
}
|
||||
updateData.periodStart = date
|
||||
}
|
||||
|
||||
if (body.periodEnd !== undefined) {
|
||||
const date = new Date(body.periodEnd)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return badRequestResponse('periodEnd must be a valid ISO date')
|
||||
}
|
||||
updateData.periodEnd = date
|
||||
}
|
||||
|
||||
if (body.cancelAtPeriodEnd !== undefined) {
|
||||
if (typeof body.cancelAtPeriodEnd !== 'boolean') {
|
||||
return badRequestResponse('cancelAtPeriodEnd must be a boolean')
|
||||
}
|
||||
updateData.cancelAtPeriodEnd = body.cancelAtPeriodEnd
|
||||
}
|
||||
|
||||
if (Object.keys(updateData).length === 0) {
|
||||
return badRequestResponse('No valid fields to update')
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(subscription)
|
||||
.set(updateData)
|
||||
.where(eq(subscription.id, subscriptionId))
|
||||
.returning()
|
||||
|
||||
const sideEffects: {
|
||||
limitsSynced: boolean
|
||||
usersAffected: string[]
|
||||
errors: string[]
|
||||
} = {
|
||||
limitsSynced: false,
|
||||
usersAffected: [],
|
||||
errors: [],
|
||||
}
|
||||
|
||||
if (syncLimits) {
|
||||
try {
|
||||
const referenceId = updated.referenceId
|
||||
|
||||
if (['free', 'pro'].includes(updated.plan)) {
|
||||
await syncUsageLimitsFromSubscription(referenceId)
|
||||
sideEffects.usersAffected.push(referenceId)
|
||||
sideEffects.limitsSynced = true
|
||||
} else if (['team', 'enterprise'].includes(updated.plan)) {
|
||||
const members = await db
|
||||
.select({ userId: member.userId })
|
||||
.from(member)
|
||||
.where(eq(member.organizationId, referenceId))
|
||||
|
||||
for (const m of members) {
|
||||
try {
|
||||
await syncUsageLimitsFromSubscription(m.userId)
|
||||
sideEffects.usersAffected.push(m.userId)
|
||||
} catch (memberError) {
|
||||
sideEffects.errors.push(`Failed to sync limits for user ${m.userId}`)
|
||||
logger.error('Admin API: Failed to sync limits for member', {
|
||||
userId: m.userId,
|
||||
error: memberError,
|
||||
})
|
||||
}
|
||||
}
|
||||
sideEffects.limitsSynced = members.length > 0
|
||||
}
|
||||
|
||||
logger.info('Admin API: Synced usage limits after subscription update', {
|
||||
subscriptionId,
|
||||
usersAffected: sideEffects.usersAffected.length,
|
||||
})
|
||||
} catch (syncError) {
|
||||
sideEffects.errors.push('Failed to sync usage limits')
|
||||
logger.error('Admin API: Failed to sync usage limits', {
|
||||
subscriptionId,
|
||||
error: syncError,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Admin API: Updated subscription ${subscriptionId}`, {
|
||||
fields: Object.keys(updateData),
|
||||
previousPlan: existing.plan,
|
||||
previousStatus: existing.status,
|
||||
syncLimits,
|
||||
reason,
|
||||
})
|
||||
|
||||
return singleResponse({
|
||||
success: true,
|
||||
message: 'Subscription cancellation triggered. Webhook will complete cleanup.',
|
||||
subscriptionId,
|
||||
stripeSubscriptionId: existing.stripeSubscriptionId,
|
||||
atPeriodEnd: false,
|
||||
...toAdminSubscription(updated),
|
||||
sideEffects,
|
||||
warnings,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Admin API: Failed to cancel subscription', { error, subscriptionId })
|
||||
return internalErrorResponse('Failed to cancel subscription')
|
||||
logger.error('Admin API: Failed to update subscription', { error, subscriptionId })
|
||||
return internalErrorResponse('Failed to update subscription')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { authenticateApiKeyFromHeader, updateApiKeyLastUsed } from '@/lib/api-key/service'
|
||||
import { ANONYMOUS_USER_ID } from '@/lib/auth/constants'
|
||||
import { isAuthDisabled } from '@/lib/core/config/feature-flags'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('V1Auth')
|
||||
@@ -15,14 +13,6 @@ export interface AuthResult {
|
||||
}
|
||||
|
||||
export async function authenticateV1Request(request: NextRequest): Promise<AuthResult> {
|
||||
if (isAuthDisabled) {
|
||||
return {
|
||||
authenticated: true,
|
||||
userId: ANONYMOUS_USER_ID,
|
||||
keyType: 'personal',
|
||||
}
|
||||
}
|
||||
|
||||
const apiKey = request.headers.get('x-api-key')
|
||||
|
||||
if (!apiKey) {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import OpenAI, { AzureOpenAI } from 'openai'
|
||||
import { checkAndBillOverageThreshold } from '@/lib/billing/threshold-billing'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { getCostMultiplier, isBillingEnabled } from '@/lib/core/config/feature-flags'
|
||||
import { getCostMultiplier, isBillingEnabled } from '@/lib/core/config/environment'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getModelPricing } from '@/providers/utils'
|
||||
|
||||
@@ -3,7 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { validate as uuidValidate, v4 as uuidv4 } from 'uuid'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { isTriggerDevEnabled } from '@/lib/core/config/feature-flags'
|
||||
import { env, isTruthy } from '@/lib/core/config/env'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { SSE_HEADERS } from '@/lib/core/utils/sse'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
@@ -236,8 +236,9 @@ type AsyncExecutionParams = {
|
||||
*/
|
||||
async function handleAsyncExecution(params: AsyncExecutionParams): Promise<NextResponse> {
|
||||
const { requestId, workflowId, userId, input, triggerType } = params
|
||||
const useTrigger = isTruthy(env.TRIGGER_DEV_ENABLED)
|
||||
|
||||
if (!isTriggerDevEnabled) {
|
||||
if (!useTrigger) {
|
||||
logger.warn(`[${requestId}] Async mode requested but TRIGGER_DEV_ENABLED is false`)
|
||||
return NextResponse.json(
|
||||
{ error: 'Async execution is not enabled. Set TRIGGER_DEV_ENABLED=true to use async mode.' },
|
||||
|
||||
@@ -80,6 +80,10 @@ export function VoiceInterface({
|
||||
const currentStateRef = useRef<'idle' | 'listening' | 'agent_speaking'>('idle')
|
||||
const isCallEndedRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
isCallEndedRef.current = false
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
currentStateRef.current = state
|
||||
}, [state])
|
||||
@@ -119,6 +123,8 @@ export function VoiceInterface({
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (isCallEndedRef.current) return
|
||||
|
||||
if (isPlayingAudio && state !== 'agent_speaking') {
|
||||
clearResponseTimeout()
|
||||
setState('agent_speaking')
|
||||
@@ -139,6 +145,9 @@ export function VoiceInterface({
|
||||
}
|
||||
}
|
||||
} else if (!isPlayingAudio && state === 'agent_speaking') {
|
||||
// Don't unmute/restart if call has ended
|
||||
if (isCallEndedRef.current) return
|
||||
|
||||
setState('idle')
|
||||
setCurrentTranscript('')
|
||||
|
||||
@@ -226,6 +235,8 @@ export function VoiceInterface({
|
||||
recognition.onstart = () => {}
|
||||
|
||||
recognition.onresult = (event: SpeechRecognitionEvent) => {
|
||||
if (isCallEndedRef.current) return
|
||||
|
||||
const currentState = currentStateRef.current
|
||||
|
||||
if (isMutedRef.current || currentState !== 'listening') {
|
||||
@@ -303,6 +314,8 @@ export function VoiceInterface({
|
||||
}, [isSupported, onVoiceTranscript, setResponseTimeout])
|
||||
|
||||
const startListening = useCallback(() => {
|
||||
if (isCallEndedRef.current) return
|
||||
|
||||
if (!isInitialized || isMuted || state !== 'idle') {
|
||||
return
|
||||
}
|
||||
@@ -320,6 +333,9 @@ export function VoiceInterface({
|
||||
}, [isInitialized, isMuted, state])
|
||||
|
||||
const stopListening = useCallback(() => {
|
||||
// Don't process if call has ended
|
||||
if (isCallEndedRef.current) return
|
||||
|
||||
setState('idle')
|
||||
setCurrentTranscript('')
|
||||
|
||||
@@ -333,12 +349,15 @@ export function VoiceInterface({
|
||||
}, [])
|
||||
|
||||
const handleInterrupt = useCallback(() => {
|
||||
if (isCallEndedRef.current) return
|
||||
|
||||
if (state === 'agent_speaking') {
|
||||
onInterrupt?.()
|
||||
setState('listening')
|
||||
setCurrentTranscript('')
|
||||
|
||||
setIsMuted(false)
|
||||
isMutedRef.current = false
|
||||
if (mediaStreamRef.current) {
|
||||
mediaStreamRef.current.getAudioTracks().forEach((track) => {
|
||||
track.enabled = true
|
||||
@@ -356,11 +375,22 @@ export function VoiceInterface({
|
||||
}, [state, onInterrupt])
|
||||
|
||||
const handleCallEnd = useCallback(() => {
|
||||
// Mark call as ended FIRST to prevent any effects from restarting recognition
|
||||
isCallEndedRef.current = true
|
||||
|
||||
// Set muted to true to prevent auto-start effect from triggering
|
||||
setIsMuted(true)
|
||||
isMutedRef.current = true
|
||||
|
||||
setState('idle')
|
||||
setCurrentTranscript('')
|
||||
setIsMuted(false)
|
||||
|
||||
// Immediately disable audio tracks to stop listening
|
||||
if (mediaStreamRef.current) {
|
||||
mediaStreamRef.current.getAudioTracks().forEach((track) => {
|
||||
track.enabled = false
|
||||
})
|
||||
}
|
||||
|
||||
if (recognitionRef.current) {
|
||||
try {
|
||||
@@ -377,6 +407,8 @@ export function VoiceInterface({
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (isCallEndedRef.current) return
|
||||
|
||||
if (event.code === 'Space') {
|
||||
event.preventDefault()
|
||||
handleInterrupt()
|
||||
@@ -388,6 +420,8 @@ export function VoiceInterface({
|
||||
}, [handleInterrupt])
|
||||
|
||||
const toggleMute = useCallback(() => {
|
||||
if (isCallEndedRef.current) return
|
||||
|
||||
if (state === 'agent_speaking') {
|
||||
handleInterrupt()
|
||||
return
|
||||
@@ -395,6 +429,7 @@ export function VoiceInterface({
|
||||
|
||||
const newMutedState = !isMuted
|
||||
setIsMuted(newMutedState)
|
||||
isMutedRef.current = newMutedState
|
||||
|
||||
if (mediaStreamRef.current) {
|
||||
mediaStreamRef.current.getAudioTracks().forEach((track) => {
|
||||
@@ -417,6 +452,8 @@ export function VoiceInterface({
|
||||
}, [isSupported, setupSpeechRecognition, setupAudio])
|
||||
|
||||
useEffect(() => {
|
||||
if (isCallEndedRef.current) return
|
||||
|
||||
if (isInitialized && !isMuted && state === 'idle') {
|
||||
startListening()
|
||||
}
|
||||
|
||||
@@ -47,16 +47,12 @@ export function FileSelectorInput({
|
||||
const [projectIdValueFromStore] = useSubBlockValue(blockId, 'projectId')
|
||||
const [planIdValueFromStore] = useSubBlockValue(blockId, 'planId')
|
||||
const [teamIdValueFromStore] = useSubBlockValue(blockId, 'teamId')
|
||||
const [siteIdValueFromStore] = useSubBlockValue(blockId, 'siteId')
|
||||
const [collectionIdValueFromStore] = useSubBlockValue(blockId, 'collectionId')
|
||||
|
||||
const connectedCredential = previewContextValues?.credential ?? connectedCredentialFromStore
|
||||
const domainValue = previewContextValues?.domain ?? domainValueFromStore
|
||||
const projectIdValue = previewContextValues?.projectId ?? projectIdValueFromStore
|
||||
const planIdValue = previewContextValues?.planId ?? planIdValueFromStore
|
||||
const teamIdValue = previewContextValues?.teamId ?? teamIdValueFromStore
|
||||
const siteIdValue = previewContextValues?.siteId ?? siteIdValueFromStore
|
||||
const collectionIdValue = previewContextValues?.collectionId ?? collectionIdValueFromStore
|
||||
|
||||
const normalizedCredentialId =
|
||||
typeof connectedCredential === 'string'
|
||||
@@ -79,8 +75,6 @@ export function FileSelectorInput({
|
||||
projectId: (projectIdValue as string) || undefined,
|
||||
planId: (planIdValue as string) || undefined,
|
||||
teamId: (teamIdValue as string) || undefined,
|
||||
siteId: (siteIdValue as string) || undefined,
|
||||
collectionId: (collectionIdValue as string) || undefined,
|
||||
})
|
||||
}, [
|
||||
subBlock,
|
||||
@@ -90,8 +84,6 @@ export function FileSelectorInput({
|
||||
projectIdValue,
|
||||
planIdValue,
|
||||
teamIdValue,
|
||||
siteIdValue,
|
||||
collectionIdValue,
|
||||
])
|
||||
|
||||
const missingCredential = !normalizedCredentialId
|
||||
@@ -105,10 +97,6 @@ export function FileSelectorInput({
|
||||
!selectorResolution.context.projectId
|
||||
const missingPlan =
|
||||
selectorResolution?.key === 'microsoft.planner' && !selectorResolution.context.planId
|
||||
const missingSite =
|
||||
selectorResolution?.key === 'webflow.collections' && !selectorResolution.context.siteId
|
||||
const missingCollection =
|
||||
selectorResolution?.key === 'webflow.items' && !selectorResolution.context.collectionId
|
||||
|
||||
const disabledReason =
|
||||
finalDisabled ||
|
||||
@@ -117,8 +105,6 @@ export function FileSelectorInput({
|
||||
missingDomain ||
|
||||
missingProject ||
|
||||
missingPlan ||
|
||||
missingSite ||
|
||||
missingCollection ||
|
||||
!selectorResolution?.key
|
||||
|
||||
if (!selectorResolution?.key) {
|
||||
|
||||
@@ -43,12 +43,14 @@ export function ProjectSelectorInput({
|
||||
|
||||
// Use previewContextValues if provided (for tools inside agent blocks), otherwise use store values
|
||||
const connectedCredential = previewContextValues?.credential ?? connectedCredentialFromStore
|
||||
const linearCredential = previewContextValues?.credential ?? connectedCredentialFromStore
|
||||
const linearTeamId = previewContextValues?.teamId ?? linearTeamIdFromStore
|
||||
const jiraDomain = previewContextValues?.domain ?? jiraDomainFromStore
|
||||
|
||||
// Derive provider from serviceId using OAuth config
|
||||
const serviceId = subBlock.serviceId || ''
|
||||
const effectiveProviderId = useMemo(() => getProviderIdFromServiceId(serviceId), [serviceId])
|
||||
const isLinear = serviceId === 'linear'
|
||||
|
||||
const { isForeignCredential } = useForeignCredential(
|
||||
effectiveProviderId,
|
||||
@@ -63,6 +65,7 @@ export function ProjectSelectorInput({
|
||||
})
|
||||
|
||||
// Jira/Discord upstream fields - use values from previewContextValues or store
|
||||
const jiraCredential = connectedCredential
|
||||
const domain = (jiraDomain as string) || ''
|
||||
|
||||
// Verify Jira credential belongs to current user; if not, treat as absent
|
||||
@@ -81,11 +84,19 @@ export function ProjectSelectorInput({
|
||||
const selectorResolution = useMemo(() => {
|
||||
return resolveSelectorForSubBlock(subBlock, {
|
||||
workflowId: workflowIdFromUrl || undefined,
|
||||
credentialId: (connectedCredential as string) || undefined,
|
||||
credentialId: (isLinear ? linearCredential : jiraCredential) as string | undefined,
|
||||
domain,
|
||||
teamId: (linearTeamId as string) || undefined,
|
||||
})
|
||||
}, [subBlock, workflowIdFromUrl, connectedCredential, domain, linearTeamId])
|
||||
}, [
|
||||
subBlock,
|
||||
workflowIdFromUrl,
|
||||
isLinear,
|
||||
linearCredential,
|
||||
jiraCredential,
|
||||
domain,
|
||||
linearTeamId,
|
||||
])
|
||||
|
||||
const missingCredential = !selectorResolution?.context.credentialId
|
||||
|
||||
|
||||
@@ -47,16 +47,12 @@ export function FileSelectorInput({
|
||||
const [projectIdValueFromStore] = useSubBlockValue(blockId, 'projectId')
|
||||
const [planIdValueFromStore] = useSubBlockValue(blockId, 'planId')
|
||||
const [teamIdValueFromStore] = useSubBlockValue(blockId, 'teamId')
|
||||
const [siteIdValueFromStore] = useSubBlockValue(blockId, 'siteId')
|
||||
const [collectionIdValueFromStore] = useSubBlockValue(blockId, 'collectionId')
|
||||
|
||||
const connectedCredential = previewContextValues?.credential ?? connectedCredentialFromStore
|
||||
const domainValue = previewContextValues?.domain ?? domainValueFromStore
|
||||
const projectIdValue = previewContextValues?.projectId ?? projectIdValueFromStore
|
||||
const planIdValue = previewContextValues?.planId ?? planIdValueFromStore
|
||||
const teamIdValue = previewContextValues?.teamId ?? teamIdValueFromStore
|
||||
const siteIdValue = previewContextValues?.siteId ?? siteIdValueFromStore
|
||||
const collectionIdValue = previewContextValues?.collectionId ?? collectionIdValueFromStore
|
||||
|
||||
const normalizedCredentialId =
|
||||
typeof connectedCredential === 'string'
|
||||
@@ -79,8 +75,6 @@ export function FileSelectorInput({
|
||||
projectId: (projectIdValue as string) || undefined,
|
||||
planId: (planIdValue as string) || undefined,
|
||||
teamId: (teamIdValue as string) || undefined,
|
||||
siteId: (siteIdValue as string) || undefined,
|
||||
collectionId: (collectionIdValue as string) || undefined,
|
||||
})
|
||||
}, [
|
||||
subBlock,
|
||||
@@ -90,8 +84,6 @@ export function FileSelectorInput({
|
||||
projectIdValue,
|
||||
planIdValue,
|
||||
teamIdValue,
|
||||
siteIdValue,
|
||||
collectionIdValue,
|
||||
])
|
||||
|
||||
const missingCredential = !normalizedCredentialId
|
||||
@@ -105,10 +97,6 @@ export function FileSelectorInput({
|
||||
!selectorResolution?.context.projectId
|
||||
const missingPlan =
|
||||
selectorResolution?.key === 'microsoft.planner' && !selectorResolution?.context.planId
|
||||
const missingSite =
|
||||
selectorResolution?.key === 'webflow.collections' && !selectorResolution?.context.siteId
|
||||
const missingCollection =
|
||||
selectorResolution?.key === 'webflow.items' && !selectorResolution?.context.collectionId
|
||||
|
||||
const disabledReason =
|
||||
finalDisabled ||
|
||||
@@ -117,8 +105,6 @@ export function FileSelectorInput({
|
||||
missingDomain ||
|
||||
missingProject ||
|
||||
missingPlan ||
|
||||
missingSite ||
|
||||
missingCollection ||
|
||||
!selectorResolution?.key
|
||||
|
||||
if (!selectorResolution?.key) {
|
||||
|
||||
@@ -579,10 +579,8 @@ const WorkflowContent = React.memo(() => {
|
||||
const node = nodeIndex.get(id)
|
||||
if (!node) return false
|
||||
|
||||
const blockParentId = blocks[id]?.data?.parentId
|
||||
const dropParentId = containerAtPoint?.loopId
|
||||
if (dropParentId !== blockParentId) return false
|
||||
|
||||
// If dropping outside containers, ignore blocks that are inside a container
|
||||
if (!containerAtPoint && blocks[id]?.data?.parentId) return false
|
||||
return true
|
||||
})
|
||||
.map(([id, block]) => {
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
} from '@/components/emcn'
|
||||
import { Input, Skeleton } from '@/components/ui'
|
||||
import { signOut, useSession } from '@/lib/auth/auth-client'
|
||||
import { ANONYMOUS_USER_ID } from '@/lib/auth/constants'
|
||||
import { useBrandConfig } from '@/lib/branding/branding'
|
||||
import { getEnv, isTruthy } from '@/lib/core/config/env'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
@@ -60,7 +59,6 @@ export function General({ onOpenChange }: GeneralProps) {
|
||||
const isLoading = isProfileLoading || isSettingsLoading
|
||||
|
||||
const isTrainingEnabled = isTruthy(getEnv('NEXT_PUBLIC_COPILOT_TRAINING_ENABLED'))
|
||||
const isAuthDisabled = session?.user?.id === ANONYMOUS_USER_ID
|
||||
|
||||
const [isSuperUser, setIsSuperUser] = useState(false)
|
||||
const [loadingSuperUser, setLoadingSuperUser] = useState(true)
|
||||
@@ -463,12 +461,10 @@ export function General({ onOpenChange }: GeneralProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isAuthDisabled && (
|
||||
<div className='mt-auto flex items-center gap-[8px]'>
|
||||
<Button onClick={handleSignOut}>Sign out</Button>
|
||||
<Button onClick={() => setShowResetPasswordModal(true)}>Reset password</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className='mt-auto flex items-center gap-[8px]'>
|
||||
<Button onClick={handleSignOut}>Sign out</Button>
|
||||
<Button onClick={() => setShowResetPasswordModal(true)}>Reset password</Button>
|
||||
</div>
|
||||
|
||||
{/* Password Reset Confirmation Modal */}
|
||||
<Modal open={showResetPasswordModal} onOpenChange={setShowResetPasswordModal}>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Button, Combobox, Input, Switch, Textarea } from '@/components/emcn'
|
||||
import { Skeleton } from '@/components/ui'
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
import { getSubscriptionStatus } from '@/lib/billing/client/utils'
|
||||
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
|
||||
import { isBillingEnabled } from '@/lib/core/config/environment'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
@@ -26,7 +26,7 @@ import { McpIcon } from '@/components/icons'
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
import { getSubscriptionStatus } from '@/lib/billing/client'
|
||||
import { getEnv, isTruthy } from '@/lib/core/config/env'
|
||||
import { isHosted } from '@/lib/core/config/feature-flags'
|
||||
import { isHosted } from '@/lib/core/config/environment'
|
||||
import { getUserRole } from '@/lib/workspaces/organization'
|
||||
import {
|
||||
ApiKeys,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AgentIcon } from '@/components/icons'
|
||||
import { isHosted } from '@/lib/core/config/feature-flags'
|
||||
import { isHosted } from '@/lib/core/config/environment'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import { AuthMode } from '@/blocks/types'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ChartBarIcon } from '@/components/icons'
|
||||
import { isHosted } from '@/lib/core/config/feature-flags'
|
||||
import { isHosted } from '@/lib/core/config/environment'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import type { BlockConfig, ParamType } from '@/blocks/types'
|
||||
import type { ProviderId } from '@/providers/types'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ShieldCheckIcon } from '@/components/icons'
|
||||
import { isHosted } from '@/lib/core/config/feature-flags'
|
||||
import { isHosted } from '@/lib/core/config/environment'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import { getHostedModels, getProviderIcon } from '@/providers/utils'
|
||||
import { useProvidersStore } from '@/stores/providers/store'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ConnectIcon } from '@/components/icons'
|
||||
import { isHosted } from '@/lib/core/config/feature-flags'
|
||||
import { isHosted } from '@/lib/core/config/environment'
|
||||
import { AuthMode, type BlockConfig } from '@/blocks/types'
|
||||
import type { ProviderId } from '@/providers/types'
|
||||
import {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { TranslateIcon } from '@/components/icons'
|
||||
import { isHosted } from '@/lib/core/config/feature-flags'
|
||||
import { isHosted } from '@/lib/core/config/environment'
|
||||
import { AuthMode, type BlockConfig } from '@/blocks/types'
|
||||
import { getHostedModels, getProviderIcon, providers } from '@/providers/utils'
|
||||
import { useProvidersStore } from '@/stores/providers/store'
|
||||
|
||||
@@ -39,65 +39,19 @@ export const WebflowBlock: BlockConfig<WebflowResponse> = {
|
||||
placeholder: 'Select Webflow account',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'siteId',
|
||||
title: 'Site',
|
||||
type: 'project-selector',
|
||||
canonicalParamId: 'siteId',
|
||||
serviceId: 'webflow',
|
||||
placeholder: 'Select Webflow site',
|
||||
dependsOn: ['credential'],
|
||||
mode: 'basic',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'manualSiteId',
|
||||
title: 'Site ID',
|
||||
type: 'short-input',
|
||||
canonicalParamId: 'siteId',
|
||||
placeholder: 'Enter site ID',
|
||||
mode: 'advanced',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'collectionId',
|
||||
title: 'Collection',
|
||||
type: 'file-selector',
|
||||
canonicalParamId: 'collectionId',
|
||||
serviceId: 'webflow',
|
||||
placeholder: 'Select collection',
|
||||
dependsOn: ['credential', 'siteId'],
|
||||
mode: 'basic',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'manualCollectionId',
|
||||
title: 'Collection ID',
|
||||
type: 'short-input',
|
||||
canonicalParamId: 'collectionId',
|
||||
placeholder: 'Enter collection ID',
|
||||
mode: 'advanced',
|
||||
dependsOn: ['credential'],
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'itemId',
|
||||
title: 'Item',
|
||||
type: 'file-selector',
|
||||
canonicalParamId: 'itemId',
|
||||
serviceId: 'webflow',
|
||||
placeholder: 'Select item',
|
||||
dependsOn: ['credential', 'collectionId'],
|
||||
mode: 'basic',
|
||||
condition: { field: 'operation', value: ['get', 'update', 'delete'] },
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'manualItemId',
|
||||
title: 'Item ID',
|
||||
type: 'short-input',
|
||||
canonicalParamId: 'itemId',
|
||||
placeholder: 'Enter item ID',
|
||||
mode: 'advanced',
|
||||
placeholder: 'ID of the item',
|
||||
condition: { field: 'operation', value: ['get', 'update', 'delete'] },
|
||||
required: true,
|
||||
},
|
||||
@@ -154,17 +108,7 @@ export const WebflowBlock: BlockConfig<WebflowResponse> = {
|
||||
}
|
||||
},
|
||||
params: (params) => {
|
||||
const {
|
||||
credential,
|
||||
fieldData,
|
||||
siteId,
|
||||
manualSiteId,
|
||||
collectionId,
|
||||
manualCollectionId,
|
||||
itemId,
|
||||
manualItemId,
|
||||
...rest
|
||||
} = params
|
||||
const { credential, fieldData, ...rest } = params
|
||||
let parsedFieldData: any | undefined
|
||||
|
||||
try {
|
||||
@@ -175,46 +119,15 @@ export const WebflowBlock: BlockConfig<WebflowResponse> = {
|
||||
throw new Error(`Invalid JSON input for ${params.operation} operation: ${error.message}`)
|
||||
}
|
||||
|
||||
const effectiveSiteId = ((siteId as string) || (manualSiteId as string) || '').trim()
|
||||
const effectiveCollectionId = (
|
||||
(collectionId as string) ||
|
||||
(manualCollectionId as string) ||
|
||||
''
|
||||
).trim()
|
||||
const effectiveItemId = ((itemId as string) || (manualItemId as string) || '').trim()
|
||||
|
||||
if (!effectiveSiteId) {
|
||||
throw new Error('Site ID is required')
|
||||
}
|
||||
|
||||
if (!effectiveCollectionId) {
|
||||
throw new Error('Collection ID is required')
|
||||
}
|
||||
|
||||
const baseParams = {
|
||||
credential,
|
||||
siteId: effectiveSiteId,
|
||||
collectionId: effectiveCollectionId,
|
||||
...rest,
|
||||
}
|
||||
|
||||
switch (params.operation) {
|
||||
case 'create':
|
||||
case 'update':
|
||||
if (params.operation === 'update' && !effectiveItemId) {
|
||||
throw new Error('Item ID is required for update operation')
|
||||
}
|
||||
return {
|
||||
...baseParams,
|
||||
itemId: effectiveItemId || undefined,
|
||||
fieldData: parsedFieldData,
|
||||
}
|
||||
case 'get':
|
||||
case 'delete':
|
||||
if (!effectiveItemId) {
|
||||
throw new Error(`Item ID is required for ${params.operation} operation`)
|
||||
}
|
||||
return { ...baseParams, itemId: effectiveItemId }
|
||||
return { ...baseParams, fieldData: parsedFieldData }
|
||||
default:
|
||||
return baseParams
|
||||
}
|
||||
@@ -224,15 +137,12 @@ export const WebflowBlock: BlockConfig<WebflowResponse> = {
|
||||
inputs: {
|
||||
operation: { type: 'string', description: 'Operation to perform' },
|
||||
credential: { type: 'string', description: 'Webflow OAuth access token' },
|
||||
siteId: { type: 'string', description: 'Webflow site identifier' },
|
||||
manualSiteId: { type: 'string', description: 'Manual site identifier' },
|
||||
collectionId: { type: 'string', description: 'Webflow collection identifier' },
|
||||
manualCollectionId: { type: 'string', description: 'Manual collection identifier' },
|
||||
itemId: { type: 'string', description: 'Item identifier' },
|
||||
manualItemId: { type: 'string', description: 'Manual item identifier' },
|
||||
offset: { type: 'number', description: 'Pagination offset' },
|
||||
limit: { type: 'number', description: 'Maximum items to return' },
|
||||
fieldData: { type: 'json', description: 'Item field data' },
|
||||
// Conditional inputs
|
||||
itemId: { type: 'string', description: 'Item identifier' }, // Required for get/update/delete
|
||||
offset: { type: 'number', description: 'Pagination offset' }, // Optional for list
|
||||
limit: { type: 'number', description: 'Maximum items to return' }, // Optional for list
|
||||
fieldData: { type: 'json', description: 'Item field data' }, // Required for create/update
|
||||
},
|
||||
outputs: {
|
||||
items: { type: 'json', description: 'Array of items (list operation)' },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Container, Img, Link, Section, Text } from '@react-email/components'
|
||||
import { getBrandConfig } from '@/lib/branding/branding'
|
||||
import { isHosted } from '@/lib/core/config/feature-flags'
|
||||
import { isHosted } from '@/lib/core/config/environment'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
|
||||
interface UnsubscribeOptions {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from 'vitest'
|
||||
import { isHosted } from '@/lib/core/config/environment'
|
||||
import { getAllBlocks } from '@/blocks'
|
||||
import { BlockType } from '@/executor/constants'
|
||||
import { AgentBlockHandler } from '@/executor/handlers/agent/agent-handler'
|
||||
@@ -10,11 +11,11 @@ import { executeTool } from '@/tools'
|
||||
|
||||
process.env.NEXT_PUBLIC_APP_URL = 'http://localhost:3000'
|
||||
|
||||
vi.mock('@/lib/core/config/feature-flags', () => ({
|
||||
isHosted: false,
|
||||
isProd: false,
|
||||
isDev: true,
|
||||
isTest: false,
|
||||
vi.mock('@/lib/core/config/environment', () => ({
|
||||
isHosted: vi.fn().mockReturnValue(false),
|
||||
isProd: vi.fn().mockReturnValue(false),
|
||||
isDev: vi.fn().mockReturnValue(true),
|
||||
isTest: vi.fn().mockReturnValue(false),
|
||||
getCostMultiplier: vi.fn().mockReturnValue(1),
|
||||
isEmailVerificationEnabled: false,
|
||||
isBillingEnabled: false,
|
||||
@@ -64,6 +65,7 @@ global.fetch = Object.assign(vi.fn(), { preconnect: vi.fn() }) as typeof fetch
|
||||
|
||||
const mockGetAllBlocks = getAllBlocks as Mock
|
||||
const mockExecuteTool = executeTool as Mock
|
||||
const mockIsHosted = isHosted as unknown as Mock
|
||||
const mockGetProviderFromModel = getProviderFromModel as Mock
|
||||
const mockTransformBlockTool = transformBlockTool as Mock
|
||||
const mockFetch = global.fetch as unknown as Mock
|
||||
@@ -118,6 +120,7 @@ describe('AgentBlockHandler', () => {
|
||||
loops: {},
|
||||
} as SerializedWorkflow,
|
||||
}
|
||||
mockIsHosted.mockReturnValue(false)
|
||||
mockGetProviderFromModel.mockReturnValue('mock-provider')
|
||||
|
||||
mockFetch.mockImplementation(() => {
|
||||
@@ -549,6 +552,8 @@ describe('AgentBlockHandler', () => {
|
||||
})
|
||||
|
||||
it('should not require API key for gpt-4o on hosted version', async () => {
|
||||
mockIsHosted.mockReturnValue(true)
|
||||
|
||||
const inputs = {
|
||||
model: 'gpt-4o',
|
||||
systemPrompt: 'You are a helpful assistant.',
|
||||
|
||||
@@ -987,18 +987,21 @@ export class AgentBlockHandler implements BlockHandler {
|
||||
try {
|
||||
const executionData = JSON.parse(executionDataHeader)
|
||||
|
||||
// If execution data contains full content, persist to memory
|
||||
if (ctx && inputs && executionData.output?.content) {
|
||||
const assistantMessage: Message = {
|
||||
role: 'assistant',
|
||||
content: executionData.output.content,
|
||||
}
|
||||
// Fire and forget - don't await
|
||||
memoryService
|
||||
.persistMemoryMessage(ctx, inputs, assistantMessage, block.id)
|
||||
.catch((error) =>
|
||||
logger.error('Failed to persist streaming response to memory:', error)
|
||||
// If execution data contains content or tool calls, persist to memory
|
||||
if (
|
||||
ctx &&
|
||||
inputs &&
|
||||
(executionData.output?.content || executionData.output?.toolCalls?.list?.length)
|
||||
) {
|
||||
const toolCalls = executionData.output?.toolCalls?.list
|
||||
const messages = this.buildMessagesForMemory(executionData.output.content, toolCalls)
|
||||
|
||||
// Fire and forget - don't await, persist all messages
|
||||
Promise.all(
|
||||
messages.map((message) =>
|
||||
memoryService.persistMemoryMessage(ctx, inputs, message, block.id)
|
||||
)
|
||||
).catch((error) => logger.error('Failed to persist streaming response to memory:', error))
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -1117,25 +1120,28 @@ export class AgentBlockHandler implements BlockHandler {
|
||||
return
|
||||
}
|
||||
|
||||
// Extract content from regular response
|
||||
// Extract content and tool calls from regular response
|
||||
const blockOutput = result as any
|
||||
const content = blockOutput?.content
|
||||
const toolCalls = blockOutput?.toolCalls?.list
|
||||
|
||||
if (!content || typeof content !== 'string') {
|
||||
// Build messages to persist
|
||||
const messages = this.buildMessagesForMemory(content, toolCalls)
|
||||
|
||||
if (messages.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const assistantMessage: Message = {
|
||||
role: 'assistant',
|
||||
content,
|
||||
// Persist all messages
|
||||
for (const message of messages) {
|
||||
await memoryService.persistMemoryMessage(ctx, inputs, message, blockId)
|
||||
}
|
||||
|
||||
await memoryService.persistMemoryMessage(ctx, inputs, assistantMessage, blockId)
|
||||
|
||||
logger.debug('Persisted assistant response to memory', {
|
||||
workflowId: ctx.workflowId,
|
||||
memoryType: inputs.memoryType,
|
||||
conversationId: inputs.conversationId,
|
||||
messageCount: messages.length,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to persist response to memory:', error)
|
||||
@@ -1143,6 +1149,69 @@ export class AgentBlockHandler implements BlockHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds messages for memory storage including tool calls and results
|
||||
* Returns proper OpenAI-compatible message format:
|
||||
* - Assistant message with tool_calls array (if tools were used)
|
||||
* - Tool role messages with results (one per tool call)
|
||||
* - Final assistant message with content (if present)
|
||||
*/
|
||||
private buildMessagesForMemory(
|
||||
content: string | undefined,
|
||||
toolCalls: any[] | undefined
|
||||
): Message[] {
|
||||
const messages: Message[] = []
|
||||
|
||||
if (toolCalls?.length) {
|
||||
// Generate stable IDs for each tool call (only if not provided by provider)
|
||||
// Use index to ensure uniqueness even for same tool name in same millisecond
|
||||
const toolCallsWithIds = toolCalls.map((tc: any, index: number) => ({
|
||||
...tc,
|
||||
_stableId:
|
||||
tc.id ||
|
||||
`call_${tc.name}_${Date.now()}_${index}_${Math.random().toString(36).slice(2, 7)}`,
|
||||
}))
|
||||
|
||||
// Add assistant message with tool_calls
|
||||
const formattedToolCalls = toolCallsWithIds.map((tc: any) => ({
|
||||
id: tc._stableId,
|
||||
type: 'function' as const,
|
||||
function: {
|
||||
name: tc.name,
|
||||
arguments: tc.rawArguments || JSON.stringify(tc.arguments || {}),
|
||||
},
|
||||
}))
|
||||
|
||||
messages.push({
|
||||
role: 'assistant',
|
||||
content: null,
|
||||
tool_calls: formattedToolCalls,
|
||||
})
|
||||
|
||||
// Add tool result messages using the same stable IDs
|
||||
for (const tc of toolCallsWithIds) {
|
||||
const resultContent =
|
||||
typeof tc.result === 'string' ? tc.result : JSON.stringify(tc.result || {})
|
||||
messages.push({
|
||||
role: 'tool',
|
||||
content: resultContent,
|
||||
tool_call_id: tc._stableId,
|
||||
name: tc.name, // Store tool name for providers that need it (e.g., Google/Gemini)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Add final assistant response if present
|
||||
if (content && typeof content === 'string') {
|
||||
messages.push({
|
||||
role: 'assistant',
|
||||
content,
|
||||
})
|
||||
}
|
||||
|
||||
return messages
|
||||
}
|
||||
|
||||
private processProviderResponse(
|
||||
response: any,
|
||||
block: SerializedBlock,
|
||||
|
||||
@@ -32,7 +32,7 @@ describe('Memory', () => {
|
||||
})
|
||||
|
||||
describe('applySlidingWindow (message-based)', () => {
|
||||
it('should keep last N conversation messages', () => {
|
||||
it('should keep last N turns (turn = user message + assistant response)', () => {
|
||||
const messages: Message[] = [
|
||||
{ role: 'system', content: 'System prompt' },
|
||||
{ role: 'user', content: 'Message 1' },
|
||||
@@ -43,9 +43,10 @@ describe('Memory', () => {
|
||||
{ role: 'assistant', content: 'Response 3' },
|
||||
]
|
||||
|
||||
const result = (memoryService as any).applySlidingWindow(messages, '4')
|
||||
// Limit to 2 turns: should keep turns 2 and 3
|
||||
const result = (memoryService as any).applySlidingWindow(messages, '2')
|
||||
|
||||
expect(result.length).toBe(5)
|
||||
expect(result.length).toBe(5) // system + 2 turns (4 messages)
|
||||
expect(result[0].role).toBe('system')
|
||||
expect(result[0].content).toBe('System prompt')
|
||||
expect(result[1].content).toBe('Message 2')
|
||||
@@ -113,19 +114,18 @@ describe('Memory', () => {
|
||||
it('should preserve first system message and exclude it from token count', () => {
|
||||
const messages: Message[] = [
|
||||
{ role: 'system', content: 'A' }, // System message - always preserved
|
||||
{ role: 'user', content: 'B' }, // ~1 token
|
||||
{ role: 'assistant', content: 'C' }, // ~1 token
|
||||
{ role: 'user', content: 'D' }, // ~1 token
|
||||
{ role: 'user', content: 'B' }, // ~1 token (turn 1)
|
||||
{ role: 'assistant', content: 'C' }, // ~1 token (turn 1)
|
||||
{ role: 'user', content: 'D' }, // ~1 token (turn 2)
|
||||
]
|
||||
|
||||
// Limit to 2 tokens - should fit system message + last 2 conversation messages (D, C)
|
||||
// Limit to 2 tokens - fits turn 2 (D=1 token), but turn 1 (B+C=2 tokens) would exceed
|
||||
const result = (memoryService as any).applySlidingWindowByTokens(messages, '2', 'gpt-4o')
|
||||
|
||||
// Should have: system message + 2 conversation messages = 3 total
|
||||
expect(result.length).toBe(3)
|
||||
// Should have: system message + turn 2 (1 message) = 2 total
|
||||
expect(result.length).toBe(2)
|
||||
expect(result[0].role).toBe('system') // First system message preserved
|
||||
expect(result[1].content).toBe('C') // Second most recent conversation message
|
||||
expect(result[2].content).toBe('D') // Most recent conversation message
|
||||
expect(result[1].content).toBe('D') // Most recent turn
|
||||
})
|
||||
|
||||
it('should process messages from newest to oldest', () => {
|
||||
@@ -249,29 +249,29 @@ describe('Memory', () => {
|
||||
})
|
||||
|
||||
describe('Token-based vs Message-based comparison', () => {
|
||||
it('should produce different results for same message count limit', () => {
|
||||
it('should produce different results based on turn limits vs token limits', () => {
|
||||
const messages: Message[] = [
|
||||
{ role: 'user', content: 'A' }, // Short message (~1 token)
|
||||
{ role: 'user', content: 'A' }, // Short message (~1 token) - turn 1
|
||||
{
|
||||
role: 'assistant',
|
||||
content: 'This is a much longer response that takes many more tokens',
|
||||
}, // Long message (~15 tokens)
|
||||
{ role: 'user', content: 'B' }, // Short message (~1 token)
|
||||
}, // Long message (~15 tokens) - turn 1
|
||||
{ role: 'user', content: 'B' }, // Short message (~1 token) - turn 2
|
||||
]
|
||||
|
||||
// Message-based: last 2 messages
|
||||
const messageResult = (memoryService as any).applySlidingWindow(messages, '2')
|
||||
expect(messageResult.length).toBe(2)
|
||||
// Turn-based with limit 1: keeps last turn only
|
||||
const messageResult = (memoryService as any).applySlidingWindow(messages, '1')
|
||||
expect(messageResult.length).toBe(1) // Only turn 2 (message B)
|
||||
|
||||
// Token-based: with limit of 10 tokens, might fit all 3 messages or just last 2
|
||||
// Token-based: with limit of 10 tokens, fits turn 2 (1 token) but not turn 1 (~16 tokens)
|
||||
const tokenResult = (memoryService as any).applySlidingWindowByTokens(
|
||||
messages,
|
||||
'10',
|
||||
'gpt-4o'
|
||||
)
|
||||
|
||||
// The long message should affect what fits
|
||||
expect(tokenResult.length).toBeGreaterThanOrEqual(1)
|
||||
// Both should only fit the last turn due to the long assistant message
|
||||
expect(tokenResult.length).toBe(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -202,13 +202,51 @@ export class Memory {
|
||||
const systemMessages = messages.filter((msg) => msg.role === 'system')
|
||||
const conversationMessages = messages.filter((msg) => msg.role !== 'system')
|
||||
|
||||
const recentMessages = conversationMessages.slice(-limit)
|
||||
// Group messages into conversation turns
|
||||
// A turn = user message + any tool calls/results + assistant response
|
||||
const turns = this.groupMessagesIntoTurns(conversationMessages)
|
||||
|
||||
// Take the last N turns
|
||||
const recentTurns = turns.slice(-limit)
|
||||
|
||||
// Flatten back to messages
|
||||
const recentMessages = recentTurns.flat()
|
||||
|
||||
const firstSystemMessage = systemMessages.length > 0 ? [systemMessages[0]] : []
|
||||
|
||||
return [...firstSystemMessage, ...recentMessages]
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups messages into conversation turns.
|
||||
* A turn starts with a user message and includes all subsequent messages
|
||||
* until the next user message (tool calls, tool results, assistant response).
|
||||
*/
|
||||
private groupMessagesIntoTurns(messages: Message[]): Message[][] {
|
||||
const turns: Message[][] = []
|
||||
let currentTurn: Message[] = []
|
||||
|
||||
for (const msg of messages) {
|
||||
if (msg.role === 'user') {
|
||||
// Start a new turn
|
||||
if (currentTurn.length > 0) {
|
||||
turns.push(currentTurn)
|
||||
}
|
||||
currentTurn = [msg]
|
||||
} else {
|
||||
// Add to current turn (assistant, tool, etc.)
|
||||
currentTurn.push(msg)
|
||||
}
|
||||
}
|
||||
|
||||
// Don't forget the last turn
|
||||
if (currentTurn.length > 0) {
|
||||
turns.push(currentTurn)
|
||||
}
|
||||
|
||||
return turns
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply token-based sliding window to limit conversation by token count
|
||||
*
|
||||
@@ -216,6 +254,11 @@ export class Memory {
|
||||
* - For consistency with message-based sliding window, the first system message is preserved
|
||||
* - System messages are excluded from the token count
|
||||
* - This ensures system prompts are always available while limiting conversation history
|
||||
*
|
||||
* Turn handling:
|
||||
* - Messages are grouped into turns (user + tool calls/results + assistant response)
|
||||
* - Complete turns are added to stay within token limit
|
||||
* - This prevents breaking tool call/result pairs
|
||||
*/
|
||||
private applySlidingWindowByTokens(
|
||||
messages: Message[],
|
||||
@@ -233,25 +276,31 @@ export class Memory {
|
||||
const systemMessages = messages.filter((msg) => msg.role === 'system')
|
||||
const conversationMessages = messages.filter((msg) => msg.role !== 'system')
|
||||
|
||||
// Group into turns to keep tool call/result pairs together
|
||||
const turns = this.groupMessagesIntoTurns(conversationMessages)
|
||||
|
||||
const result: Message[] = []
|
||||
let currentTokenCount = 0
|
||||
|
||||
// Add conversation messages from most recent backwards
|
||||
for (let i = conversationMessages.length - 1; i >= 0; i--) {
|
||||
const message = conversationMessages[i]
|
||||
const messageTokens = getAccurateTokenCount(message.content, model)
|
||||
// Add turns from most recent backwards
|
||||
for (let i = turns.length - 1; i >= 0; i--) {
|
||||
const turn = turns[i]
|
||||
const turnTokens = turn.reduce(
|
||||
(sum, msg) => sum + getAccurateTokenCount(msg.content || '', model),
|
||||
0
|
||||
)
|
||||
|
||||
if (currentTokenCount + messageTokens <= tokenLimit) {
|
||||
result.unshift(message)
|
||||
currentTokenCount += messageTokens
|
||||
if (currentTokenCount + turnTokens <= tokenLimit) {
|
||||
result.unshift(...turn)
|
||||
currentTokenCount += turnTokens
|
||||
} else if (result.length === 0) {
|
||||
logger.warn('Single message exceeds token limit, including anyway', {
|
||||
messageTokens,
|
||||
logger.warn('Single turn exceeds token limit, including anyway', {
|
||||
turnTokens,
|
||||
tokenLimit,
|
||||
messageRole: message.role,
|
||||
turnMessages: turn.length,
|
||||
})
|
||||
result.unshift(message)
|
||||
currentTokenCount += messageTokens
|
||||
result.unshift(...turn)
|
||||
currentTokenCount += turnTokens
|
||||
break
|
||||
} else {
|
||||
// Token limit reached, stop processing
|
||||
@@ -259,17 +308,20 @@ export class Memory {
|
||||
}
|
||||
}
|
||||
|
||||
// No need to remove orphaned messages - turns are already complete
|
||||
const cleanedResult = result
|
||||
|
||||
logger.debug('Applied token-based sliding window', {
|
||||
totalMessages: messages.length,
|
||||
conversationMessages: conversationMessages.length,
|
||||
includedMessages: result.length,
|
||||
includedMessages: cleanedResult.length,
|
||||
totalTokens: currentTokenCount,
|
||||
tokenLimit,
|
||||
})
|
||||
|
||||
// Preserve first system message and prepend to results (consistent with message-based window)
|
||||
const firstSystemMessage = systemMessages.length > 0 ? [systemMessages[0]] : []
|
||||
return [...firstSystemMessage, ...result]
|
||||
return [...firstSystemMessage, ...cleanedResult]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -324,7 +376,7 @@ export class Memory {
|
||||
// Count tokens used by system messages first
|
||||
let systemTokenCount = 0
|
||||
for (const msg of systemMessages) {
|
||||
systemTokenCount += getAccurateTokenCount(msg.content, model)
|
||||
systemTokenCount += getAccurateTokenCount(msg.content || '', model)
|
||||
}
|
||||
|
||||
// Calculate remaining tokens available for conversation messages
|
||||
@@ -339,30 +391,36 @@ export class Memory {
|
||||
return systemMessages
|
||||
}
|
||||
|
||||
// Group into turns to keep tool call/result pairs together
|
||||
const turns = this.groupMessagesIntoTurns(conversationMessages)
|
||||
|
||||
const result: Message[] = []
|
||||
let currentTokenCount = 0
|
||||
|
||||
for (let i = conversationMessages.length - 1; i >= 0; i--) {
|
||||
const message = conversationMessages[i]
|
||||
const messageTokens = getAccurateTokenCount(message.content, model)
|
||||
for (let i = turns.length - 1; i >= 0; i--) {
|
||||
const turn = turns[i]
|
||||
const turnTokens = turn.reduce(
|
||||
(sum, msg) => sum + getAccurateTokenCount(msg.content || '', model),
|
||||
0
|
||||
)
|
||||
|
||||
if (currentTokenCount + messageTokens <= remainingTokens) {
|
||||
result.unshift(message)
|
||||
currentTokenCount += messageTokens
|
||||
if (currentTokenCount + turnTokens <= remainingTokens) {
|
||||
result.unshift(...turn)
|
||||
currentTokenCount += turnTokens
|
||||
} else if (result.length === 0) {
|
||||
logger.warn('Single message exceeds remaining context window, including anyway', {
|
||||
messageTokens,
|
||||
logger.warn('Single turn exceeds remaining context window, including anyway', {
|
||||
turnTokens,
|
||||
remainingTokens,
|
||||
systemTokenCount,
|
||||
messageRole: message.role,
|
||||
turnMessages: turn.length,
|
||||
})
|
||||
result.unshift(message)
|
||||
currentTokenCount += messageTokens
|
||||
result.unshift(...turn)
|
||||
currentTokenCount += turnTokens
|
||||
break
|
||||
} else {
|
||||
logger.info('Auto-trimmed conversation history to fit context window', {
|
||||
originalMessages: conversationMessages.length,
|
||||
trimmedMessages: result.length,
|
||||
originalTurns: turns.length,
|
||||
trimmedTurns: turns.length - i - 1,
|
||||
conversationTokens: currentTokenCount,
|
||||
systemTokens: systemTokenCount,
|
||||
totalTokens: currentTokenCount + systemTokenCount,
|
||||
@@ -372,6 +430,7 @@ export class Memory {
|
||||
}
|
||||
}
|
||||
|
||||
// No need to remove orphaned messages - turns are already complete
|
||||
return [...systemMessages, ...result]
|
||||
}
|
||||
|
||||
@@ -638,7 +697,7 @@ export class Memory {
|
||||
/**
|
||||
* Validate inputs to prevent malicious data or performance issues
|
||||
*/
|
||||
private validateInputs(conversationId?: string, content?: string): void {
|
||||
private validateInputs(conversationId?: string, content?: string | null): void {
|
||||
if (conversationId) {
|
||||
if (conversationId.length > 255) {
|
||||
throw new Error('Conversation ID too long (max 255 characters)')
|
||||
|
||||
@@ -37,10 +37,22 @@ export interface ToolInput {
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
role: 'system' | 'user' | 'assistant'
|
||||
content: string
|
||||
role: 'system' | 'user' | 'assistant' | 'tool'
|
||||
content: string | null
|
||||
function_call?: any
|
||||
tool_calls?: any[]
|
||||
tool_calls?: ToolCallMessage[]
|
||||
tool_call_id?: string
|
||||
/** Tool name for tool role messages (used by providers like Google/Gemini) */
|
||||
name?: string
|
||||
}
|
||||
|
||||
export interface ToolCallMessage {
|
||||
id: string
|
||||
type: 'function'
|
||||
function: {
|
||||
name: string
|
||||
arguments: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface StreamingConfig {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { isHosted } from '@/lib/core/config/feature-flags'
|
||||
import { isHosted } from '@/lib/core/config/environment'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('CopilotKeysQuery')
|
||||
|
||||
@@ -673,99 +673,6 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
|
||||
return { id: doc.id, label: doc.filename }
|
||||
},
|
||||
},
|
||||
'webflow.sites': {
|
||||
key: 'webflow.sites',
|
||||
staleTime: SELECTOR_STALE,
|
||||
getQueryKey: ({ context }: SelectorQueryArgs) => [
|
||||
'selectors',
|
||||
'webflow.sites',
|
||||
context.credentialId ?? 'none',
|
||||
],
|
||||
enabled: ({ context }) => Boolean(context.credentialId),
|
||||
fetchList: async ({ context }: SelectorQueryArgs) => {
|
||||
const credentialId = ensureCredential(context, 'webflow.sites')
|
||||
const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId })
|
||||
const data = await fetchJson<{ sites: { id: string; name: string }[] }>(
|
||||
'/api/tools/webflow/sites',
|
||||
{
|
||||
method: 'POST',
|
||||
body,
|
||||
}
|
||||
)
|
||||
return (data.sites || []).map((site) => ({
|
||||
id: site.id,
|
||||
label: site.name,
|
||||
}))
|
||||
},
|
||||
},
|
||||
'webflow.collections': {
|
||||
key: 'webflow.collections',
|
||||
staleTime: SELECTOR_STALE,
|
||||
getQueryKey: ({ context }: SelectorQueryArgs) => [
|
||||
'selectors',
|
||||
'webflow.collections',
|
||||
context.credentialId ?? 'none',
|
||||
context.siteId ?? 'none',
|
||||
],
|
||||
enabled: ({ context }) => Boolean(context.credentialId && context.siteId),
|
||||
fetchList: async ({ context }: SelectorQueryArgs) => {
|
||||
const credentialId = ensureCredential(context, 'webflow.collections')
|
||||
if (!context.siteId) {
|
||||
throw new Error('Missing site ID for webflow.collections selector')
|
||||
}
|
||||
const body = JSON.stringify({
|
||||
credential: credentialId,
|
||||
workflowId: context.workflowId,
|
||||
siteId: context.siteId,
|
||||
})
|
||||
const data = await fetchJson<{ collections: { id: string; name: string }[] }>(
|
||||
'/api/tools/webflow/collections',
|
||||
{
|
||||
method: 'POST',
|
||||
body,
|
||||
}
|
||||
)
|
||||
return (data.collections || []).map((collection) => ({
|
||||
id: collection.id,
|
||||
label: collection.name,
|
||||
}))
|
||||
},
|
||||
},
|
||||
'webflow.items': {
|
||||
key: 'webflow.items',
|
||||
staleTime: 15 * 1000,
|
||||
getQueryKey: ({ context, search }: SelectorQueryArgs) => [
|
||||
'selectors',
|
||||
'webflow.items',
|
||||
context.credentialId ?? 'none',
|
||||
context.collectionId ?? 'none',
|
||||
search ?? '',
|
||||
],
|
||||
enabled: ({ context }) => Boolean(context.credentialId && context.collectionId),
|
||||
fetchList: async ({ context, search }: SelectorQueryArgs) => {
|
||||
const credentialId = ensureCredential(context, 'webflow.items')
|
||||
if (!context.collectionId) {
|
||||
throw new Error('Missing collection ID for webflow.items selector')
|
||||
}
|
||||
const body = JSON.stringify({
|
||||
credential: credentialId,
|
||||
workflowId: context.workflowId,
|
||||
collectionId: context.collectionId,
|
||||
search,
|
||||
})
|
||||
const data = await fetchJson<{ items: { id: string; name: string }[] }>(
|
||||
'/api/tools/webflow/items',
|
||||
{
|
||||
method: 'POST',
|
||||
body,
|
||||
}
|
||||
)
|
||||
return (data.items || []).map((item) => ({
|
||||
id: item.id,
|
||||
label: item.name,
|
||||
}))
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export function getSelectorDefinition(key: SelectorKey): SelectorDefinition {
|
||||
|
||||
@@ -15,8 +15,6 @@ export interface SelectorResolutionArgs {
|
||||
planId?: string
|
||||
teamId?: string
|
||||
knowledgeBaseId?: string
|
||||
siteId?: string
|
||||
collectionId?: string
|
||||
}
|
||||
|
||||
const defaultContext: SelectorContext = {}
|
||||
@@ -54,8 +52,6 @@ function buildBaseContext(
|
||||
planId: args.planId,
|
||||
teamId: args.teamId,
|
||||
knowledgeBaseId: args.knowledgeBaseId,
|
||||
siteId: args.siteId,
|
||||
collectionId: args.collectionId,
|
||||
...extra,
|
||||
}
|
||||
}
|
||||
@@ -110,14 +106,6 @@ function resolveFileSelector(
|
||||
}
|
||||
case 'sharepoint':
|
||||
return { key: 'sharepoint.sites', context, allowSearch: true }
|
||||
case 'webflow':
|
||||
if (subBlock.id === 'collectionId') {
|
||||
return { key: 'webflow.collections', context, allowSearch: false }
|
||||
}
|
||||
if (subBlock.id === 'itemId') {
|
||||
return { key: 'webflow.items', context, allowSearch: true }
|
||||
}
|
||||
return { key: null, context, allowSearch: true }
|
||||
default:
|
||||
return { key: null, context, allowSearch: true }
|
||||
}
|
||||
@@ -171,8 +159,6 @@ function resolveProjectSelector(
|
||||
}
|
||||
case 'jira':
|
||||
return { key: 'jira.projects', context, allowSearch: true }
|
||||
case 'webflow':
|
||||
return { key: 'webflow.sites', context, allowSearch: false }
|
||||
default:
|
||||
return { key: null, context, allowSearch: true }
|
||||
}
|
||||
|
||||
@@ -23,9 +23,6 @@ export type SelectorKey =
|
||||
| 'microsoft.planner'
|
||||
| 'google.drive'
|
||||
| 'knowledge.documents'
|
||||
| 'webflow.sites'
|
||||
| 'webflow.collections'
|
||||
| 'webflow.items'
|
||||
|
||||
export interface SelectorOption {
|
||||
id: string
|
||||
@@ -46,8 +43,6 @@ export interface SelectorContext {
|
||||
planId?: string
|
||||
mimeType?: string
|
||||
fileId?: string
|
||||
siteId?: string
|
||||
collectionId?: string
|
||||
}
|
||||
|
||||
export interface SelectorQueryArgs {
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
import { db } from '@sim/db'
|
||||
import * as schema from '@sim/db/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { ANONYMOUS_USER, ANONYMOUS_USER_ID } from './constants'
|
||||
|
||||
const logger = createLogger('AnonymousAuth')
|
||||
|
||||
let anonymousUserEnsured = false
|
||||
|
||||
/**
|
||||
* Ensures the anonymous user and their stats record exist in the database.
|
||||
* Called when DISABLE_AUTH is enabled to ensure DB operations work.
|
||||
*/
|
||||
export async function ensureAnonymousUserExists(): Promise<void> {
|
||||
if (anonymousUserEnsured) return
|
||||
|
||||
try {
|
||||
const existingUser = await db.query.user.findFirst({
|
||||
where: eq(schema.user.id, ANONYMOUS_USER_ID),
|
||||
})
|
||||
|
||||
if (!existingUser) {
|
||||
const now = new Date()
|
||||
await db.insert(schema.user).values({
|
||||
...ANONYMOUS_USER,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
logger.info('Created anonymous user for DISABLE_AUTH mode')
|
||||
}
|
||||
|
||||
const existingStats = await db.query.userStats.findFirst({
|
||||
where: eq(schema.userStats.userId, ANONYMOUS_USER_ID),
|
||||
})
|
||||
|
||||
if (!existingStats) {
|
||||
await db.insert(schema.userStats).values({
|
||||
id: crypto.randomUUID(),
|
||||
userId: ANONYMOUS_USER_ID,
|
||||
currentUsageLimit: '10000000000',
|
||||
})
|
||||
logger.info('Created anonymous user stats for DISABLE_AUTH mode')
|
||||
}
|
||||
|
||||
anonymousUserEnsured = true
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof Error &&
|
||||
(error.message.includes('unique') || error.message.includes('duplicate'))
|
||||
) {
|
||||
anonymousUserEnsured = true
|
||||
return
|
||||
}
|
||||
logger.error('Failed to ensure anonymous user exists', { error })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export interface AnonymousSession {
|
||||
user: {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
emailVerified: boolean
|
||||
image: null
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
}
|
||||
session: {
|
||||
id: string
|
||||
userId: string
|
||||
expiresAt: Date
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
token: string
|
||||
ipAddress: null
|
||||
userAgent: null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an anonymous session for when auth is disabled.
|
||||
*/
|
||||
export function createAnonymousSession(): AnonymousSession {
|
||||
const now = new Date()
|
||||
return {
|
||||
user: {
|
||||
...ANONYMOUS_USER,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
session: {
|
||||
id: 'anonymous-session',
|
||||
userId: ANONYMOUS_USER_ID,
|
||||
expiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), // 1 year
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
token: 'anonymous-token',
|
||||
ipAddress: null,
|
||||
userAgent: null,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
import { createAuthClient } from 'better-auth/react'
|
||||
import type { auth } from '@/lib/auth'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
|
||||
import { isBillingEnabled } from '@/lib/core/config/environment'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { SessionContext, type SessionHookResult } from '@/app/_shell/providers/session-provider'
|
||||
|
||||
@@ -25,9 +25,9 @@ export const client = createAuthClient({
|
||||
stripeClient({
|
||||
subscription: true, // Enable subscription management
|
||||
}),
|
||||
organizationClient(),
|
||||
]
|
||||
: []),
|
||||
organizationClient(),
|
||||
...(env.NEXT_PUBLIC_SSO_ENABLED ? [ssoClient()] : []),
|
||||
],
|
||||
})
|
||||
@@ -42,9 +42,7 @@ export function useSession(): SessionHookResult {
|
||||
return ctx
|
||||
}
|
||||
|
||||
export const useActiveOrganization = isBillingEnabled
|
||||
? client.useActiveOrganization
|
||||
: () => ({ data: undefined, isPending: false, error: null })
|
||||
export const { useActiveOrganization } = client
|
||||
|
||||
export const useSubscription = () => {
|
||||
return {
|
||||
|
||||
@@ -38,19 +38,13 @@ import {
|
||||
handleSubscriptionCreated,
|
||||
handleSubscriptionDeleted,
|
||||
} from '@/lib/billing/webhooks/subscription'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import {
|
||||
isAuthDisabled,
|
||||
isBillingEnabled,
|
||||
isEmailVerificationEnabled,
|
||||
isRegistrationDisabled,
|
||||
} from '@/lib/core/config/feature-flags'
|
||||
import { env, isTruthy } from '@/lib/core/config/env'
|
||||
import { isBillingEnabled, isEmailVerificationEnabled } from '@/lib/core/config/environment'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { sendEmail } from '@/lib/messaging/email/mailer'
|
||||
import { getFromEmailAddress } from '@/lib/messaging/email/utils'
|
||||
import { quickValidateEmail } from '@/lib/messaging/email/validation'
|
||||
import { createAnonymousSession, ensureAnonymousUserExists } from './anonymous'
|
||||
import { SSO_TRUSTED_PROVIDERS } from './sso/constants'
|
||||
|
||||
const logger = createLogger('Auth')
|
||||
@@ -276,7 +270,7 @@ export const auth = betterAuth({
|
||||
},
|
||||
hooks: {
|
||||
before: createAuthMiddleware(async (ctx) => {
|
||||
if (ctx.path.startsWith('/sign-up') && isRegistrationDisabled)
|
||||
if (ctx.path.startsWith('/sign-up') && isTruthy(env.DISABLE_REGISTRATION))
|
||||
throw new Error('Registration is disabled, please contact your admin.')
|
||||
|
||||
if (
|
||||
@@ -2093,6 +2087,14 @@ export const auth = betterAuth({
|
||||
|
||||
try {
|
||||
await handleSubscriptionDeleted(subscription)
|
||||
|
||||
// Reset usage limits to free tier
|
||||
await syncSubscriptionUsageLimits(subscription)
|
||||
|
||||
logger.info('[onSubscriptionDeleted] Reset usage limits to free tier', {
|
||||
subscriptionId: subscription.id,
|
||||
referenceId: subscription.referenceId,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('[onSubscriptionDeleted] Failed to handle subscription deletion', {
|
||||
subscriptionId: subscription.id,
|
||||
@@ -2191,11 +2193,6 @@ export const auth = betterAuth({
|
||||
})
|
||||
|
||||
export async function getSession() {
|
||||
if (isAuthDisabled) {
|
||||
await ensureAnonymousUserExists()
|
||||
return createAnonymousSession()
|
||||
}
|
||||
|
||||
const hdrs = await headers()
|
||||
return await auth.api.getSession({
|
||||
headers: hdrs,
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
/** Anonymous user ID used when DISABLE_AUTH is enabled */
|
||||
export const ANONYMOUS_USER_ID = '00000000-0000-0000-0000-000000000000'
|
||||
|
||||
export const ANONYMOUS_USER = {
|
||||
id: ANONYMOUS_USER_ID,
|
||||
name: 'Anonymous',
|
||||
email: 'anonymous@localhost',
|
||||
emailVerified: true,
|
||||
image: null,
|
||||
} as const
|
||||
@@ -1,4 +1 @@
|
||||
export type { AnonymousSession } from './anonymous'
|
||||
export { createAnonymousSession, ensureAnonymousUserExists } from './anonymous'
|
||||
export { auth, getSession, signIn, signUp } from './auth'
|
||||
export { ANONYMOUS_USER, ANONYMOUS_USER_ID } from './constants'
|
||||
|
||||
@@ -2,7 +2,7 @@ import { db } from '@sim/db'
|
||||
import { member, organization, userStats } from '@sim/db/schema'
|
||||
import { and, eq, inArray } from 'drizzle-orm'
|
||||
import { getUserUsageLimit } from '@/lib/billing/core/usage'
|
||||
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
|
||||
import { isBillingEnabled } from '@/lib/core/config/environment'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('UsageMonitor')
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
getPerUserMinimumLimit,
|
||||
} from '@/lib/billing/subscriptions/utils'
|
||||
import type { UserSubscriptionState } from '@/lib/billing/types'
|
||||
import { isProd } from '@/lib/core/config/feature-flags'
|
||||
import { isProd } from '@/lib/core/config/environment'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
getPlanPricing,
|
||||
} from '@/lib/billing/subscriptions/utils'
|
||||
import type { BillingData, UsageData, UsageLimitInfo } from '@/lib/billing/types'
|
||||
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
|
||||
import { isBillingEnabled } from '@/lib/core/config/environment'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { sendEmail } from '@/lib/messaging/email/mailer'
|
||||
|
||||
@@ -21,131 +21,6 @@ import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('OrganizationMembership')
|
||||
|
||||
export interface RestoreProResult {
|
||||
restored: boolean
|
||||
usageRestored: boolean
|
||||
subscriptionId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore a user's personal Pro subscription if it was paused (cancelAtPeriodEnd=true).
|
||||
* Also restores any snapshotted Pro usage from when they joined a team.
|
||||
*
|
||||
* Called when:
|
||||
* - A member leaves a team (via removeUserFromOrganization)
|
||||
* - A team subscription ends (members stay but get Pro restored)
|
||||
*/
|
||||
export async function restoreUserProSubscription(userId: string): Promise<RestoreProResult> {
|
||||
const result: RestoreProResult = {
|
||||
restored: false,
|
||||
usageRestored: false,
|
||||
}
|
||||
|
||||
try {
|
||||
const [personalPro] = await db
|
||||
.select()
|
||||
.from(subscriptionTable)
|
||||
.where(
|
||||
and(
|
||||
eq(subscriptionTable.referenceId, userId),
|
||||
eq(subscriptionTable.status, 'active'),
|
||||
eq(subscriptionTable.plan, 'pro')
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!personalPro?.cancelAtPeriodEnd || !personalPro.stripeSubscriptionId) {
|
||||
return result
|
||||
}
|
||||
|
||||
result.subscriptionId = personalPro.id
|
||||
|
||||
try {
|
||||
const stripe = requireStripeClient()
|
||||
await stripe.subscriptions.update(personalPro.stripeSubscriptionId, {
|
||||
cancel_at_period_end: false,
|
||||
})
|
||||
} catch (stripeError) {
|
||||
logger.error('Stripe restore cancel_at_period_end failed for personal Pro', {
|
||||
userId,
|
||||
stripeSubscriptionId: personalPro.stripeSubscriptionId,
|
||||
error: stripeError,
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
await db
|
||||
.update(subscriptionTable)
|
||||
.set({ cancelAtPeriodEnd: false })
|
||||
.where(eq(subscriptionTable.id, personalPro.id))
|
||||
|
||||
result.restored = true
|
||||
|
||||
logger.info('Restored personal Pro subscription', {
|
||||
userId,
|
||||
subscriptionId: personalPro.id,
|
||||
})
|
||||
} catch (dbError) {
|
||||
logger.error('DB update failed when restoring personal Pro', {
|
||||
userId,
|
||||
subscriptionId: personalPro.id,
|
||||
error: dbError,
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const [stats] = await db
|
||||
.select({
|
||||
currentPeriodCost: userStats.currentPeriodCost,
|
||||
proPeriodCostSnapshot: userStats.proPeriodCostSnapshot,
|
||||
})
|
||||
.from(userStats)
|
||||
.where(eq(userStats.userId, userId))
|
||||
.limit(1)
|
||||
|
||||
if (stats) {
|
||||
const currentUsage = stats.currentPeriodCost || '0'
|
||||
const snapshotUsage = stats.proPeriodCostSnapshot || '0'
|
||||
const snapshotNum = Number.parseFloat(snapshotUsage)
|
||||
|
||||
if (snapshotNum > 0) {
|
||||
const currentNum = Number.parseFloat(currentUsage)
|
||||
const restoredUsage = (currentNum + snapshotNum).toString()
|
||||
|
||||
await db
|
||||
.update(userStats)
|
||||
.set({
|
||||
currentPeriodCost: restoredUsage,
|
||||
proPeriodCostSnapshot: '0',
|
||||
})
|
||||
.where(eq(userStats.userId, userId))
|
||||
|
||||
result.usageRestored = true
|
||||
|
||||
logger.info('Restored Pro usage snapshot', {
|
||||
userId,
|
||||
previousUsage: currentUsage,
|
||||
snapshotUsage,
|
||||
restoredUsage,
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (usageRestoreError) {
|
||||
logger.error('Failed to restore Pro usage snapshot', {
|
||||
userId,
|
||||
error: usageRestoreError,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to restore user Pro subscription', {
|
||||
userId,
|
||||
error,
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export interface AddMemberParams {
|
||||
userId: string
|
||||
organizationId: string
|
||||
@@ -534,6 +409,7 @@ export async function removeUserFromOrganization(
|
||||
// STEP 3: Restore personal Pro if user has no remaining paid team memberships
|
||||
if (!skipBillingLogic) {
|
||||
try {
|
||||
// Check for remaining paid team memberships
|
||||
const remainingPaidTeams = await db
|
||||
.select({ orgId: member.organizationId })
|
||||
.from(member)
|
||||
@@ -552,10 +428,104 @@ export async function removeUserFromOrganization(
|
||||
)
|
||||
}
|
||||
|
||||
// If no remaining paid teams, try to restore personal Pro
|
||||
if (!hasAnyPaidTeam) {
|
||||
const restoreResult = await restoreUserProSubscription(userId)
|
||||
billingActions.proRestored = restoreResult.restored
|
||||
billingActions.usageRestored = restoreResult.usageRestored
|
||||
const [personalPro] = await db
|
||||
.select()
|
||||
.from(subscriptionTable)
|
||||
.where(
|
||||
and(
|
||||
eq(subscriptionTable.referenceId, userId),
|
||||
eq(subscriptionTable.status, 'active'),
|
||||
eq(subscriptionTable.plan, 'pro')
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
// Only restore if cancelAtPeriodEnd is true AND stripeSubscriptionId exists
|
||||
if (
|
||||
personalPro &&
|
||||
personalPro.cancelAtPeriodEnd === true &&
|
||||
personalPro.stripeSubscriptionId
|
||||
) {
|
||||
// Call Stripe API first (separate try/catch so failure doesn't prevent DB update)
|
||||
try {
|
||||
const stripe = requireStripeClient()
|
||||
await stripe.subscriptions.update(personalPro.stripeSubscriptionId, {
|
||||
cancel_at_period_end: false,
|
||||
})
|
||||
} catch (stripeError) {
|
||||
logger.error('Stripe restore cancel_at_period_end failed for personal Pro', {
|
||||
userId,
|
||||
stripeSubscriptionId: personalPro.stripeSubscriptionId,
|
||||
error: stripeError,
|
||||
})
|
||||
}
|
||||
|
||||
// Update DB (separate try/catch)
|
||||
try {
|
||||
await db
|
||||
.update(subscriptionTable)
|
||||
.set({ cancelAtPeriodEnd: false })
|
||||
.where(eq(subscriptionTable.id, personalPro.id))
|
||||
|
||||
billingActions.proRestored = true
|
||||
|
||||
logger.info('Restored personal Pro after leaving last paid team', {
|
||||
userId,
|
||||
personalSubscriptionId: personalPro.id,
|
||||
})
|
||||
} catch (dbError) {
|
||||
logger.error('DB update failed when restoring personal Pro', {
|
||||
userId,
|
||||
subscriptionId: personalPro.id,
|
||||
error: dbError,
|
||||
})
|
||||
}
|
||||
|
||||
// Restore snapshotted Pro usage (separate try/catch)
|
||||
try {
|
||||
const [stats] = await db
|
||||
.select({
|
||||
currentPeriodCost: userStats.currentPeriodCost,
|
||||
proPeriodCostSnapshot: userStats.proPeriodCostSnapshot,
|
||||
})
|
||||
.from(userStats)
|
||||
.where(eq(userStats.userId, userId))
|
||||
.limit(1)
|
||||
|
||||
if (stats) {
|
||||
const currentUsage = stats.currentPeriodCost || '0'
|
||||
const snapshotUsage = stats.proPeriodCostSnapshot || '0'
|
||||
|
||||
const currentNum = Number.parseFloat(currentUsage)
|
||||
const snapshotNum = Number.parseFloat(snapshotUsage)
|
||||
const restoredUsage = (currentNum + snapshotNum).toString()
|
||||
|
||||
await db
|
||||
.update(userStats)
|
||||
.set({
|
||||
currentPeriodCost: restoredUsage,
|
||||
proPeriodCostSnapshot: '0',
|
||||
})
|
||||
.where(eq(userStats.userId, userId))
|
||||
|
||||
billingActions.usageRestored = true
|
||||
|
||||
logger.info('Restored Pro usage after leaving team', {
|
||||
userId,
|
||||
previousUsage: currentUsage,
|
||||
snapshotUsage: snapshotUsage,
|
||||
restoredUsage: restoredUsage,
|
||||
})
|
||||
}
|
||||
} catch (usageRestoreError) {
|
||||
logger.error('Failed to restore Pro usage after leaving team', {
|
||||
userId,
|
||||
error: usageRestoreError,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (postRemoveError) {
|
||||
logger.error('Post-removal personal Pro restore check failed', {
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
import { organization, subscription, userStats } from '@sim/db/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { getEnv } from '@/lib/core/config/env'
|
||||
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
|
||||
import { isBillingEnabled } from '@/lib/core/config/environment'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('StorageLimits')
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import { db } from '@sim/db'
|
||||
import { organization, userStats } from '@sim/db/schema'
|
||||
import { eq, sql } from 'drizzle-orm'
|
||||
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
|
||||
import { isBillingEnabled } from '@/lib/core/config/environment'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('StorageTracking')
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { db } from '@sim/db'
|
||||
import { member, organization, subscription } from '@sim/db/schema'
|
||||
import { subscription } from '@sim/db/schema'
|
||||
import { and, eq, ne } from 'drizzle-orm'
|
||||
import { calculateSubscriptionOverage } from '@/lib/billing/core/billing'
|
||||
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
|
||||
import { restoreUserProSubscription } from '@/lib/billing/organizations/membership'
|
||||
import { requireStripeClient } from '@/lib/billing/stripe-client'
|
||||
import {
|
||||
getBilledOverageForSubscription,
|
||||
@@ -13,71 +11,6 @@ import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('StripeSubscriptionWebhooks')
|
||||
|
||||
/**
|
||||
* Restore personal Pro subscriptions for all members of an organization
|
||||
* when the team/enterprise subscription ends.
|
||||
*/
|
||||
async function restoreMemberProSubscriptions(organizationId: string): Promise<number> {
|
||||
let restoredCount = 0
|
||||
|
||||
try {
|
||||
const members = await db
|
||||
.select({ userId: member.userId })
|
||||
.from(member)
|
||||
.where(eq(member.organizationId, organizationId))
|
||||
|
||||
for (const m of members) {
|
||||
const result = await restoreUserProSubscription(m.userId)
|
||||
if (result.restored) {
|
||||
restoredCount++
|
||||
}
|
||||
}
|
||||
|
||||
if (restoredCount > 0) {
|
||||
logger.info('Restored Pro subscriptions for team members', {
|
||||
organizationId,
|
||||
restoredCount,
|
||||
totalMembers: members.length,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to restore member Pro subscriptions', {
|
||||
organizationId,
|
||||
error,
|
||||
})
|
||||
}
|
||||
|
||||
return restoredCount
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup organization when team/enterprise subscription is deleted.
|
||||
* - Restores member Pro subscriptions
|
||||
* - Deletes the organization
|
||||
* - Syncs usage limits for former members (resets to free or Pro tier)
|
||||
*/
|
||||
async function cleanupOrganizationSubscription(organizationId: string): Promise<{
|
||||
restoredProCount: number
|
||||
membersSynced: number
|
||||
}> {
|
||||
// Get member userIds before deletion (needed for limit syncing after org deletion)
|
||||
const memberUserIds = await db
|
||||
.select({ userId: member.userId })
|
||||
.from(member)
|
||||
.where(eq(member.organizationId, organizationId))
|
||||
|
||||
const restoredProCount = await restoreMemberProSubscriptions(organizationId)
|
||||
|
||||
await db.delete(organization).where(eq(organization.id, organizationId))
|
||||
|
||||
// Sync usage limits for former members (now free or Pro tier)
|
||||
for (const m of memberUserIds) {
|
||||
await syncUsageLimitsFromSubscription(m.userId)
|
||||
}
|
||||
|
||||
return { restoredProCount, membersSynced: memberUserIds.length }
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle new subscription creation - reset usage if transitioning from free to paid
|
||||
*/
|
||||
@@ -165,24 +98,12 @@ export async function handleSubscriptionDeleted(subscription: {
|
||||
const totalOverage = await calculateSubscriptionOverage(subscription)
|
||||
const stripe = requireStripeClient()
|
||||
|
||||
// Enterprise plans have no overages - reset usage and cleanup org
|
||||
// Enterprise plans have no overages - just reset usage
|
||||
if (subscription.plan === 'enterprise') {
|
||||
await resetUsageForSubscription({
|
||||
plan: subscription.plan,
|
||||
referenceId: subscription.referenceId,
|
||||
})
|
||||
|
||||
const { restoredProCount, membersSynced } = await cleanupOrganizationSubscription(
|
||||
subscription.referenceId
|
||||
)
|
||||
|
||||
logger.info('Successfully processed enterprise subscription cancellation', {
|
||||
subscriptionId: subscription.id,
|
||||
stripeSubscriptionId,
|
||||
restoredProCount,
|
||||
organizationDeleted: true,
|
||||
membersSynced,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -288,32 +209,13 @@ export async function handleSubscriptionDeleted(subscription: {
|
||||
referenceId: subscription.referenceId,
|
||||
})
|
||||
|
||||
// Plan-specific cleanup after billing
|
||||
let restoredProCount = 0
|
||||
let organizationDeleted = false
|
||||
let membersSynced = 0
|
||||
|
||||
if (subscription.plan === 'team') {
|
||||
const cleanup = await cleanupOrganizationSubscription(subscription.referenceId)
|
||||
restoredProCount = cleanup.restoredProCount
|
||||
membersSynced = cleanup.membersSynced
|
||||
organizationDeleted = true
|
||||
} else if (subscription.plan === 'pro') {
|
||||
await syncUsageLimitsFromSubscription(subscription.referenceId)
|
||||
membersSynced = 1
|
||||
}
|
||||
|
||||
// Note: better-auth's Stripe plugin already updates status to 'canceled' before calling this handler
|
||||
// We handle overage billing, usage reset, Pro restoration, limit syncing, and org cleanup
|
||||
// We only need to handle overage billing and usage reset
|
||||
|
||||
logger.info('Successfully processed subscription cancellation', {
|
||||
subscriptionId: subscription.id,
|
||||
stripeSubscriptionId,
|
||||
plan: subscription.plan,
|
||||
totalOverage,
|
||||
restoredProCount,
|
||||
organizationDeleted,
|
||||
membersSynced,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to handle subscription deletion', {
|
||||
|
||||
@@ -20,7 +20,6 @@ export const env = createEnv({
|
||||
BETTER_AUTH_URL: z.string().url(), // Base URL for Better Auth service
|
||||
BETTER_AUTH_SECRET: z.string().min(32), // Secret key for Better Auth JWT signing
|
||||
DISABLE_REGISTRATION: z.boolean().optional(), // Flag to disable new user registration
|
||||
DISABLE_AUTH: z.boolean().optional(), // Bypass authentication entirely (self-hosted only, creates anonymous session)
|
||||
ALLOWED_LOGIN_EMAILS: z.string().optional(), // Comma-separated list of allowed email addresses for login
|
||||
ALLOWED_LOGIN_DOMAINS: z.string().optional(), // Comma-separated list of allowed email domains for login
|
||||
ENCRYPTION_KEY: z.string().min(32), // Key for encrypting sensitive data
|
||||
|
||||
@@ -35,31 +35,6 @@ export const isBillingEnabled = isTruthy(env.BILLING_ENABLED)
|
||||
*/
|
||||
export const isEmailVerificationEnabled = isTruthy(env.EMAIL_VERIFICATION_ENABLED)
|
||||
|
||||
/**
|
||||
* Is authentication disabled (for self-hosted deployments behind private networks)
|
||||
*/
|
||||
export const isAuthDisabled = isTruthy(env.DISABLE_AUTH)
|
||||
|
||||
/**
|
||||
* Is user registration disabled
|
||||
*/
|
||||
export const isRegistrationDisabled = isTruthy(env.DISABLE_REGISTRATION)
|
||||
|
||||
/**
|
||||
* Is Trigger.dev enabled for async job processing
|
||||
*/
|
||||
export const isTriggerDevEnabled = isTruthy(env.TRIGGER_DEV_ENABLED)
|
||||
|
||||
/**
|
||||
* Is SSO enabled for enterprise authentication
|
||||
*/
|
||||
export const isSsoEnabled = isTruthy(env.SSO_ENABLED)
|
||||
|
||||
/**
|
||||
* Is E2B enabled for remote code execution
|
||||
*/
|
||||
export const isE2bEnabled = isTruthy(env.E2B_ENABLED)
|
||||
|
||||
/**
|
||||
* Get cost multiplier based on environment
|
||||
*/
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getEnv } from '@/lib/core/config/env'
|
||||
import { isProd } from '@/lib/core/config/feature-flags'
|
||||
import { isProd } from '@/lib/core/config/environment'
|
||||
|
||||
/**
|
||||
* Returns the base URL of the application from NEXT_PUBLIC_APP_URL
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
} from '@sim/db/schema'
|
||||
import { and, eq, or, sql } from 'drizzle-orm'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { isTriggerDevEnabled } from '@/lib/core/config/feature-flags'
|
||||
import { env, isTruthy } from '@/lib/core/config/env'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import type { WorkflowExecutionLog } from '@/lib/logs/types'
|
||||
import {
|
||||
@@ -140,7 +140,9 @@ export async function emitWorkflowExecutionCompleted(log: WorkflowExecutionLog):
|
||||
alertConfig: alertConfig || undefined,
|
||||
}
|
||||
|
||||
if (isTriggerDevEnabled) {
|
||||
const useTrigger = isTruthy(env.TRIGGER_DEV_ENABLED)
|
||||
|
||||
if (useTrigger) {
|
||||
await workspaceNotificationDeliveryTask.trigger(payload)
|
||||
logger.info(
|
||||
`Enqueued ${subscription.notificationType} notification ${deliveryId} via Trigger.dev`
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
maybeSendUsageThresholdEmail,
|
||||
} from '@/lib/billing/core/usage'
|
||||
import { checkAndBillOverageThreshold } from '@/lib/billing/threshold-billing'
|
||||
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
|
||||
import { isBillingEnabled } from '@/lib/core/config/environment'
|
||||
import { redactApiKeys } from '@/lib/core/security/redaction'
|
||||
import { filterForDisplay } from '@/lib/core/utils/display-filters'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import { db } from '@sim/db'
|
||||
import { mcpServers } from '@sim/db/schema'
|
||||
import { and, eq, isNull } from 'drizzle-orm'
|
||||
import { isTest } from '@/lib/core/config/feature-flags'
|
||||
import { isTest } from '@/lib/core/config/environment'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { getEffectiveDecryptedEnv } from '@/lib/environment/utils'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
} from '@sim/db/schema'
|
||||
import { and, eq, gte, sql } from 'drizzle-orm'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { isTriggerDevEnabled } from '@/lib/core/config/feature-flags'
|
||||
import { env, isTruthy } from '@/lib/core/config/env'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import {
|
||||
executeNotificationDelivery,
|
||||
@@ -118,7 +118,9 @@ async function checkWorkflowInactivity(
|
||||
alertConfig,
|
||||
}
|
||||
|
||||
if (isTriggerDevEnabled) {
|
||||
const useTrigger = isTruthy(env.TRIGGER_DEV_ENABLED)
|
||||
|
||||
if (useTrigger) {
|
||||
await workspaceNotificationDeliveryTask.trigger(payload)
|
||||
} else {
|
||||
void executeNotificationDelivery(payload).catch((error) => {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { tasks } from '@trigger.dev/sdk'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { isTriggerDevEnabled } from '@/lib/core/config/feature-flags'
|
||||
import { env, isTruthy } from '@/lib/core/config/env'
|
||||
import { preprocessExecution } from '@/lib/execution/preprocessing'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { convertSquareBracketsToTwiML } from '@/lib/webhooks/utils'
|
||||
@@ -707,7 +707,9 @@ export async function queueWebhookExecution(
|
||||
...(credentialId ? { credentialId } : {}),
|
||||
}
|
||||
|
||||
if (isTriggerDevEnabled) {
|
||||
const useTrigger = isTruthy(env.TRIGGER_DEV_ENABLED)
|
||||
|
||||
if (useTrigger) {
|
||||
const handle = await tasks.trigger('webhook-execution', payload)
|
||||
logger.info(
|
||||
`[${options.requestId}] Queued ${options.testMode ? 'TEST ' : ''}webhook execution task ${
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { NextConfig } from 'next'
|
||||
import { env, getEnv, isTruthy } from './lib/core/config/env'
|
||||
import { isDev, isHosted } from './lib/core/config/feature-flags'
|
||||
import { isDev, isHosted } from './lib/core/config/environment'
|
||||
import { getMainCSPPolicy, getWorkflowExecutionCSPPolicy } from './lib/core/security/csp'
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
|
||||
@@ -4,7 +4,12 @@ import type { StreamingExecution } from '@/executor/types'
|
||||
import { executeTool } from '@/tools'
|
||||
import { getProviderDefaultModel, getProviderModels } from '../models'
|
||||
import type { ProviderConfig, ProviderRequest, ProviderResponse, TimeSegment } from '../types'
|
||||
import { prepareToolExecution, prepareToolsWithUsageControl, trackForcedToolUsage } from '../utils'
|
||||
import {
|
||||
prepareToolExecution,
|
||||
prepareToolsWithUsageControl,
|
||||
sanitizeMessagesForProvider,
|
||||
trackForcedToolUsage,
|
||||
} from '../utils'
|
||||
|
||||
const logger = createLogger('AnthropicProvider')
|
||||
|
||||
@@ -68,8 +73,12 @@ export const anthropicProvider: ProviderConfig = {
|
||||
|
||||
// Add remaining messages
|
||||
if (request.messages) {
|
||||
request.messages.forEach((msg) => {
|
||||
// Sanitize messages to ensure proper tool call/result pairing
|
||||
const sanitizedMessages = sanitizeMessagesForProvider(request.messages)
|
||||
|
||||
sanitizedMessages.forEach((msg) => {
|
||||
if (msg.role === 'function') {
|
||||
// Legacy function role format
|
||||
messages.push({
|
||||
role: 'user',
|
||||
content: [
|
||||
@@ -80,7 +89,41 @@ export const anthropicProvider: ProviderConfig = {
|
||||
},
|
||||
],
|
||||
})
|
||||
} else if (msg.role === 'tool') {
|
||||
// Modern tool role format (OpenAI-compatible)
|
||||
messages.push({
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: (msg as any).tool_call_id,
|
||||
content: msg.content || '',
|
||||
},
|
||||
],
|
||||
})
|
||||
} else if (msg.tool_calls && Array.isArray(msg.tool_calls)) {
|
||||
// Modern tool_calls format (OpenAI-compatible)
|
||||
const toolUseContent = msg.tool_calls.map((tc: any) => ({
|
||||
type: 'tool_use',
|
||||
id: tc.id,
|
||||
name: tc.function?.name || tc.name,
|
||||
input:
|
||||
typeof tc.function?.arguments === 'string'
|
||||
? (() => {
|
||||
try {
|
||||
return JSON.parse(tc.function.arguments)
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
})()
|
||||
: tc.function?.arguments || tc.arguments || {},
|
||||
}))
|
||||
messages.push({
|
||||
role: 'assistant',
|
||||
content: toolUseContent,
|
||||
})
|
||||
} else if (msg.function_call) {
|
||||
// Legacy function_call format
|
||||
const toolUseId = `${msg.function_call.name}-${Date.now()}`
|
||||
messages.push({
|
||||
role: 'assistant',
|
||||
@@ -490,9 +533,14 @@ ${fieldDescriptions}
|
||||
}
|
||||
}
|
||||
|
||||
// Use the original tool use ID from the API response
|
||||
const toolUseId = toolUse.id || generateToolUseId(toolName)
|
||||
|
||||
toolCalls.push({
|
||||
id: toolUseId,
|
||||
name: toolName,
|
||||
arguments: toolParams,
|
||||
rawArguments: JSON.stringify(toolArgs),
|
||||
startTime: new Date(toolCallStartTime).toISOString(),
|
||||
endTime: new Date(toolCallEndTime).toISOString(),
|
||||
duration: toolCallDuration,
|
||||
@@ -501,7 +549,6 @@ ${fieldDescriptions}
|
||||
})
|
||||
|
||||
// Add the tool call and result to messages (both success and failure)
|
||||
const toolUseId = generateToolUseId(toolName)
|
||||
|
||||
currentMessages.push({
|
||||
role: 'assistant',
|
||||
@@ -840,9 +887,14 @@ ${fieldDescriptions}
|
||||
}
|
||||
}
|
||||
|
||||
// Use the original tool use ID from the API response
|
||||
const toolUseId = toolUse.id || generateToolUseId(toolName)
|
||||
|
||||
toolCalls.push({
|
||||
id: toolUseId,
|
||||
name: toolName,
|
||||
arguments: toolParams,
|
||||
rawArguments: JSON.stringify(toolArgs),
|
||||
startTime: new Date(toolCallStartTime).toISOString(),
|
||||
endTime: new Date(toolCallEndTime).toISOString(),
|
||||
duration: toolCallDuration,
|
||||
@@ -851,7 +903,6 @@ ${fieldDescriptions}
|
||||
})
|
||||
|
||||
// Add the tool call and result to messages (both success and failure)
|
||||
const toolUseId = generateToolUseId(toolName)
|
||||
|
||||
currentMessages.push({
|
||||
role: 'assistant',
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
import {
|
||||
prepareToolExecution,
|
||||
prepareToolsWithUsageControl,
|
||||
sanitizeMessagesForProvider,
|
||||
trackForcedToolUsage,
|
||||
} from '@/providers/utils'
|
||||
import { executeTool } from '@/tools'
|
||||
@@ -120,9 +121,10 @@ export const azureOpenAIProvider: ProviderConfig = {
|
||||
})
|
||||
}
|
||||
|
||||
// Add remaining messages
|
||||
// Add remaining messages (sanitized to ensure proper tool call/result pairing)
|
||||
if (request.messages) {
|
||||
allMessages.push(...request.messages)
|
||||
const sanitizedMessages = sanitizeMessagesForProvider(request.messages)
|
||||
allMessages.push(...sanitizedMessages)
|
||||
}
|
||||
|
||||
// Transform tools to Azure OpenAI format if provided
|
||||
@@ -417,8 +419,10 @@ export const azureOpenAIProvider: ProviderConfig = {
|
||||
}
|
||||
|
||||
toolCalls.push({
|
||||
id: toolCall.id,
|
||||
name: toolName,
|
||||
arguments: toolParams,
|
||||
rawArguments: toolCall.function.arguments,
|
||||
startTime: new Date(toolCallStartTime).toISOString(),
|
||||
endTime: new Date(toolCallEndTime).toISOString(),
|
||||
duration: toolCallDuration,
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
import {
|
||||
prepareToolExecution,
|
||||
prepareToolsWithUsageControl,
|
||||
sanitizeMessagesForProvider,
|
||||
trackForcedToolUsage,
|
||||
} from '@/providers/utils'
|
||||
import { executeTool } from '@/tools'
|
||||
@@ -86,9 +87,10 @@ export const cerebrasProvider: ProviderConfig = {
|
||||
})
|
||||
}
|
||||
|
||||
// Add remaining messages
|
||||
// Add remaining messages (sanitized to ensure proper tool call/result pairing)
|
||||
if (request.messages) {
|
||||
allMessages.push(...request.messages)
|
||||
const sanitizedMessages = sanitizeMessagesForProvider(request.messages)
|
||||
allMessages.push(...sanitizedMessages)
|
||||
}
|
||||
|
||||
// Transform tools to Cerebras format if provided
|
||||
@@ -323,8 +325,10 @@ export const cerebrasProvider: ProviderConfig = {
|
||||
}
|
||||
|
||||
toolCalls.push({
|
||||
id: toolCall.id,
|
||||
name: toolName,
|
||||
arguments: toolParams,
|
||||
rawArguments: toolCall.function.arguments,
|
||||
startTime: new Date(toolCallStartTime).toISOString(),
|
||||
endTime: new Date(toolCallEndTime).toISOString(),
|
||||
duration: toolCallDuration,
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
import {
|
||||
prepareToolExecution,
|
||||
prepareToolsWithUsageControl,
|
||||
sanitizeMessagesForProvider,
|
||||
trackForcedToolUsage,
|
||||
} from '@/providers/utils'
|
||||
import { executeTool } from '@/tools'
|
||||
@@ -84,9 +85,10 @@ export const deepseekProvider: ProviderConfig = {
|
||||
})
|
||||
}
|
||||
|
||||
// Add remaining messages
|
||||
// Add remaining messages (sanitized to ensure proper tool call/result pairing)
|
||||
if (request.messages) {
|
||||
allMessages.push(...request.messages)
|
||||
const sanitizedMessages = sanitizeMessagesForProvider(request.messages)
|
||||
allMessages.push(...sanitizedMessages)
|
||||
}
|
||||
|
||||
// Transform tools to OpenAI format if provided
|
||||
@@ -323,8 +325,10 @@ export const deepseekProvider: ProviderConfig = {
|
||||
}
|
||||
|
||||
toolCalls.push({
|
||||
id: toolCall.id,
|
||||
name: toolName,
|
||||
arguments: toolParams,
|
||||
rawArguments: toolCall.function.arguments,
|
||||
startTime: new Date(toolCallStartTime).toISOString(),
|
||||
endTime: new Date(toolCallEndTime).toISOString(),
|
||||
duration: toolCallDuration,
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
import {
|
||||
prepareToolExecution,
|
||||
prepareToolsWithUsageControl,
|
||||
sanitizeMessagesForProvider,
|
||||
trackForcedToolUsage,
|
||||
} from '@/providers/utils'
|
||||
import { executeTool } from '@/tools'
|
||||
@@ -552,9 +553,14 @@ export const googleProvider: ProviderConfig = {
|
||||
}
|
||||
}
|
||||
|
||||
// Generate a unique ID for this tool call (Google doesn't provide one)
|
||||
const toolCallId = `call_${toolName}_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`
|
||||
|
||||
toolCalls.push({
|
||||
id: toolCallId,
|
||||
name: toolName,
|
||||
arguments: toolParams,
|
||||
rawArguments: JSON.stringify(toolArgs),
|
||||
startTime: new Date(toolCallStartTime).toISOString(),
|
||||
endTime: new Date(toolCallEndTime).toISOString(),
|
||||
duration: toolCallDuration,
|
||||
@@ -1087,9 +1093,10 @@ function convertToGeminiFormat(request: ProviderRequest): {
|
||||
contents.push({ role: 'user', parts: [{ text: request.context }] })
|
||||
}
|
||||
|
||||
// Process messages
|
||||
// Process messages (sanitized to ensure proper tool call/result pairing)
|
||||
if (request.messages && request.messages.length > 0) {
|
||||
for (const message of request.messages) {
|
||||
const sanitizedMessages = sanitizeMessagesForProvider(request.messages)
|
||||
for (const message of sanitizedMessages) {
|
||||
if (message.role === 'system') {
|
||||
// Add to system instruction
|
||||
if (!systemInstruction) {
|
||||
@@ -1119,10 +1126,28 @@ function convertToGeminiFormat(request: ProviderRequest): {
|
||||
contents.push({ role: 'model', parts: functionCalls })
|
||||
}
|
||||
} else if (message.role === 'tool') {
|
||||
// Convert tool response (Gemini only accepts user/model roles)
|
||||
// Convert tool response to Gemini's functionResponse format
|
||||
// Gemini uses 'user' role for function responses
|
||||
const functionName = (message as any).name || 'function'
|
||||
|
||||
let responseData: any
|
||||
try {
|
||||
responseData =
|
||||
typeof message.content === 'string' ? JSON.parse(message.content) : message.content
|
||||
} catch {
|
||||
responseData = { result: message.content }
|
||||
}
|
||||
|
||||
contents.push({
|
||||
role: 'user',
|
||||
parts: [{ text: `Function result: ${message.content}` }],
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
name: functionName,
|
||||
response: responseData,
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
import {
|
||||
prepareToolExecution,
|
||||
prepareToolsWithUsageControl,
|
||||
sanitizeMessagesForProvider,
|
||||
trackForcedToolUsage,
|
||||
} from '@/providers/utils'
|
||||
import { executeTool } from '@/tools'
|
||||
@@ -75,9 +76,10 @@ export const groqProvider: ProviderConfig = {
|
||||
})
|
||||
}
|
||||
|
||||
// Add remaining messages
|
||||
// Add remaining messages (sanitized to ensure proper tool call/result pairing)
|
||||
if (request.messages) {
|
||||
allMessages.push(...request.messages)
|
||||
const sanitizedMessages = sanitizeMessagesForProvider(request.messages)
|
||||
allMessages.push(...sanitizedMessages)
|
||||
}
|
||||
|
||||
// Transform tools to function format if provided
|
||||
@@ -296,8 +298,10 @@ export const groqProvider: ProviderConfig = {
|
||||
}
|
||||
|
||||
toolCalls.push({
|
||||
id: toolCall.id,
|
||||
name: toolName,
|
||||
arguments: toolParams,
|
||||
rawArguments: toolCall.function.arguments,
|
||||
startTime: new Date(toolCallStartTime).toISOString(),
|
||||
endTime: new Date(toolCallEndTime).toISOString(),
|
||||
duration: toolCallDuration,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getCostMultiplier } from '@/lib/core/config/feature-flags'
|
||||
import { getCostMultiplier } from '@/lib/core/config/environment'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import type { StreamingExecution } from '@/executor/types'
|
||||
import type { ProviderRequest, ProviderResponse } from '@/providers/types'
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user