Compare commits

...

19 Commits

Author SHA1 Message Date
Waleed
52edbea659 v0.5.22: rss feed trigger, sftp tool, billing fixes, 413 surfacing, copilot improvements 2025-12-09 10:27:36 -08:00
Waleed
aa1d896b38 feat(i18n): update translations (#2268)
Co-authored-by: waleedlatif1 <waleedlatif1@users.noreply.github.com>
2025-12-09 00:46:09 -08:00
Waleed
2fcd07e82d feat(triggers): added rss feed trigger & poller (#2267) 2025-12-08 23:07:07 -08:00
Vikhyath Mondreti
0db5ba1b27 fix(org-limits): remove fallbacks for enterprise plan (#2255)
* fix(org-limits): remove fallbacks for enterprise plan

* remove comment

* remove comments

* make logger use new helper
2025-12-08 21:43:43 -08:00
Waleed
e390ba0491 feat(dropdowns): added searchbox to the operation dropdown for all blocks (#2266) 2025-12-08 20:54:59 -08:00
Vikhyath Mondreti
2f0509adaf fix(nextjs-size-limit): surface 413s accurately (#2265)
* fix(api-call-size-limit): cannot exceed nextjs size limits

* fix

* Convert to buffer

---------

Co-authored-by: Siddharth Ganesan <siddharthganesan@gmail.com>
2025-12-08 20:54:25 -08:00
Waleed
9f0584a818 feat(redis): added redis option for rate limiter, 10x speed improvement in rate limit checks & reduction of DB load (#2263)
* feat(redis): added redis option for rate limiter, 10x speed improvement in rate limit checks & reduction of DB load

* ack PR comments

* improvements
2025-12-08 20:39:29 -08:00
Siddharth Ganesan
6b4d76298f fix(custom-tools, copilot): custom tools state + copilot fixes (#2264)
* Workspace env vars

* Fix execution animation on copilot run

* Custom tools toolg

* Custom tools

* Fix custom tool

* remove extra fallback

* Fix lint
2025-12-08 20:14:49 -08:00
Vikhyath Mondreti
b7a1e8f5cf fix(pre-proc-checks): deployed checks should precede cost/ratelimit increments" (#2250) 2025-12-08 20:04:21 -08:00
Waleed
3ce2788562 feat(i18n): update translations (#2262)
Co-authored-by: waleedlatif1 <waleedlatif1@users.noreply.github.com>
2025-12-08 19:40:42 -08:00
Waleed
17a084cd61 feat(copilot): updated copilot keys to have names, full parity with API keys page (#2260) 2025-12-08 19:28:40 -08:00
Waleed
dafd2f5ce8 feat(tools): added sftp tool to accompany smtp and ssh tools (#2261) 2025-12-08 19:21:10 -08:00
Waleed
5af67d08ba feat(i18n): update translations (#2259)
* feat(i18n): update translations

* fix chinese page

---------

Co-authored-by: waleedlatif1 <waleedlatif1@users.noreply.github.com>
2025-12-08 17:58:49 -08:00
Waleed
209b0f1906 feat(tools): added duckduckgo (#2258) 2025-12-08 17:34:18 -08:00
Waleed
e067b584ee feat(admin): updated admin routes to consolidate duplicate behavior (#2257)
* feat(admin): updated admin routes to consolidate duplicate behavior

* ack PR comments
2025-12-08 17:29:55 -08:00
Waleed
87084edbe6 fix(timeouts): increased timeouts for function execution & agent (#2256) 2025-12-08 16:25:58 -08:00
Vikhyath Mondreti
99e0b81233 improvement(org): remove dead seats get endpoint (#2247)
* improvement(org): remove dead seats get endpoint

* remove more dead code

* remove fallback limit
2025-12-08 15:38:08 -08:00
Vikhyath Mondreti
d480057fd3 fix(migration): migration got removed by force push (#2253) 2025-12-08 14:08:12 -08:00
Vikhyath Mondreti
c197b04bcc fix(migration): migration got removed by force push (#2253) 2025-12-08 14:07:03 -08:00
136 changed files with 15593 additions and 1295 deletions

View File

@@ -3798,6 +3798,23 @@ export function SshIcon(props: SVGProps<SVGSVGElement>) {
)
}
export function SftpIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
{...props}
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 32 32'
width='32px'
height='32px'
>
<path
d='M 6 3 L 6 29 L 26 29 L 26 9.59375 L 25.71875 9.28125 L 19.71875 3.28125 L 19.40625 3 Z M 8 5 L 18 5 L 18 11 L 24 11 L 24 27 L 8 27 Z M 20 6.4375 L 22.5625 9 L 20 9 Z'
fill='currentColor'
/>
</svg>
)
}
export function ApifyIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
@@ -4129,3 +4146,56 @@ export function CursorIcon(props: SVGProps<SVGSVGElement>) {
</svg>
)
}
export function DuckDuckGoIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='-108 -108 216 216'>
<circle r='108' fill='#d53' />
<circle r='96' fill='none' stroke='#ffffff' stroke-width='7' />
<path
d='M-32-55C-62-48-51-6-51-6l19 93 7 3M-39-73h-8l11 4s-11 0-11 7c24-1 35 5 35 5'
fill='#ddd'
/>
<path d='M25 95S1 57 1 32c0-47 31-7 31-44S1-58 1-58c-15-19-44-15-44-15l7 4s-7 2-9 4 19-3 28 5c-37 3-31 33-31 33l21 120' />
<path d='M25-1l38-10c34 5-29 24-33 23C0 7 9 32 45 24s9 20-24 9C-26 20-1-3 25-1' fill='#fc0' />
<path
d='M15 78l2-3c22 8 23 11 22-9s0-20-23-3c0-5-13-3-15 0-21-9-23-12-22 2 2 29 1 24 21 14'
fill='#6b5'
/>
<path d='M-1 67v12c1 2 17 2 17-2s-8 3-13 1-2-13-2-13' fill='#4a4' />
<path
d='M-23-32c-5-6-18-1-15 7 1-4 8-10 15-7m32 0c1-6 11-7 14-1-4-2-10-2-14 1m-33 16a2 2 0 1 1 0 1m-8 3a7 7 0 1 0 0-1m52-6a2 2 0 1 1 0 1m-6 3a6 6 0 1 0 0-1'
fill='#148'
/>
</svg>
)
}
export function RssIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
{...props}
width='24'
height='24'
viewBox='0 0 24 24'
fill='none'
xmlns='http://www.w3.org/2000/svg'
>
<path
d='M4 11C6.38695 11 8.67613 11.9482 10.364 13.636C12.0518 15.3239 13 17.6131 13 20'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
/>
<path
d='M4 4C8.24346 4 12.3131 5.68571 15.3137 8.68629C18.3143 11.6869 20 15.7565 20 20'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
/>
<circle cx='5' cy='19' r='1' fill='currentColor' />
</svg>
)
}

View File

@@ -20,6 +20,7 @@ import {
DiscordIcon,
DocumentIcon,
DropboxIcon,
DuckDuckGoIcon,
DynamoDBIcon,
ElasticsearchIcon,
ElevenLabsIcon,
@@ -85,6 +86,7 @@ import {
SendgridIcon,
SentryIcon,
SerperIcon,
SftpIcon,
ShopifyIcon,
SlackIcon,
SmtpIcon,
@@ -147,6 +149,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
slack: SlackIcon,
shopify: ShopifyIcon,
sharepoint: MicrosoftSharepointIcon,
sftp: SftpIcon,
serper: SerperIcon,
sentry: SentryIcon,
sendgrid: SendgridIcon,
@@ -212,6 +215,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
elevenlabs: ElevenLabsIcon,
elasticsearch: ElasticsearchIcon,
dynamodb: DynamoDBIcon,
duckduckgo: DuckDuckGoIcon,
dropbox: DropboxIcon,
discord: DiscordIcon,
datadog: DatadogIcon,

View File

@@ -0,0 +1,63 @@
---
title: DuckDuckGo
description: Suche mit DuckDuckGo
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="duckduckgo"
color="#FFFFFF"
/>
{/* MANUAL-CONTENT-START:intro */}
[DuckDuckGo](https://duckduckgo.com/) ist eine datenschutzorientierte Websuchmaschine, die sofortige Antworten, Zusammenfassungen, verwandte Themen und mehr liefert ohne dich oder deine Suchen zu verfolgen. DuckDuckGo macht es einfach, Informationen ohne Benutzerprofilierung oder zielgerichtete Werbung zu finden.
Mit DuckDuckGo in Sim kannst du:
- **Im Web suchen**: Finde sofort Antworten, Fakten und Übersichten für eine bestimmte Suchanfrage
- **Direkte Antworten erhalten**: Erhalte spezifische Antworten für Berechnungen, Umrechnungen oder Faktenfragen
- **Auf Zusammenfassungen zugreifen**: Erhalte kurze Zusammenfassungen oder Beschreibungen für deine Suchthemen
- **Verwandte Themen abrufen**: Entdecke Links und Referenzen, die für deine Suche relevant sind
- **Ausgabe filtern**: Optional HTML entfernen oder Begriffsklärungen überspringen für sauberere Ergebnisse
Diese Funktionen ermöglichen es deinen Sim-Agenten, den Zugriff auf aktuelles Webwissen zu automatisieren vom Auffinden von Fakten in einem Workflow bis hin zur Anreicherung von Dokumenten und Analysen mit aktuellen Informationen. Da DuckDuckGos Instant Answers API offen ist und keinen API-Schlüssel erfordert, lässt sie sich einfach und datenschutzsicher in deine automatisierten Geschäftsprozesse integrieren.
{/* MANUAL-CONTENT-END */}
## Nutzungsanleitung
Durchsuche das Web mit der DuckDuckGo Instant Answers API. Liefert sofortige Antworten, Zusammenfassungen, verwandte Themen und mehr. Kostenlos nutzbar ohne API-Schlüssel.
## Tools
### `duckduckgo_search`
Durchsuche das Web mit der DuckDuckGo Instant Answers API. Liefert sofortige Antworten, Zusammenfassungen und verwandte Themen für deine Anfrage. Kostenlos nutzbar ohne API-Schlüssel.
#### Eingabe
| Parameter | Typ | Erforderlich | Beschreibung |
| --------- | ---- | -------- | ----------- |
| `query` | string | Ja | Die auszuführende Suchanfrage |
| `noHtml` | boolean | Nein | HTML aus Text in Ergebnissen entfernen \(Standard: true\) |
| `skipDisambig` | boolean | Nein | Begriffsklärungsergebnisse überspringen \(Standard: false\) |
#### Ausgabe
| Parameter | Typ | Beschreibung |
| --------- | ---- | ----------- |
| `heading` | string | Die Überschrift/der Titel der Sofortantwort |
| `abstract` | string | Eine kurze Zusammenfassung des Themas |
| `abstractText` | string | Einfache Textversion der Zusammenfassung |
| `abstractSource` | string | Die Quelle der Zusammenfassung \(z.B. Wikipedia\) |
| `abstractURL` | string | URL zur Quelle der Zusammenfassung |
| `image` | string | URL zu einem Bild zum Thema |
| `answer` | string | Direkte Antwort, falls verfügbar \(z.B. für Berechnungen\) |
| `answerType` | string | Typ der Antwort \(z.B. calc, ip, usw.\) |
| `type` | string | Antworttyp: A \(Artikel\), D \(Begriffsklärung\), C \(Kategorie\), N \(Name\), E \(Exklusiv\) |
| `relatedTopics` | array | Array verwandter Themen mit URLs und Beschreibungen |
## Hinweise
- Kategorie: `tools`
- Typ: `duckduckgo`

View File

@@ -0,0 +1,183 @@
---
title: SFTP
description: Übertragen Sie Dateien über SFTP (SSH File Transfer Protocol)
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="sftp"
color="#2D3748"
/>
{/* MANUAL-CONTENT-START:intro */}
[SFTP (SSH File Transfer Protocol)](https://en.wikipedia.org/wiki/SSH_File_Transfer_Protocol) ist ein sicheres Netzwerkprotokoll, das es Ihnen ermöglicht, Dateien auf entfernten Servern hochzuladen, herunterzuladen und zu verwalten. SFTP arbeitet über SSH und ist damit ideal für automatisierte, verschlüsselte Dateiübertragungen und die Fernverwaltung von Dateien in modernen Arbeitsabläufen.
Mit den in Sim integrierten SFTP-Tools können Sie die Übertragung von Dateien zwischen Ihren KI-Agenten und externen Systemen oder Servern einfach automatisieren. Dies ermöglicht Ihren Agenten, kritische Datenaustausche, Backups, Dokumentenerstellung und die Orchestrierung entfernter Systeme alles mit robuster Sicherheit zu verwalten.
**Wichtige Funktionen, die über SFTP-Tools verfügbar sind:**
- **Dateien hochladen:** Übertragen Sie nahtlos Dateien jeder Art von Ihrem Workflow auf einen entfernten Server, mit Unterstützung für Passwort- und SSH-Private-Key-Authentifizierung.
- **Dateien herunterladen:** Rufen Sie Dateien von entfernten SFTP-Servern direkt zur Verarbeitung, Archivierung oder weiteren Automatisierung ab.
- **Dateien auflisten & verwalten:** Verzeichnisse auflisten, Dateien und Ordner löschen oder erstellen und Dateisystemberechtigungen ferngesteuert verwalten.
- **Flexible Authentifizierung:** Verbinden Sie sich entweder mit herkömmlichen Passwörtern oder SSH-Schlüsseln, mit Unterstützung für Passphrasen und Berechtigungskontrolle.
- **Unterstützung großer Dateien:** Verwalten Sie programmatisch große Datei-Uploads und -Downloads, mit integrierten Größenbeschränkungen für die Sicherheit.
Durch die Integration von SFTP in Sim können Sie sichere Dateioperationen als Teil jedes Workflows automatisieren, sei es Datenerfassung, Berichterstattung, Wartung entfernter Systeme oder dynamischer Inhaltsaustausch zwischen Plattformen.
Die folgenden Abschnitte beschreiben die wichtigsten verfügbaren SFTP-Tools:
- **sftp_upload:** Laden Sie eine oder mehrere Dateien auf einen entfernten Server hoch.
- **sftp_download:** Laden Sie Dateien von einem entfernten Server in Ihren Workflow herunter.
- **sftp_list:** Listen Sie Verzeichnisinhalte auf einem entfernten SFTP-Server auf.
- **sftp_delete:** Löschen Sie Dateien oder Verzeichnisse von einem entfernten Server.
- **sftp_create:** Erstellen Sie neue Dateien auf einem entfernten SFTP-Server.
- **sftp_mkdir:** Erstellen Sie neue Verzeichnisse aus der Ferne.
Siehe die Werkzeugdokumentation unten für detaillierte Ein- und Ausgabeparameter für jede Operation.
{/* MANUAL-CONTENT-END */}
## Nutzungsanweisungen
Dateien auf Remote-Servern über SFTP hochladen, herunterladen, auflisten und verwalten. Unterstützt sowohl Passwort- als auch Private-Key-Authentifizierung für sichere Dateiübertragungen.
## Werkzeuge
### `sftp_upload`
Dateien auf einen Remote-SFTP-Server hochladen
#### Eingabe
| Parameter | Typ | Erforderlich | Beschreibung |
| --------- | ---- | -------- | ----------- |
| `host` | string | Ja | SFTP-Server-Hostname oder IP-Adresse |
| `port` | number | Ja | SFTP-Server-Port \(Standard: 22\) |
| `username` | string | Ja | SFTP-Benutzername |
| `password` | string | Nein | Passwort für die Authentifizierung \(wenn kein Private Key verwendet wird\) |
| `privateKey` | string | Nein | Private Key für die Authentifizierung \(OpenSSH-Format\) |
| `passphrase` | string | Nein | Passphrase für verschlüsselten Private Key |
| `remotePath` | string | Ja | Zielverzeichnis auf dem Remote-Server |
| `files` | file[] | Nein | Hochzuladende Dateien |
| `fileContent` | string | Nein | Direkter Dateiinhalt zum Hochladen \(für Textdateien\) |
| `fileName` | string | Nein | Dateiname bei Verwendung von direktem Inhalt |
| `overwrite` | boolean | Nein | Ob bestehende Dateien überschrieben werden sollen \(Standard: true\) |
| `permissions` | string | Nein | Dateiberechtigungen \(z.B. 0644\) |
#### Ausgabe
| Parameter | Typ | Beschreibung |
| --------- | ---- | ----------- |
| `success` | boolean | Ob der Upload erfolgreich war |
| `uploadedFiles` | json | Array mit Details zu hochgeladenen Dateien \(Name, remotePath, Größe\) |
| `message` | string | Statusmeldung des Vorgangs |
### `sftp_download`
Datei von einem entfernten SFTP-Server herunterladen
#### Eingabe
| Parameter | Typ | Erforderlich | Beschreibung |
| --------- | ---- | -------- | ----------- |
| `host` | string | Ja | SFTP-Server-Hostname oder IP-Adresse |
| `port` | number | Ja | SFTP-Server-Port \(Standard: 22\) |
| `username` | string | Ja | SFTP-Benutzername |
| `password` | string | Nein | Passwort für die Authentifizierung \(wenn kein privater Schlüssel verwendet wird\) |
| `privateKey` | string | Nein | Privater Schlüssel für die Authentifizierung \(OpenSSH-Format\) |
| `passphrase` | string | Nein | Passphrase für verschlüsselten privaten Schlüssel |
| `remotePath` | string | Ja | Pfad zur Datei auf dem entfernten Server |
| `encoding` | string | Nein | Ausgabe-Kodierung: utf-8 für Text, base64 für Binärdaten \(Standard: utf-8\) |
#### Ausgabe
| Parameter | Typ | Beschreibung |
| --------- | ---- | ----------- |
| `success` | boolean | Ob der Download erfolgreich war |
| `fileName` | string | Name der heruntergeladenen Datei |
| `content` | string | Dateiinhalt \(Text oder base64-kodiert\) |
| `size` | number | Dateigröße in Bytes |
| `encoding` | string | Inhaltskodierung \(utf-8 oder base64\) |
| `message` | string | Statusmeldung des Vorgangs |
### `sftp_list`
Dateien und Verzeichnisse auf einem entfernten SFTP-Server auflisten
#### Eingabe
| Parameter | Typ | Erforderlich | Beschreibung |
| --------- | ---- | -------- | ----------- |
| `host` | string | Ja | SFTP-Server-Hostname oder IP-Adresse |
| `port` | number | Ja | SFTP-Server-Port \(Standard: 22\) |
| `username` | string | Ja | SFTP-Benutzername |
| `password` | string | Nein | Passwort für die Authentifizierung \(wenn kein privater Schlüssel verwendet wird\) |
| `privateKey` | string | Nein | Privater Schlüssel für die Authentifizierung \(OpenSSH-Format\) |
| `passphrase` | string | Nein | Passphrase für verschlüsselten privaten Schlüssel |
| `remotePath` | string | Ja | Verzeichnispfad auf dem entfernten Server |
| `detailed` | boolean | Nein | Detaillierte Dateiinformationen einschließen \(Größe, Berechtigungen, Änderungsdatum\) |
#### Ausgabe
| Parameter | Typ | Beschreibung |
| --------- | ---- | ----------- |
| `success` | boolean | Ob der Vorgang erfolgreich war |
| `path` | string | Verzeichnispfad, der aufgelistet wurde |
| `entries` | json | Array von Verzeichniseinträgen mit Name, Typ, Größe, Berechtigungen, modifiedAt |
| `count` | number | Anzahl der Einträge im Verzeichnis |
| `message` | string | Statusmeldung des Vorgangs |
### `sftp_delete`
Löschen einer Datei oder eines Verzeichnisses auf einem entfernten SFTP-Server
#### Eingabe
| Parameter | Typ | Erforderlich | Beschreibung |
| --------- | ---- | -------- | ----------- |
| `host` | string | Ja | SFTP-Server-Hostname oder IP-Adresse |
| `port` | number | Ja | SFTP-Server-Port \(Standard: 22\) |
| `username` | string | Ja | SFTP-Benutzername |
| `password` | string | Nein | Passwort für die Authentifizierung \(wenn kein privater Schlüssel verwendet wird\) |
| `privateKey` | string | Nein | Privater Schlüssel für die Authentifizierung \(OpenSSH-Format\) |
| `passphrase` | string | Nein | Passphrase für verschlüsselten privaten Schlüssel |
| `remotePath` | string | Ja | Pfad zur Datei oder zum Verzeichnis, das gelöscht werden soll |
| `recursive` | boolean | Nein | Verzeichnisse rekursiv löschen |
#### Ausgabe
| Parameter | Typ | Beschreibung |
| --------- | ---- | ----------- |
| `success` | boolean | Ob das Löschen erfolgreich war |
| `deletedPath` | string | Pfad, der gelöscht wurde |
| `message` | string | Statusmeldung des Vorgangs |
### `sftp_mkdir`
Ein Verzeichnis auf einem entfernten SFTP-Server erstellen
#### Eingabe
| Parameter | Typ | Erforderlich | Beschreibung |
| --------- | ---- | -------- | ----------- |
| `host` | string | Ja | SFTP-Server-Hostname oder IP-Adresse |
| `port` | number | Ja | SFTP-Server-Port \(Standard: 22\) |
| `username` | string | Ja | SFTP-Benutzername |
| `password` | string | Nein | Passwort für die Authentifizierung \(wenn kein privater Schlüssel verwendet wird\) |
| `privateKey` | string | Nein | Privater Schlüssel für die Authentifizierung \(OpenSSH-Format\) |
| `passphrase` | string | Nein | Passphrase für verschlüsselten privaten Schlüssel |
| `remotePath` | string | Ja | Pfad für das neue Verzeichnis |
| `recursive` | boolean | Nein | Übergeordnete Verzeichnisse erstellen, falls sie nicht existieren |
#### Ausgabe
| Parameter | Typ | Beschreibung |
| --------- | ---- | ----------- |
| `success` | boolean | Ob das Verzeichnis erfolgreich erstellt wurde |
| `createdPath` | string | Pfad des erstellten Verzeichnisses |
| `message` | string | Statusmeldung des Vorgangs |
## Hinweise
- Kategorie: `tools`
- Typ: `sftp`

View File

@@ -7,7 +7,7 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="smtp"
color="#4A5568"
color="#2D3748"
/>
{/* MANUAL-CONTENT-START:intro */}

View File

@@ -30,15 +30,19 @@ Verwende den Start-Block für alles, was aus dem Editor, deploy-to-API oder depl
<Card title="Schedule" href="/triggers/schedule">
Cron- oder intervallbasierte Ausführung
</Card>
<Card title="RSS Feed" href="/triggers/rss">
RSS- und Atom-Feeds auf neue Inhalte überwachen
</Card>
</Cards>
## Schneller Vergleich
| Trigger | Startbedingung |
|---------|-----------------|
| **Start** | Editor-Ausführungen, deploy-to-API Anfragen oder Chat-Nachrichten |
| **Start** | Editor-Ausführungen, Deploy-to-API-Anfragen oder Chat-Nachrichten |
| **Schedule** | Timer, der im Schedule-Block verwaltet wird |
| **Webhook** | Bei eingehender HTTP-Anfrage |
| **RSS Feed** | Neues Element im Feed veröffentlicht |
> Der Start-Block stellt immer `input`, `conversationId` und `files` Felder bereit. Füge benutzerdefinierte Felder zum Eingabeformat für zusätzliche strukturierte Daten hinzu.

View File

@@ -0,0 +1,49 @@
---
title: RSS-Feed
---
import { Callout } from 'fumadocs-ui/components/callout'
import { Image } from '@/components/ui/image'
Der RSS-Feed-Block überwacht RSS- und Atom-Feeds wenn neue Einträge veröffentlicht werden, wird Ihr Workflow automatisch ausgelöst.
<div className="flex justify-center">
<Image
src="/static/blocks/rss.png"
alt="RSS-Feed-Block"
width={500}
height={400}
className="my-6"
/>
</div>
## Konfiguration
1. **RSS-Feed-Block hinzufügen** - Ziehen Sie den RSS-Feed-Block, um Ihren Workflow zu starten
2. **Feed-URL eingeben** - Fügen Sie die URL eines beliebigen RSS- oder Atom-Feeds ein
3. **Bereitstellen** - Stellen Sie Ihren Workflow bereit, um das Polling zu aktivieren
Nach der Bereitstellung wird der Feed jede Minute auf neue Einträge überprüft.
## Ausgabefelder
| Feld | Typ | Beschreibung |
|-------|------|-------------|
| `title` | string | Titel des Eintrags |
| `link` | string | Link des Eintrags |
| `pubDate` | string | Veröffentlichungsdatum |
| `item` | object | Rohdaten des Eintrags mit allen Feldern |
| `feed` | object | Rohdaten der Feed-Metadaten |
Greifen Sie direkt auf zugeordnete Felder zu (`<rss.title>`) oder verwenden Sie die Rohobjekte für beliebige Felder (`<rss.item.author>`, `<rss.feed.language>`).
## Anwendungsfälle
- **Inhaltsüberwachung** - Verfolgen Sie Blogs, Nachrichtenseiten oder Updates von Wettbewerbern
- **Podcast-Automatisierung** - Lösen Sie Workflows aus, wenn neue Episoden erscheinen
- **Release-Tracking** - Überwachen Sie GitHub-Releases, Changelogs oder Produkt-Updates
- **Social-Media-Aggregation** - Sammeln Sie Inhalte von Plattformen, die RSS-Feeds anbieten
<Callout>
RSS-Trigger werden nur für Einträge ausgelöst, die nach dem Speichern des Triggers veröffentlicht wurden. Bestehende Feed-Einträge werden nicht verarbeitet.
</Callout>

View File

@@ -0,0 +1,68 @@
---
title: DuckDuckGo
description: Search with DuckDuckGo
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="duckduckgo"
color="#FFFFFF"
/>
{/* MANUAL-CONTENT-START:intro */}
[DuckDuckGo](https://duckduckgo.com/) is a privacy-focused web search engine that delivers instant answers, abstracts, related topics, and more — without tracking you or your searches. DuckDuckGo makes it easy to find information without any user profiling or targeted ads.
With DuckDuckGo in Sim, you can:
- **Search the web**: Instantly find answers, facts, and overviews for a given search query
- **Get direct answers**: Retrieve specific responses for calculations, conversions, or factual queries
- **Access abstracts**: Receive short summaries or descriptions for your search topics
- **Fetch related topics**: Discover links and references relevant to your search
- **Filter output**: Optionally remove HTML or skip disambiguation for cleaner results
These features enable your Sim agents to automate access to fresh web knowledge — from surfacing facts in a workflow, to enriching documents and analysis with up-to-date information. Because DuckDuckGos Instant Answers API is open and does not require an API key, its simple and privacy-safe to integrate into your automated business processes.
{/* MANUAL-CONTENT-END */}
## Usage Instructions
Search the web using DuckDuckGo Instant Answers API. Returns instant answers, abstracts, related topics, and more. Free to use without an API key.
## Tools
### `duckduckgo_search`
Search the web using DuckDuckGo Instant Answers API. Returns instant answers, abstracts, and related topics for your query. Free to use without an API key.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `query` | string | Yes | The search query to execute |
| `noHtml` | boolean | No | Remove HTML from text in results \(default: true\) |
| `skipDisambig` | boolean | No | Skip disambiguation results \(default: false\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `heading` | string | The heading/title of the instant answer |
| `abstract` | string | A short abstract summary of the topic |
| `abstractText` | string | Plain text version of the abstract |
| `abstractSource` | string | The source of the abstract \(e.g., Wikipedia\) |
| `abstractURL` | string | URL to the source of the abstract |
| `image` | string | URL to an image related to the topic |
| `answer` | string | Direct answer if available \(e.g., for calculations\) |
| `answerType` | string | Type of the answer \(e.g., calc, ip, etc.\) |
| `type` | string | Response type: A \(article\), D \(disambiguation\), C \(category\), N \(name\), E \(exclusive\) |
| `relatedTopics` | array | Array of related topics with URLs and descriptions |
## Notes
- Category: `tools`
- Type: `duckduckgo`

View File

@@ -15,6 +15,7 @@
"datadog",
"discord",
"dropbox",
"duckduckgo",
"dynamodb",
"elasticsearch",
"elevenlabs",
@@ -80,6 +81,7 @@
"sendgrid",
"sentry",
"serper",
"sftp",
"sharepoint",
"shopify",
"slack",

View File

@@ -0,0 +1,188 @@
---
title: SFTP
description: Transfer files via SFTP (SSH File Transfer Protocol)
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="sftp"
color="#2D3748"
/>
{/* MANUAL-CONTENT-START:intro */}
[SFTP (SSH File Transfer Protocol)](https://en.wikipedia.org/wiki/SSH_File_Transfer_Protocol) is a secure network protocol that enables you to upload, download, and manage files on remote servers. SFTP operates over SSH, making it ideal for automated, encrypted file transfers and remote file management within modern workflows.
With SFTP tools integrated into Sim, you can easily automate the movement of files between your AI agents and external systems or servers. This empowers your agents to manage critical data exchanges, backups, document generation, and remote system orchestration—all with robust security.
**Key functionality available via SFTP tools:**
- **Upload Files:** Seamlessly transfer files of any type from your workflow to a remote server, with support for both password and SSH private key authentication.
- **Download Files:** Retrieve files from remote SFTP servers directly for processing, archiving, or further automation.
- **List & Manage Files:** Enumerate directories, delete or create files and folders, and manage file system permissions remotely.
- **Flexible Authentication:** Connect using either traditional passwords or SSH keys, with support for passphrases and permissions control.
- **Large File Support:** Programmatically manage large file uploads and downloads, with built-in size limits for safety.
By integrating SFTP into Sim, you can automate secure file operations as part of any workflow, whether its data collection, reporting, remote system maintenance, or dynamic content exchange between platforms.
The sections below describe the key SFTP tools available:
- **sftp_upload:** Upload one or more files to a remote server.
- **sftp_download:** Download files from a remote server to your workflow.
- **sftp_list:** List directory contents on a remote SFTP server.
- **sftp_delete:** Delete files or directories from a remote server.
- **sftp_create:** Create new files on a remote SFTP server.
- **sftp_mkdir:** Create new directories remotely.
See the tool documentation below for detailed input and output parameters for each operation.
{/* MANUAL-CONTENT-END */}
## Usage Instructions
Upload, download, list, and manage files on remote servers via SFTP. Supports both password and private key authentication for secure file transfers.
## Tools
### `sftp_upload`
Upload files to a remote SFTP server
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `host` | string | Yes | SFTP server hostname or IP address |
| `port` | number | Yes | SFTP server port \(default: 22\) |
| `username` | string | Yes | SFTP username |
| `password` | string | No | Password for authentication \(if not using private key\) |
| `privateKey` | string | No | Private key for authentication \(OpenSSH format\) |
| `passphrase` | string | No | Passphrase for encrypted private key |
| `remotePath` | string | Yes | Destination directory on the remote server |
| `files` | file[] | No | Files to upload |
| `fileContent` | string | No | Direct file content to upload \(for text files\) |
| `fileName` | string | No | File name when using direct content |
| `overwrite` | boolean | No | Whether to overwrite existing files \(default: true\) |
| `permissions` | string | No | File permissions \(e.g., 0644\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `success` | boolean | Whether the upload was successful |
| `uploadedFiles` | json | Array of uploaded file details \(name, remotePath, size\) |
| `message` | string | Operation status message |
### `sftp_download`
Download a file from a remote SFTP server
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `host` | string | Yes | SFTP server hostname or IP address |
| `port` | number | Yes | SFTP server port \(default: 22\) |
| `username` | string | Yes | SFTP username |
| `password` | string | No | Password for authentication \(if not using private key\) |
| `privateKey` | string | No | Private key for authentication \(OpenSSH format\) |
| `passphrase` | string | No | Passphrase for encrypted private key |
| `remotePath` | string | Yes | Path to the file on the remote server |
| `encoding` | string | No | Output encoding: utf-8 for text, base64 for binary \(default: utf-8\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `success` | boolean | Whether the download was successful |
| `fileName` | string | Name of the downloaded file |
| `content` | string | File content \(text or base64 encoded\) |
| `size` | number | File size in bytes |
| `encoding` | string | Content encoding \(utf-8 or base64\) |
| `message` | string | Operation status message |
### `sftp_list`
List files and directories on a remote SFTP server
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `host` | string | Yes | SFTP server hostname or IP address |
| `port` | number | Yes | SFTP server port \(default: 22\) |
| `username` | string | Yes | SFTP username |
| `password` | string | No | Password for authentication \(if not using private key\) |
| `privateKey` | string | No | Private key for authentication \(OpenSSH format\) |
| `passphrase` | string | No | Passphrase for encrypted private key |
| `remotePath` | string | Yes | Directory path on the remote server |
| `detailed` | boolean | No | Include detailed file information \(size, permissions, modified date\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `success` | boolean | Whether the operation was successful |
| `path` | string | Directory path that was listed |
| `entries` | json | Array of directory entries with name, type, size, permissions, modifiedAt |
| `count` | number | Number of entries in the directory |
| `message` | string | Operation status message |
### `sftp_delete`
Delete a file or directory on a remote SFTP server
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `host` | string | Yes | SFTP server hostname or IP address |
| `port` | number | Yes | SFTP server port \(default: 22\) |
| `username` | string | Yes | SFTP username |
| `password` | string | No | Password for authentication \(if not using private key\) |
| `privateKey` | string | No | Private key for authentication \(OpenSSH format\) |
| `passphrase` | string | No | Passphrase for encrypted private key |
| `remotePath` | string | Yes | Path to the file or directory to delete |
| `recursive` | boolean | No | Delete directories recursively |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `success` | boolean | Whether the deletion was successful |
| `deletedPath` | string | Path that was deleted |
| `message` | string | Operation status message |
### `sftp_mkdir`
Create a directory on a remote SFTP server
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `host` | string | Yes | SFTP server hostname or IP address |
| `port` | number | Yes | SFTP server port \(default: 22\) |
| `username` | string | Yes | SFTP username |
| `password` | string | No | Password for authentication \(if not using private key\) |
| `privateKey` | string | No | Private key for authentication \(OpenSSH format\) |
| `passphrase` | string | No | Passphrase for encrypted private key |
| `remotePath` | string | Yes | Path for the new directory |
| `recursive` | boolean | No | Create parent directories if they do not exist |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `success` | boolean | Whether the directory was created successfully |
| `createdPath` | string | Path of the created directory |
| `message` | string | Operation status message |
## Notes
- Category: `tools`
- Type: `sftp`

View File

@@ -7,7 +7,7 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="smtp"
color="#4A5568"
color="#2D3748"
/>
{/* MANUAL-CONTENT-START:intro */}

View File

@@ -30,6 +30,9 @@ Use the Start block for everything originating from the editor, deploy-to-API, o
<Card title="Schedule" href="/triggers/schedule">
Cron or interval based execution
</Card>
<Card title="RSS Feed" href="/triggers/rss">
Monitor RSS and Atom feeds for new content
</Card>
</Cards>
## Quick Comparison
@@ -39,6 +42,7 @@ Use the Start block for everything originating from the editor, deploy-to-API, o
| **Start** | Editor runs, deploy-to-API requests, or chat messages |
| **Schedule** | Timer managed in schedule block |
| **Webhook** | On inbound HTTP request |
| **RSS Feed** | New item published to feed |
> The Start block always exposes `input`, `conversationId`, and `files` fields. Add custom fields to the input format for additional structured data.

View File

@@ -1,3 +1,3 @@
{
"pages": ["index", "start", "schedule", "webhook"]
"pages": ["index", "start", "schedule", "webhook", "rss"]
}

View File

@@ -0,0 +1,49 @@
---
title: RSS Feed
---
import { Callout } from 'fumadocs-ui/components/callout'
import { Image } from '@/components/ui/image'
The RSS Feed block monitors RSS and Atom feeds when new items are published, your workflow triggers automatically.
<div className="flex justify-center">
<Image
src="/static/blocks/rss.png"
alt="RSS Feed Block"
width={500}
height={400}
className="my-6"
/>
</div>
## Configuration
1. **Add RSS Feed Block** - Drag the RSS Feed block to start your workflow
2. **Enter Feed URL** - Paste the URL of any RSS or Atom feed
3. **Deploy** - Deploy your workflow to activate polling
Once deployed, the feed is checked every minute for new items.
## Output Fields
| Field | Type | Description |
|-------|------|-------------|
| `title` | string | Item title |
| `link` | string | Item link |
| `pubDate` | string | Publication date |
| `item` | object | Raw item with all fields |
| `feed` | object | Raw feed metadata |
Access mapped fields directly (`<rss.title>`) or use the raw objects for any field (`<rss.item.author>`, `<rss.feed.language>`).
## Use Cases
- **Content monitoring** - Track blogs, news sites, or competitor updates
- **Podcast automation** - Trigger workflows when new episodes drop
- **Release tracking** - Monitor GitHub releases, changelogs, or product updates
- **Social aggregation** - Collect content from platforms that expose RSS feeds
<Callout>
RSS triggers only fire for items published after you save the trigger. Existing feed items are not processed.
</Callout>

View File

@@ -0,0 +1,63 @@
---
title: DuckDuckGo
description: Busca con DuckDuckGo
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="duckduckgo"
color="#FFFFFF"
/>
{/* MANUAL-CONTENT-START:intro */}
[DuckDuckGo](https://duckduckgo.com/) es un motor de búsqueda web enfocado en la privacidad que ofrece respuestas instantáneas, resúmenes, temas relacionados y más, sin rastrear a ti o tus búsquedas. DuckDuckGo facilita encontrar información sin perfiles de usuario ni anuncios dirigidos.
Con DuckDuckGo en Sim, puedes:
- **Buscar en la web**: Encuentra instantáneamente respuestas, hechos y resúmenes para una consulta de búsqueda determinada
- **Obtener respuestas directas**: Recibe respuestas específicas para cálculos, conversiones o consultas factuales
- **Acceder a resúmenes**: Recibe breves sumarios o descripciones para tus temas de búsqueda
- **Obtener temas relacionados**: Descubre enlaces y referencias relevantes para tu búsqueda
- **Filtrar resultados**: Opcionalmente elimina HTML o evita la desambiguación para obtener resultados más limpios
Estas características permiten a tus agentes Sim automatizar el acceso a conocimientos web actualizados, desde mostrar hechos en un flujo de trabajo hasta enriquecer documentos y análisis con información actualizada. Como la API de Respuestas Instantáneas de DuckDuckGo es abierta y no requiere una clave API, es simple y segura para la privacidad al integrarla en tus procesos de negocio automatizados.
{/* MANUAL-CONTENT-END */}
## Instrucciones de uso
Busca en la web usando la API de Respuestas Instantáneas de DuckDuckGo. Devuelve respuestas instantáneas, resúmenes, temas relacionados y más. Uso gratuito sin necesidad de clave API.
## Herramientas
### `duckduckgo_search`
Busca en la web usando la API de Respuestas Instantáneas de DuckDuckGo. Devuelve respuestas instantáneas, resúmenes y temas relacionados para tu consulta. Uso gratuito sin necesidad de clave API.
#### Entrada
| Parámetro | Tipo | Obligatorio | Descripción |
| --------- | ---- | ----------- | ----------- |
| `query` | string | Sí | La consulta de búsqueda a ejecutar |
| `noHtml` | boolean | No | Eliminar HTML del texto en los resultados \(predeterminado: true\) |
| `skipDisambig` | boolean | No | Omitir resultados de desambiguación \(predeterminado: false\) |
#### Salida
| Parámetro | Tipo | Descripción |
| --------- | ---- | ----------- |
| `heading` | string | El encabezado/título de la respuesta instantánea |
| `abstract` | string | Un breve resumen abstracto del tema |
| `abstractText` | string | Versión en texto plano del resumen |
| `abstractSource` | string | La fuente del resumen \(p. ej., Wikipedia\) |
| `abstractURL` | string | URL a la fuente del resumen |
| `image` | string | URL a una imagen relacionada con el tema |
| `answer` | string | Respuesta directa si está disponible \(p. ej., para cálculos\) |
| `answerType` | string | Tipo de respuesta \(p. ej., calc, ip, etc.\) |
| `type` | string | Tipo de respuesta: A \(artículo\), D \(desambiguación\), C \(categoría\), N \(nombre\), E \(exclusivo\) |
| `relatedTopics` | array | Array de temas relacionados con URLs y descripciones |
## Notas
- Categoría: `tools`
- Tipo: `duckduckgo`

View File

@@ -0,0 +1,184 @@
---
title: SFTP
description: Transferir archivos a través de SFTP (Protocolo de transferencia de
archivos SSH)
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="sftp"
color="#2D3748"
/>
{/* MANUAL-CONTENT-START:intro */}
[SFTP (Protocolo de transferencia de archivos SSH)](https://en.wikipedia.org/wiki/SSH_File_Transfer_Protocol) es un protocolo de red seguro que te permite subir, descargar y gestionar archivos en servidores remotos. SFTP opera sobre SSH, lo que lo hace ideal para transferencias de archivos automatizadas y cifradas, así como para la gestión remota de archivos dentro de flujos de trabajo modernos.
Con las herramientas SFTP integradas en Sim, puedes automatizar fácilmente el movimiento de archivos entre tus agentes de IA y sistemas o servidores externos. Esto permite a tus agentes gestionar intercambios críticos de datos, copias de seguridad, generación de documentos y orquestación de sistemas remotos, todo con una seguridad robusta.
**Funcionalidades clave disponibles a través de las herramientas SFTP:**
- **Subir archivos:** Transfiere sin problemas archivos de cualquier tipo desde tu flujo de trabajo a un servidor remoto, con soporte tanto para autenticación por contraseña como por clave privada SSH.
- **Descargar archivos:** Recupera archivos de servidores SFTP remotos directamente para su procesamiento, archivo o automatización adicional.
- **Listar y gestionar archivos:** Enumera directorios, elimina o crea archivos y carpetas, y gestiona permisos del sistema de archivos de forma remota.
- **Autenticación flexible:** Conéctate usando contraseñas tradicionales o claves SSH, con soporte para frases de contraseña y control de permisos.
- **Soporte para archivos grandes:** Gestiona programáticamente cargas y descargas de archivos grandes, con límites de tamaño incorporados para mayor seguridad.
Al integrar SFTP en Sim, puedes automatizar operaciones seguras de archivos como parte de cualquier flujo de trabajo, ya sea recopilación de datos, informes, mantenimiento de sistemas remotos o intercambio dinámico de contenido entre plataformas.
Las secciones a continuación describen las principales herramientas SFTP disponibles:
- **sftp_upload:** Sube uno o más archivos a un servidor remoto.
- **sftp_download:** Descarga archivos desde un servidor remoto a tu flujo de trabajo.
- **sftp_list:** Lista el contenido de directorios en un servidor SFTP remoto.
- **sftp_delete:** Elimina archivos o directorios de un servidor remoto.
- **sftp_create:** Crea nuevos archivos en un servidor SFTP remoto.
- **sftp_mkdir:** Crea nuevos directorios de forma remota.
Consulta la documentación de la herramienta a continuación para conocer los parámetros detallados de entrada y salida para cada operación.
{/* MANUAL-CONTENT-END */}
## Instrucciones de uso
Sube, descarga, lista y gestiona archivos en servidores remotos a través de SFTP. Compatible con autenticación por contraseña y clave privada para transferencias seguras de archivos.
## Herramientas
### `sftp_upload`
Subir archivos a un servidor SFTP remoto
#### Entrada
| Parámetro | Tipo | Obligatorio | Descripción |
| --------- | ---- | ----------- | ----------- |
| `host` | string | Sí | Nombre de host o dirección IP del servidor SFTP |
| `port` | number | Sí | Puerto del servidor SFTP \(predeterminado: 22\) |
| `username` | string | Sí | Nombre de usuario SFTP |
| `password` | string | No | Contraseña para autenticación \(si no se usa clave privada\) |
| `privateKey` | string | No | Clave privada para autenticación \(formato OpenSSH\) |
| `passphrase` | string | No | Frase de contraseña para clave privada cifrada |
| `remotePath` | string | Sí | Directorio de destino en el servidor remoto |
| `files` | file[] | No | Archivos para subir |
| `fileContent` | string | No | Contenido directo del archivo para subir \(para archivos de texto\) |
| `fileName` | string | No | Nombre del archivo cuando se usa contenido directo |
| `overwrite` | boolean | No | Si se deben sobrescribir archivos existentes \(predeterminado: true\) |
| `permissions` | string | No | Permisos del archivo \(p. ej., 0644\) |
#### Salida
| Parámetro | Tipo | Descripción |
| --------- | ---- | ----------- |
| `success` | boolean | Si la subida fue exitosa |
| `uploadedFiles` | json | Array de detalles de archivos subidos \(nombre, rutaRemota, tamaño\) |
| `message` | string | Mensaje de estado de la operación |
### `sftp_download`
Descargar un archivo desde un servidor SFTP remoto
#### Entrada
| Parámetro | Tipo | Obligatorio | Descripción |
| --------- | ---- | -------- | ----------- |
| `host` | string | Sí | Nombre de host o dirección IP del servidor SFTP |
| `port` | number | Sí | Puerto del servidor SFTP \(predeterminado: 22\) |
| `username` | string | Sí | Nombre de usuario SFTP |
| `password` | string | No | Contraseña para autenticación \(si no se usa clave privada\) |
| `privateKey` | string | No | Clave privada para autenticación \(formato OpenSSH\) |
| `passphrase` | string | No | Frase de contraseña para clave privada cifrada |
| `remotePath` | string | Sí | Ruta al archivo en el servidor remoto |
| `encoding` | string | No | Codificación de salida: utf-8 para texto, base64 para binario \(predeterminado: utf-8\) |
#### Salida
| Parámetro | Tipo | Descripción |
| --------- | ---- | ----------- |
| `success` | boolean | Si la descarga fue exitosa |
| `fileName` | string | Nombre del archivo descargado |
| `content` | string | Contenido del archivo \(texto o codificado en base64\) |
| `size` | number | Tamaño del archivo en bytes |
| `encoding` | string | Codificación del contenido \(utf-8 o base64\) |
| `message` | string | Mensaje de estado de la operación |
### `sftp_list`
Listar archivos y directorios en un servidor SFTP remoto
#### Entrada
| Parámetro | Tipo | Obligatorio | Descripción |
| --------- | ---- | -------- | ----------- |
| `host` | string | Sí | Nombre de host o dirección IP del servidor SFTP |
| `port` | number | Sí | Puerto del servidor SFTP \(predeterminado: 22\) |
| `username` | string | Sí | Nombre de usuario SFTP |
| `password` | string | No | Contraseña para autenticación \(si no se usa clave privada\) |
| `privateKey` | string | No | Clave privada para autenticación \(formato OpenSSH\) |
| `passphrase` | string | No | Frase de contraseña para clave privada cifrada |
| `remotePath` | string | Sí | Ruta del directorio en el servidor remoto |
| `detailed` | boolean | No | Incluir información detallada de archivos \(tamaño, permisos, fecha de modificación\) |
#### Salida
| Parámetro | Tipo | Descripción |
| --------- | ---- | ----------- |
| `success` | boolean | Si la operación fue exitosa |
| `path` | string | Ruta del directorio que fue listado |
| `entries` | json | Array de entradas del directorio con nombre, tipo, tamaño, permisos, modifiedAt |
| `count` | number | Número de entradas en el directorio |
| `message` | string | Mensaje de estado de la operación |
### `sftp_delete`
Eliminar un archivo o directorio en un servidor SFTP remoto
#### Entrada
| Parámetro | Tipo | Requerido | Descripción |
| --------- | ---- | -------- | ----------- |
| `host` | string | Sí | Nombre de host o dirección IP del servidor SFTP |
| `port` | number | Sí | Puerto del servidor SFTP \(predeterminado: 22\) |
| `username` | string | Sí | Nombre de usuario SFTP |
| `password` | string | No | Contraseña para autenticación \(si no se usa clave privada\) |
| `privateKey` | string | No | Clave privada para autenticación \(formato OpenSSH\) |
| `passphrase` | string | No | Frase de contraseña para clave privada cifrada |
| `remotePath` | string | Sí | Ruta al archivo o directorio a eliminar |
| `recursive` | boolean | No | Eliminar directorios recursivamente |
#### Salida
| Parámetro | Tipo | Descripción |
| --------- | ---- | ----------- |
| `success` | boolean | Si la eliminación fue exitosa |
| `deletedPath` | string | Ruta que fue eliminada |
| `message` | string | Mensaje de estado de la operación |
### `sftp_mkdir`
Crear un directorio en un servidor SFTP remoto
#### Entrada
| Parámetro | Tipo | Obligatorio | Descripción |
| --------- | ---- | ----------- | ----------- |
| `host` | string | Sí | Nombre de host o dirección IP del servidor SFTP |
| `port` | number | Sí | Puerto del servidor SFTP \(predeterminado: 22\) |
| `username` | string | Sí | Nombre de usuario SFTP |
| `password` | string | No | Contraseña para autenticación \(si no se usa clave privada\) |
| `privateKey` | string | No | Clave privada para autenticación \(formato OpenSSH\) |
| `passphrase` | string | No | Frase de contraseña para clave privada cifrada |
| `remotePath` | string | Sí | Ruta para el nuevo directorio |
| `recursive` | boolean | No | Crear directorios principales si no existen |
#### Salida
| Parámetro | Tipo | Descripción |
| --------- | ---- | ----------- |
| `success` | boolean | Si el directorio se creó correctamente |
| `createdPath` | string | Ruta del directorio creado |
| `message` | string | Mensaje de estado de la operación |
## Notas
- Categoría: `tools`
- Tipo: `sftp`

View File

@@ -7,7 +7,7 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="smtp"
color="#4A5568"
color="#2D3748"
/>
{/* MANUAL-CONTENT-START:intro */}

View File

@@ -30,6 +30,9 @@ Utiliza el bloque Start para todo lo que se origina desde el editor, despliegue
<Card title="Schedule" href="/triggers/schedule">
Ejecución basada en cron o intervalos
</Card>
<Card title="RSS Feed" href="/triggers/rss">
Monitorea feeds RSS y Atom para nuevo contenido
</Card>
</Cards>
## Comparación rápida
@@ -39,6 +42,7 @@ Utiliza el bloque Start para todo lo que se origina desde el editor, despliegue
| **Start** | Ejecuciones del editor, solicitudes de despliegue a API o mensajes de chat |
| **Schedule** | Temporizador gestionado en el bloque de programación |
| **Webhook** | Al recibir una solicitud HTTP entrante |
| **RSS Feed** | Nuevo elemento publicado en el feed |
> El bloque Start siempre expone los campos `input`, `conversationId` y `files`. Añade campos personalizados al formato de entrada para datos estructurados adicionales.

View File

@@ -0,0 +1,49 @@
---
title: Feed RSS
---
import { Callout } from 'fumadocs-ui/components/callout'
import { Image } from '@/components/ui/image'
El bloque de Feed RSS monitorea feeds RSS y Atom cuando se publican nuevos elementos, tu flujo de trabajo se activa automáticamente.
<div className="flex justify-center">
<Image
src="/static/blocks/rss.png"
alt="Bloque de Feed RSS"
width={500}
height={400}
className="my-6"
/>
</div>
## Configuración
1. **Añadir bloque de Feed RSS** - Arrastra el bloque de Feed RSS para iniciar tu flujo de trabajo
2. **Introducir URL del feed** - Pega la URL de cualquier feed RSS o Atom
3. **Implementar** - Implementa tu flujo de trabajo para activar el sondeo
Una vez implementado, el feed se comprueba cada minuto en busca de nuevos elementos.
## Campos de salida
| Campo | Tipo | Descripción |
|-------|------|-------------|
| `title` | string | Título del elemento |
| `link` | string | Enlace del elemento |
| `pubDate` | string | Fecha de publicación |
| `item` | object | Elemento en bruto con todos los campos |
| `feed` | object | Metadatos en bruto del feed |
Accede a los campos mapeados directamente (`<rss.title>`) o utiliza los objetos en bruto para cualquier campo (`<rss.item.author>`, `<rss.feed.language>`).
## Casos de uso
- **Monitoreo de contenido** - Sigue blogs, sitios de noticias o actualizaciones de competidores
- **Automatización de podcasts** - Activa flujos de trabajo cuando se publican nuevos episodios
- **Seguimiento de lanzamientos** - Monitorea lanzamientos de GitHub, registros de cambios o actualizaciones de productos
- **Agregación social** - Recopila contenido de plataformas que exponen feeds RSS
<Callout>
Los disparadores RSS solo se activan para elementos publicados después de guardar el disparador. Los elementos existentes en el feed no se procesan.
</Callout>

View File

@@ -0,0 +1,63 @@
---
title: DuckDuckGo
description: Recherchez avec DuckDuckGo
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="duckduckgo"
color="#FFFFFF"
/>
{/* MANUAL-CONTENT-START:intro */}
[DuckDuckGo](https://duckduckgo.com/) est un moteur de recherche web axé sur la confidentialité qui fournit des réponses instantanées, des résumés, des sujets connexes et plus encore — sans vous suivre ni suivre vos recherches. DuckDuckGo facilite la recherche d'informations sans profilage d'utilisateur ni publicités ciblées.
Avec DuckDuckGo dans Sim, vous pouvez :
- **Rechercher sur le web** : trouvez instantanément des réponses, des faits et des aperçus pour une requête de recherche donnée
- **Obtenir des réponses directes** : recevez des réponses spécifiques pour des calculs, des conversions ou des requêtes factuelles
- **Accéder à des résumés** : recevez de courts résumés ou descriptions pour vos sujets de recherche
- **Récupérer des sujets connexes** : découvrez des liens et références pertinents pour votre recherche
- **Filtrer les résultats** : supprimez éventuellement le HTML ou ignorez la désambiguïsation pour des résultats plus propres
Ces fonctionnalités permettent à vos agents Sim d'automatiser l'accès à des connaissances web récentes — de la présentation de faits dans un flux de travail à l'enrichissement de documents et d'analyses avec des informations à jour. Comme l'API Instant Answers de DuckDuckGo est ouverte et ne nécessite pas de clé API, elle s'intègre facilement et en toute sécurité dans vos processus d'entreprise automatisés.
{/* MANUAL-CONTENT-END */}
## Instructions d'utilisation
Recherchez sur le web en utilisant l'API Instant Answers de DuckDuckGo. Renvoie des réponses instantanées, des résumés, des sujets connexes et plus encore. Gratuit à utiliser sans clé API.
## Outils
### `duckduckgo_search`
Recherchez sur le web en utilisant l'API Instant Answers de DuckDuckGo. Renvoie des réponses instantanées, des résumés et des sujets connexes pour votre requête. Gratuit à utiliser sans clé API.
#### Entrée
| Paramètre | Type | Obligatoire | Description |
| --------- | ---- | -------- | ----------- |
| `query` | string | Oui | La requête de recherche à exécuter |
| `noHtml` | boolean | Non | Supprimer le HTML du texte dans les résultats \(par défaut : true\) |
| `skipDisambig` | boolean | Non | Ignorer les résultats de désambiguïsation \(par défaut : false\) |
#### Sortie
| Paramètre | Type | Description |
| --------- | ---- | ----------- |
| `heading` | string | Le titre/en-tête de la réponse instantanée |
| `abstract` | string | Un court résumé du sujet |
| `abstractText` | string | Version en texte brut du résumé |
| `abstractSource` | string | La source du résumé \(par exemple, Wikipédia\) |
| `abstractURL` | string | URL vers la source du résumé |
| `image` | string | URL vers une image liée au sujet |
| `answer` | string | Réponse directe si disponible \(par exemple, pour les calculs\) |
| `answerType` | string | Type de réponse \(par exemple, calc, ip, etc.\) |
| `type` | string | Type de réponse : A \(article\), D \(désambiguïsation\), C \(catégorie\), N \(nom\), E \(exclusif\) |
| `relatedTopics` | array | Tableau des sujets connexes avec URLs et descriptions |
## Notes
- Catégorie : `tools`
- Type : `duckduckgo`

View File

@@ -0,0 +1,183 @@
---
title: SFTP
description: Transférer des fichiers via SFTP (Protocole de transfert de fichiers SSH)
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="sftp"
color="#2D3748"
/>
{/* MANUAL-CONTENT-START:intro */}
[SFTP (Protocole de transfert de fichiers SSH)](https://en.wikipedia.org/wiki/SSH_File_Transfer_Protocol) est un protocole réseau sécurisé qui vous permet de téléverser, télécharger et gérer des fichiers sur des serveurs distants. SFTP fonctionne via SSH, ce qui en fait une solution idéale pour les transferts de fichiers automatisés et chiffrés, ainsi que pour la gestion de fichiers à distance dans les flux de travail modernes.
Grâce aux outils SFTP intégrés à Sim, vous pouvez facilement automatiser le déplacement de fichiers entre vos agents IA et des systèmes ou serveurs externes. Cela permet à vos agents de gérer les échanges de données critiques, les sauvegardes, la génération de documents et l'orchestration de systèmes distants, le tout avec une sécurité robuste.
**Fonctionnalités clés disponibles via les outils SFTP :**
- **Téléversement de fichiers :** Transférez facilement des fichiers de tout type depuis votre flux de travail vers un serveur distant, avec prise en charge de l'authentification par mot de passe et par clé privée SSH.
- **Téléchargement de fichiers :** Récupérez des fichiers depuis des serveurs SFTP distants directement pour traitement, archivage ou automatisation supplémentaire.
- **Liste et gestion des fichiers :** Énumérez les répertoires, supprimez ou créez des fichiers et dossiers, et gérez les permissions du système de fichiers à distance.
- **Authentification flexible :** Connectez-vous en utilisant soit des mots de passe traditionnels, soit des clés SSH, avec prise en charge des phrases secrètes et du contrôle des permissions.
- **Prise en charge des fichiers volumineux :** Gérez de manière programmatique les téléversements et téléchargements de fichiers volumineux, avec des limites de taille intégrées pour la sécurité.
En intégrant SFTP à Sim, vous pouvez automatiser les opérations de fichiers sécurisées dans le cadre de n'importe quel flux de travail, qu'il s'agisse de collecte de données, de rapports, de maintenance de systèmes distants ou d'échange dynamique de contenu entre plateformes.
Les sections ci-dessous décrivent les principaux outils SFTP disponibles :
- **sftp_upload :** Téléverser un ou plusieurs fichiers vers un serveur distant.
- **sftp_download :** Télécharger des fichiers depuis un serveur distant vers votre flux de travail.
- **sftp_list :** Lister le contenu des répertoires sur un serveur SFTP distant.
- **sftp_delete :** Supprimer des fichiers ou des répertoires d'un serveur distant.
- **sftp_create :** Créer de nouveaux fichiers sur un serveur SFTP distant.
- **sftp_mkdir :** Créer de nouveaux répertoires à distance.
Consultez la documentation de l'outil ci-dessous pour les paramètres d'entrée et de sortie détaillés pour chaque opération.
{/* MANUAL-CONTENT-END */}
## Instructions d'utilisation
Téléchargez, téléchargez, listez et gérez des fichiers sur des serveurs distants via SFTP. Prend en charge l'authentification par mot de passe et par clé privée pour des transferts de fichiers sécurisés.
## Outils
### `sftp_upload`
Téléverser des fichiers vers un serveur SFTP distant
#### Entrée
| Paramètre | Type | Obligatoire | Description |
| --------- | ---- | ----------- | ----------- |
| `host` | string | Oui | Nom d'hôte ou adresse IP du serveur SFTP |
| `port` | number | Oui | Port du serveur SFTP \(par défaut : 22\) |
| `username` | string | Oui | Nom d'utilisateur SFTP |
| `password` | string | Non | Mot de passe pour l'authentification \(si vous n'utilisez pas de clé privée\) |
| `privateKey` | string | Non | Clé privée pour l'authentification \(format OpenSSH\) |
| `passphrase` | string | Non | Phrase secrète pour la clé privée chiffrée |
| `remotePath` | string | Oui | Répertoire de destination sur le serveur distant |
| `files` | file[] | Non | Fichiers à téléverser |
| `fileContent` | string | Non | Contenu direct du fichier à téléverser \(pour les fichiers texte\) |
| `fileName` | string | Non | Nom du fichier lors de l'utilisation du contenu direct |
| `overwrite` | boolean | Non | Écraser les fichiers existants \(par défaut : true\) |
| `permissions` | string | Non | Permissions du fichier \(ex. 0644\) |
#### Sortie
| Paramètre | Type | Description |
| --------- | ---- | ----------- |
| `success` | boolean | Si le téléversement a réussi |
| `uploadedFiles` | json | Tableau des détails des fichiers téléversés \(nom, chemin distant, taille\) |
| `message` | string | Message d'état de l'opération |
### `sftp_download`
Télécharger un fichier depuis un serveur SFTP distant
#### Entrée
| Paramètre | Type | Obligatoire | Description |
| --------- | ---- | -------- | ----------- |
| `host` | chaîne | Oui | Nom d'hôte ou adresse IP du serveur SFTP |
| `port` | nombre | Oui | Port du serveur SFTP \(par défaut : 22\) |
| `username` | chaîne | Oui | Nom d'utilisateur SFTP |
| `password` | chaîne | Non | Mot de passe pour l'authentification \(si vous n'utilisez pas de clé privée\) |
| `privateKey` | chaîne | Non | Clé privée pour l'authentification \(format OpenSSH\) |
| `passphrase` | chaîne | Non | Phrase secrète pour la clé privée chiffrée |
| `remotePath` | chaîne | Oui | Chemin vers le fichier sur le serveur distant |
| `encoding` | chaîne | Non | Encodage de sortie : utf-8 pour le texte, base64 pour le binaire \(par défaut : utf-8\) |
#### Sortie
| Paramètre | Type | Description |
| --------- | ---- | ----------- |
| `success` | booléen | Indique si le téléchargement a réussi |
| `fileName` | chaîne | Nom du fichier téléchargé |
| `content` | chaîne | Contenu du fichier \(texte ou encodé en base64\) |
| `size` | nombre | Taille du fichier en octets |
| `encoding` | chaîne | Encodage du contenu \(utf-8 ou base64\) |
| `message` | chaîne | Message d'état de l'opération |
### `sftp_list`
Lister les fichiers et répertoires sur un serveur SFTP distant
#### Entrée
| Paramètre | Type | Obligatoire | Description |
| --------- | ---- | -------- | ----------- |
| `host` | chaîne | Oui | Nom d'hôte ou adresse IP du serveur SFTP |
| `port` | nombre | Oui | Port du serveur SFTP \(par défaut : 22\) |
| `username` | chaîne | Oui | Nom d'utilisateur SFTP |
| `password` | chaîne | Non | Mot de passe pour l'authentification \(si vous n'utilisez pas de clé privée\) |
| `privateKey` | chaîne | Non | Clé privée pour l'authentification \(format OpenSSH\) |
| `passphrase` | chaîne | Non | Phrase secrète pour la clé privée chiffrée |
| `remotePath` | chaîne | Oui | Chemin du répertoire sur le serveur distant |
| `detailed` | booléen | Non | Inclure des informations détaillées sur les fichiers \(taille, permissions, date de modification\) |
#### Sortie
| Paramètre | Type | Description |
| --------- | ---- | ----------- |
| `success` | boolean | Indique si l'opération a réussi |
| `path` | string | Chemin du répertoire qui a été listé |
| `entries` | json | Tableau des entrées du répertoire avec nom, type, taille, permissions, modifiedAt |
| `count` | number | Nombre d'entrées dans le répertoire |
| `message` | string | Message d'état de l'opération |
### `sftp_delete`
Supprimer un fichier ou un répertoire sur un serveur SFTP distant
#### Entrée
| Paramètre | Type | Obligatoire | Description |
| --------- | ---- | ---------- | ----------- |
| `host` | string | Oui | Nom d'hôte ou adresse IP du serveur SFTP |
| `port` | number | Oui | Port du serveur SFTP \(par défaut : 22\) |
| `username` | string | Oui | Nom d'utilisateur SFTP |
| `password` | string | Non | Mot de passe pour l'authentification \(si vous n'utilisez pas de clé privée\) |
| `privateKey` | string | Non | Clé privée pour l'authentification \(format OpenSSH\) |
| `passphrase` | string | Non | Phrase secrète pour la clé privée chiffrée |
| `remotePath` | string | Oui | Chemin vers le fichier ou le répertoire à supprimer |
| `recursive` | boolean | Non | Supprimer les répertoires de façon récursive |
#### Sortie
| Paramètre | Type | Description |
| --------- | ---- | ----------- |
| `success` | boolean | Indique si la suppression a réussi |
| `deletedPath` | string | Chemin qui a été supprimé |
| `message` | string | Message d'état de l'opération |
### `sftp_mkdir`
Créer un répertoire sur un serveur SFTP distant
#### Entrée
| Paramètre | Type | Obligatoire | Description |
| --------- | ---- | ----------- | ----------- |
| `host` | chaîne | Oui | Nom d'hôte ou adresse IP du serveur SFTP |
| `port` | nombre | Oui | Port du serveur SFTP \(par défaut : 22\) |
| `username` | chaîne | Oui | Nom d'utilisateur SFTP |
| `password` | chaîne | Non | Mot de passe pour l'authentification \(si vous n'utilisez pas de clé privée\) |
| `privateKey` | chaîne | Non | Clé privée pour l'authentification \(format OpenSSH\) |
| `passphrase` | chaîne | Non | Phrase secrète pour la clé privée chiffrée |
| `remotePath` | chaîne | Oui | Chemin pour le nouveau répertoire |
| `recursive` | booléen | Non | Créer les répertoires parents s'ils n'existent pas |
#### Sortie
| Paramètre | Type | Description |
| --------- | ---- | ----------- |
| `success` | booléen | Indique si le répertoire a été créé avec succès |
| `createdPath` | chaîne | Chemin du répertoire créé |
| `message` | chaîne | Message d'état de l'opération |
## Remarques
- Catégorie : `tools`
- Type : `sftp`

View File

@@ -7,7 +7,7 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="smtp"
color="#4A5568"
color="#2D3748"
/>
{/* MANUAL-CONTENT-START:intro */}

View File

@@ -21,24 +21,28 @@ import { Image } from '@/components/ui/image'
Utilisez le bloc Démarrer pour tout ce qui provient de l'éditeur, du déploiement vers l'API ou des expériences de déploiement vers le chat. D'autres déclencheurs restent disponibles pour les flux de travail basés sur des événements :
<Cards>
<Card title="Démarrer" href="/triggers/start">
<Card title="Start" href="/triggers/start">
Point d'entrée unifié qui prend en charge les exécutions de l'éditeur, les déploiements d'API et les déploiements de chat
</Card>
<Card title="Webhook" href="/triggers/webhook">
Recevoir des charges utiles de webhook externes
</Card>
<Card title="Planification" href="/triggers/schedule">
<Card title="Schedule" href="/triggers/schedule">
Exécution basée sur cron ou intervalle
</Card>
<Card title="RSS Feed" href="/triggers/rss">
Surveiller les flux RSS et Atom pour du nouveau contenu
</Card>
</Cards>
## Comparaison rapide
| Déclencheur | Condition de démarrage |
|---------|-----------------|
| **Démarrer** | Exécutions de l'éditeur, requêtes de déploiement vers l'API ou messages de chat |
| **Planification** | Minuteur géré dans le bloc de planification |
| **Start** | Exécutions de l'éditeur, requêtes de déploiement d'API ou messages de chat |
| **Schedule** | Minuteur géré dans le bloc de planification |
| **Webhook** | Sur requête HTTP entrante |
| **RSS Feed** | Nouvel élément publié dans le flux |
> Le bloc Démarrer expose toujours les champs `input`, `conversationId` et `files`. Ajoutez des champs personnalisés au format d'entrée pour des données structurées supplémentaires.

View File

@@ -0,0 +1,49 @@
---
title: Flux RSS
---
import { Callout } from 'fumadocs-ui/components/callout'
import { Image } from '@/components/ui/image'
Le bloc Flux RSS surveille les flux RSS et Atom lorsque de nouveaux éléments sont publiés, votre workflow se déclenche automatiquement.
<div className="flex justify-center">
<Image
src="/static/blocks/rss.png"
alt="Bloc Flux RSS"
width={500}
height={400}
className="my-6"
/>
</div>
## Configuration
1. **Ajouter le bloc Flux RSS** - Faites glisser le bloc Flux RSS pour démarrer votre workflow
2. **Saisir l'URL du flux** - Collez l'URL de n'importe quel flux RSS ou Atom
3. **Déployer** - Déployez votre workflow pour activer l'interrogation
Une fois déployé, le flux est vérifié chaque minute pour détecter de nouveaux éléments.
## Champs de sortie
| Champ | Type | Description |
|-------|------|-------------|
| `title` | string | Titre de l'élément |
| `link` | string | Lien de l'élément |
| `pubDate` | string | Date de publication |
| `item` | object | Élément brut avec tous les champs |
| `feed` | object | Métadonnées brutes du flux |
Accédez directement aux champs mappés (`<rss.title>`) ou utilisez les objets bruts pour n'importe quel champ (`<rss.item.author>`, `<rss.feed.language>`).
## Cas d'utilisation
- **Surveillance de contenu** - Suivez les blogs, sites d'actualités ou mises à jour des concurrents
- **Automatisation de podcast** - Déclenchez des workflows lors de la sortie de nouveaux épisodes
- **Suivi des versions** - Surveillez les versions GitHub, les journaux de modifications ou les mises à jour de produits
- **Agrégation sociale** - Collectez du contenu à partir de plateformes qui exposent des flux RSS
<Callout>
Les déclencheurs RSS ne s'activent que pour les éléments publiés après l'enregistrement du déclencheur. Les éléments existants du flux ne sont pas traités.
</Callout>

View File

@@ -0,0 +1,63 @@
---
title: DuckDuckGo
description: DuckDuckGoで検索
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="duckduckgo"
color="#FFFFFF"
/>
{/* MANUAL-CONTENT-START:intro */}
[DuckDuckGo](https://duckduckgo.com/)は、プライバシーを重視したウェブ検索エンジンで、あなたやあなたの検索を追跡することなく、即時の回答、要約、関連トピックなどを提供します。DuckDuckGoを使えば、ユーザープロファイリングやターゲット広告なしで簡単に情報を見つけることができます。
SimでDuckDuckGoを使用すると、以下のことができます
- **ウェブ検索**: 特定の検索クエリに対して、回答、事実、概要を即座に見つける
- **直接的な回答を取得**: 計算、変換、事実に関するクエリに対して特定の回答を取得
- **要約にアクセス**: 検索トピックに関する短い要約や説明を受け取る
- **関連トピックを取得**: 検索に関連するリンクや参考情報を発見
- **出力をフィルタリング**: オプションでHTMLを削除したり、より明確な結果を得るために曖昧さ回避をスキップしたりする
これらの機能により、Simエージェントは最新のウェブ知識への自動アクセスを可能にします — ワークフローでの事実の表示から、最新情報によるドキュメントや分析の強化まで。DuckDuckGoのインスタントアンサーAPIはオープンでAPIキーを必要としないため、自動化されたビジネスプロセスにプライバシーを保ちながら簡単に統合できます。
{/* MANUAL-CONTENT-END */}
## 使用方法
DuckDuckGoインスタントアンサーAPIを使用してウェブを検索します。インスタントアンサー、要約、関連トピックなどを返します。APIキーなしで無料で使用できます。
## ツール
### `duckduckgo_search`
DuckDuckGoインスタントアンサーAPIを使用してウェブを検索します。クエリに対するインスタントアンサー、要約、関連トピックを返します。APIキーなしで無料で使用できます。
#### 入力
| パラメータ | 型 | 必須 | 説明 |
| --------- | ---- | -------- | ----------- |
| `query` | string | はい | 実行する検索クエリ |
| `noHtml` | boolean | いいえ | 結果のテキストからHTMLを削除するデフォルト: true |
| `skipDisambig` | boolean | いいえ | 曖昧さ回避の結果をスキップする(デフォルト: false |
#### 出力
| パラメータ | 型 | 説明 |
| --------- | ---- | ----------- |
| `heading` | string | インスタントアンサーの見出し/タイトル |
| `abstract` | string | トピックの短い要約 |
| `abstractText` | string | 要約のプレーンテキストバージョン |
| `abstractSource` | string | 要約の情報源Wikipedia |
| `abstractURL` | string | 要約の情報源へのURL |
| `image` | string | トピックに関連する画像へのURL |
| `answer` | string | 利用可能な場合は直接的な回答(例:計算の場合) |
| `answerType` | string | 回答のタイプcalc、ipなど |
| `type` | string | レスポンスタイプA記事、D曖昧さ回避、Cカテゴリ、N名前、E排他的 |
| `relatedTopics` | array | URLと説明を含む関連トピックの配列 |
## 注意事項
- カテゴリ: `tools`
- タイプ: `duckduckgo`

View File

@@ -0,0 +1,183 @@
---
title: SFTP
description: SFTPSSH File Transfer Protocolを介してファイルを転送
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="sftp"
color="#2D3748"
/>
{/* MANUAL-CONTENT-START:intro */}
[SFTPSSH File Transfer Protocol](https://en.wikipedia.org/wiki/SSH_File_Transfer_Protocol)は、リモートサーバー上でファイルのアップロード、ダウンロード、管理を可能にする安全なネットワークプロトコルです。SFTPはSSH上で動作し、現代のワークフロー内での自動化された暗号化ファイル転送とリモートファイル管理に最適です。
SimにSFTPツールを統合することで、AIエージェントと外部システムやサーバー間のファイル移動を簡単に自動化できます。これにより、エージェントは重要なデータ交換、バックアップ、ドキュメント生成、リモートシステムのオーケストレーションを堅牢なセキュリティで管理できるようになります。
**SFTPツールで利用可能な主要機能**
- **ファイルのアップロード:** パスワードとSSH秘密鍵認証の両方をサポートし、ワークフローからリモートサーバーへあらゆるタイプのファイルをシームレスに転送。
- **ファイルのダウンロード:** リモートSFTPサーバーから直接ファイルを取得し、処理、アーカイブ、または更なる自動化を行う。
- **ファイルの一覧表示と管理:** ディレクトリの列挙、ファイルやフォルダの削除または作成、リモートでのファイルシステム権限の管理。
- **柔軟な認証:** 従来のパスワードまたはSSH鍵を使用して接続し、パスフレーズと権限制御をサポート。
- **大容量ファイルのサポート:** 安全性のための組み込みサイズ制限付きで、大容量ファイルのアップロードとダウンロードをプログラムで管理。
SimにSFTPを統合することで、データ収集、レポート作成、リモートシステムのメンテナンス、プラットフォーム間の動的コンテンツ交換など、あらゆるワークフローの一部として安全なファイル操作を自動化できます。
以下のセクションでは、利用可能な主要なSFTPツールについて説明します
- **sftp_upload** 1つまたは複数のファイルをリモートサーバーにアップロード。
- **sftp_download** リモートサーバーからワークフローにファイルをダウンロード。
- **sftp_list** リモートSFTPサーバー上のディレクトリ内容を一覧表示。
- **sftp_delete** リモートサーバーからファイルまたはディレクトリを削除。
- **sftp_create** リモートSFTPサーバー上に新しいファイルを作成。
- **sftp_mkdir** リモートで新しいディレクトリを作成。
各操作の詳細な入力パラメータと出力パラメータについては、以下のツールドキュメントをご覧ください。
{/* MANUAL-CONTENT-END */}
## 使用方法
SFTPを介してリモートサーバーにファイルをアップロード、ダウンロード、一覧表示、管理できます。安全なファイル転送のためにパスワード認証と秘密鍵認証の両方をサポートしています。
## ツール
### `sftp_upload`
リモートSFTPサーバーにファイルをアップロードする
#### 入力
| パラメータ | 型 | 必須 | 説明 |
| --------- | ---- | -------- | ----------- |
| `host` | string | はい | SFTPサーバーのホスト名またはIPアドレス |
| `port` | number | はい | SFTPサーバーのポートデフォルト22 |
| `username` | string | はい | SFTPユーザー名 |
| `password` | string | いいえ | 認証用パスワード(秘密鍵を使用しない場合) |
| `privateKey` | string | いいえ | 認証用秘密鍵OpenSSH形式 |
| `passphrase` | string | いいえ | 暗号化された秘密鍵のパスフレーズ |
| `remotePath` | string | はい | リモートサーバー上の宛先ディレクトリ |
| `files` | file[] | いいえ | アップロードするファイル |
| `fileContent` | string | いいえ | アップロードする直接ファイルコンテンツ(テキストファイル用) |
| `fileName` | string | いいえ | 直接コンテンツを使用する場合のファイル名 |
| `overwrite` | boolean | いいえ | 既存のファイルを上書きするかどうかデフォルトtrue |
| `permissions` | string | いいえ | ファイルのパーミッション0644 |
#### 出力
| パラメータ | 型 | 説明 |
| --------- | ---- | ----------- |
| `success` | boolean | アップロードが成功したかどうか |
| `uploadedFiles` | json | アップロードされたファイルの詳細の配列(名前、リモートパス、サイズ) |
| `message` | string | 操作ステータスメッセージ |
### `sftp_download`
リモートSFTPサーバーからファイルをダウンロードする
#### 入力
| パラメータ | 型 | 必須 | 説明 |
| --------- | ---- | -------- | ----------- |
| `host` | string | はい | SFTPサーバーのホスト名またはIPアドレス |
| `port` | number | はい | SFTPサーバーのポートデフォルト22 |
| `username` | string | はい | SFTPユーザー名 |
| `password` | string | いいえ | 認証用パスワード(秘密鍵を使用しない場合) |
| `privateKey` | string | いいえ | 認証用秘密鍵OpenSSH形式 |
| `passphrase` | string | いいえ | 暗号化された秘密鍵のパスフレーズ |
| `remotePath` | string | はい | リモートサーバー上のファイルパス |
| `encoding` | string | いいえ | 出力エンコーディングテキストの場合はutf-8、バイナリの場合はbase64デフォルトutf-8 |
#### 出力
| パラメータ | 型 | 説明 |
| --------- | ---- | ----------- |
| `success` | boolean | ダウンロードが成功したかどうか |
| `fileName` | string | ダウンロードしたファイルの名前 |
| `content` | string | ファイルの内容テキストまたはbase64エンコード |
| `size` | number | ファイルサイズ(バイト) |
| `encoding` | string | コンテンツエンコーディングutf-8またはbase64 |
| `message` | string | 操作ステータスメッセージ |
### `sftp_list`
リモートSFTPサーバー上のファイルとディレクトリを一覧表示する
#### 入力
| パラメータ | 型 | 必須 | 説明 |
| --------- | ---- | -------- | ----------- |
| `host` | string | はい | SFTPサーバーのホスト名またはIPアドレス |
| `port` | number | はい | SFTPサーバーのポートデフォルト22 |
| `username` | string | はい | SFTPユーザー名 |
| `password` | string | いいえ | 認証用パスワード(秘密鍵を使用しない場合) |
| `privateKey` | string | いいえ | 認証用秘密鍵OpenSSH形式 |
| `passphrase` | string | いいえ | 暗号化された秘密鍵のパスフレーズ |
| `remotePath` | string | はい | リモートサーバー上のディレクトリパス |
| `detailed` | boolean | いいえ | 詳細なファイル情報(サイズ、権限、更新日)を含める |
#### 出力
| パラメータ | 型 | 説明 |
| --------- | ---- | ----------- |
| `success` | boolean | 操作が成功したかどうか |
| `path` | string | 一覧表示されたディレクトリパス |
| `entries` | json | 名前、タイプ、サイズ、権限、更新日時を含むディレクトリエントリの配列 |
| `count` | number | ディレクトリ内のエントリ数 |
| `message` | string | 操作のステータスメッセージ |
### `sftp_delete`
リモートSFTPサーバー上のファイルまたはディレクトリを削除する
#### 入力
| パラメータ | 型 | 必須 | 説明 |
| --------- | ---- | -------- | ----------- |
| `host` | string | はい | SFTPサーバーのホスト名またはIPアドレス |
| `port` | number | はい | SFTPサーバーのポートデフォルト22 |
| `username` | string | はい | SFTPユーザー名 |
| `password` | string | いいえ | 認証用パスワード(秘密鍵を使用しない場合) |
| `privateKey` | string | いいえ | 認証用の秘密鍵OpenSSH形式 |
| `passphrase` | string | いいえ | 暗号化された秘密鍵のパスフレーズ |
| `remotePath` | string | はい | 削除するファイルまたはディレクトリのパス |
| `recursive` | boolean | いいえ | ディレクトリを再帰的に削除する |
#### 出力
| パラメータ | 型 | 説明 |
| --------- | ---- | ----------- |
| `success` | boolean | 削除が成功したかどうか |
| `deletedPath` | string | 削除されたパス |
| `message` | string | 操作のステータスメッセージ |
### `sftp_mkdir`
リモートSFTPサーバーにディレクトリを作成する
#### 入力
| パラメータ | 型 | 必須 | 説明 |
| --------- | ---- | -------- | ----------- |
| `host` | string | はい | SFTPサーバーのホスト名またはIPアドレス |
| `port` | number | はい | SFTPサーバーのポートデフォルト22 |
| `username` | string | はい | SFTPユーザー名 |
| `password` | string | いいえ | 認証用パスワード(秘密鍵を使用しない場合) |
| `privateKey` | string | いいえ | 認証用秘密鍵OpenSSH形式 |
| `passphrase` | string | いいえ | 暗号化された秘密鍵のパスフレーズ |
| `remotePath` | string | はい | 新しいディレクトリのパス |
| `recursive` | boolean | いいえ | 親ディレクトリが存在しない場合に作成する |
#### 出力
| パラメータ | 型 | 説明 |
| --------- | ---- | ----------- |
| `success` | boolean | ディレクトリが正常に作成されたかどうか |
| `createdPath` | string | 作成されたディレクトリのパス |
| `message` | string | 操作のステータスメッセージ |
## 注意事項
- カテゴリ: `tools`
- タイプ: `sftp`

View File

@@ -7,7 +7,7 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="smtp"
color="#4A5568"
color="#2D3748"
/>
{/* MANUAL-CONTENT-START:intro */}

View File

@@ -21,24 +21,28 @@ import { Image } from '@/components/ui/image'
エディタ、APIへのデプロイ、またはチャットへのデプロイエクスペリエンスから始まるすべてのものにはスタートブロックを使用します。イベント駆動型ワークフローには他のトリガーも利用可能です
<Cards>
<Card title="スタート" href="/triggers/start">
<Card title="Start" href="/triggers/start">
エディタ実行、APIデプロイメント、チャットデプロイメントをサポートする統合エントリーポイント
</Card>
<Card title="ウェブフック" href="/triggers/webhook">
外部ウェブフックペイロードを受信
<Card title="Webhook" href="/triggers/webhook">
外部のwebhookペイロードを受信
</Card>
<Card title="スケジュール" href="/triggers/schedule">
<Card title="Schedule" href="/triggers/schedule">
Cronまたは間隔ベースの実行
</Card>
<Card title="RSS Feed" href="/triggers/rss">
新しいコンテンツのRSSとAtomフィードを監視
</Card>
</Cards>
## クイック比較
| トリガー | 開始条件 |
|---------|-----------------|
| **スタート** | エディタ実行、APIへのデプロイリクエスト、またはチャットメッセージ |
| **スケジュール** | スケジュールブロックで管理されるタイマー |
| **ウェブフック** | 受信HTTPリクエスト時 |
| **Start** | エディタ実行、APIへのデプロイリクエスト、またはチャットメッセージ |
| **Schedule** | スケジュールブロックで管理されるタイマー |
| **Webhook** | 受信HTTPリクエスト時 |
| **RSS Feed** | フィードに新しいアイテムが公開された時 |
> スタートブロックは常に `input`、`conversationId`、および `files` フィールドを公開します。追加の構造化データには入力フォーマットにカスタムフィールドを追加してください。

View File

@@ -0,0 +1,49 @@
---
title: RSSフィード
---
import { Callout } from 'fumadocs-ui/components/callout'
import { Image } from '@/components/ui/image'
RSSフィードブロックはRSSとAtomフィードを監視します - 新しいアイテムが公開されると、ワークフローが自動的にトリガーされます。
<div className="flex justify-center">
<Image
src="/static/blocks/rss.png"
alt="RSSフィードブロック"
width={500}
height={400}
className="my-6"
/>
</div>
## 設定
1. **RSSフィードブロックを追加** - RSSフィードブロックをドラッグしてワークフローを開始
2. **フィードURLを入力** - 任意のRSSまたはAtomフィードのURLを貼り付け
3. **デプロイ** - ワークフローをデプロイしてポーリングを有効化
デプロイ後、フィードは1分ごとに新しいアイテムをチェックします。
## 出力フィールド
| フィールド | 型 | 説明 |
|-------|------|-------------|
| `title` | string | アイテムのタイトル |
| `link` | string | アイテムのリンク |
| `pubDate` | string | 公開日 |
| `item` | object | すべてのフィールドを含む生のアイテム |
| `feed` | object | 生のフィードメタデータ |
マッピングされたフィールドに直接アクセスするか(`<rss.title>`)、任意のフィールドに生のオブジェクトを使用します(`<rss.item.author>`、`<rss.feed.language>`)。
## ユースケース
- **コンテンツ監視** - ブログ、ニュースサイト、または競合他社の更新を追跡
- **ポッドキャスト自動化** - 新しいエピソードが公開されたときにワークフローをトリガー
- **リリース追跡** - GitHubリリース、変更ログ、または製品アップデートを監視
- **ソーシャルアグリゲーション** - RSSフィードを公開しているプラットフォームからコンテンツを収集
<Callout>
RSSトリガーは、トリガーを保存した後に公開されたアイテムに対してのみ実行されます。既存のフィードアイテムは処理されません。
</Callout>

View File

@@ -0,0 +1,63 @@
---
title: DuckDuckGo
description: 使用 DuckDuckGo 搜索
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="duckduckgo"
color="#FFFFFF"
/>
{/* MANUAL-CONTENT-START:intro */}
[DuckDuckGo](https://duckduckgo.com/) 是一个注重隐私的网页搜索引擎提供即时答案、摘要、相关主题等功能——无需跟踪您或您的搜索记录。DuckDuckGo 让您无需用户画像或定向广告即可轻松获取信息。
在 Sim 中使用 DuckDuckGo您可以
- **搜索网页**:即时找到答案、事实和搜索查询的概述
- **获取直接答案**:检索计算、转换或事实查询的具体响应
- **访问摘要**:接收搜索主题的简短总结或描述
- **获取相关主题**:发现与搜索相关的链接和参考资料
- **过滤输出**:可选择移除 HTML 或跳过歧义消解以获得更简洁的结果
这些功能使您的 Sim 代理能够自动访问最新的网络知识——从在工作流程中呈现事实,到通过最新信息丰富文档和分析。由于 DuckDuckGo 的即时答案 API 是开放的且不需要 API 密钥,因此集成到您的自动化业务流程中既简单又安全。
{/* MANUAL-CONTENT-END */}
## 使用说明
使用 DuckDuckGo 即时答案 API 搜索网页。返回即时答案、摘要、相关主题等。无需 API 密钥即可免费使用。
## 工具
### `duckduckgo_search`
使用 DuckDuckGo 即时答案 API 搜索网页。返回查询的即时答案、摘要和相关主题。无需 API 密钥即可免费使用。
#### 输入
| 参数 | 类型 | 必需 | 描述 |
| --------- | ---- | -------- | ----------- |
| `query` | string | 是 | 要执行的搜索查询 |
| `noHtml` | boolean | 否 | 从结果文本中移除 HTML \(默认值: true\) |
| `skipDisambig` | boolean | 否 | 跳过歧义消解结果 \(默认值: false\) |
#### 输出
| 参数 | 类型 | 描述 |
| --------- | ---- | ----------- |
| `heading` | string | 即时答案的标题 |
| `abstract` | string | 主题的简短摘要 |
| `abstractText` | string | 摘要的纯文本版本 |
| `abstractSource` | string | 摘要的来源例如Wikipedia |
| `abstractURL` | string | 摘要来源的 URL |
| `image` | string | 与主题相关的图片的 URL |
| `answer` | string | 如果可用,直接答案(例如,用于计算) |
| `answerType` | string | 答案的类型例如calcip 等) |
| `type` | string | 响应类型A文章D消歧C类别N名称E独占 |
| `relatedTopics` | array | 包含相关主题及其 URL 和描述的数组 |
## 注意事项
- 类别:`tools`
- 类型:`duckduckgo`

View File

@@ -0,0 +1,183 @@
---
title: SFTP
description: 通过 SFTPSSH 文件传输协议)传输文件
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="sftp"
color="#2D3748"
/>
{/* MANUAL-CONTENT-START:intro */}
[SFTPSSH 文件传输协议)](https://en.wikipedia.org/wiki/SSH_File_Transfer_Protocol) 是一种安全的网络协议可让您在远程服务器上上传、下载和管理文件。SFTP 基于 SSH 运行,非常适合现代工作流程中的自动化加密文件传输和远程文件管理。
通过将 SFTP 工具集成到 Sim 中,您可以轻松实现 AI 代理与外部系统或服务器之间的文件自动化传输。这使您的代理能够管理关键数据交换、备份、文档生成和远程系统协调——所有这些都具有强大的安全性。
**通过 SFTP 工具可用的关键功能:**
- **上传文件:** 无缝地将任何类型的文件从您的工作流程传输到远程服务器,支持密码和 SSH 私钥认证。
- **下载文件:** 直接从远程 SFTP 服务器检索文件以进行处理、存档或进一步自动化。
- **列出和管理文件:** 枚举目录,删除或创建文件和文件夹,并远程管理文件系统权限。
- **灵活的认证:** 使用传统密码或 SSH 密钥连接,支持密码短语和权限控制。
- **大文件支持:** 以编程方式管理大文件的上传和下载,并内置大小限制以确保安全。
通过将 SFTP 集成到 Sim 中,您可以将安全的文件操作自动化为任何工作流程的一部分,无论是数据收集、报告、远程系统维护,还是平台之间的动态内容交换。
以下部分描述了可用的关键 SFTP 工具:
- **sftp_upload** 将一个或多个文件上传到远程服务器。
- **sftp_download** 从远程服务器下载文件到您的工作流程。
- **sftp_list** 列出远程 SFTP 服务器上的目录内容。
- **sftp_delete** 从远程服务器删除文件或目录。
- **sftp_create** 在远程 SFTP 服务器上创建新文件。
- **sftp_mkdir** 远程创建新目录。
请参阅下面的工具文档,了解每个操作的详细输入和输出参数。
{/* MANUAL-CONTENT-END */}
## 使用说明
通过 SFTP 上传、下载、列出和管理远程服务器上的文件。支持密码和私钥认证,确保文件传输安全。
## 工具
### `sftp_upload`
将文件上传到远程 SFTP 服务器
#### 输入
| 参数 | 类型 | 必需 | 描述 |
| --------- | ---- | -------- | ----------- |
| `host` | string | 是 | SFTP 服务器主机名或 IP 地址 |
| `port` | number | 是 | SFTP 服务器端口 \(默认值: 22\) |
| `username` | string | 是 | SFTP 用户名 |
| `password` | string | 否 | 用于认证的密码 \(如果未使用私钥\) |
| `privateKey` | string | 否 | 用于认证的私钥 \(OpenSSH 格式\) |
| `passphrase` | string | 否 | 加密私钥的密码短语 |
| `remotePath` | string | 是 | 远程服务器上的目标目录 |
| `files` | file[] | 否 | 要上传的文件 |
| `fileContent` | string | 否 | 要上传的直接文件内容 \(针对文本文件\) |
| `fileName` | string | 否 | 使用直接内容时的文件名 |
| `overwrite` | boolean | 否 | 是否覆盖现有文件 \(默认值: true\) |
| `permissions` | string | 否 | 文件权限 \(例如: 0644\) |
#### 输出
| 参数 | 类型 | 描述 |
| --------- | ---- | ----------- |
| `success` | boolean | 上传是否成功 |
| `uploadedFiles` | json | 上传文件详情数组 \(名称, 远程路径, 大小\) |
| `message` | string | 操作状态消息 |
### `sftp_download`
从远程 SFTP 服务器下载文件
#### 输入
| 参数 | 类型 | 必需 | 描述 |
| --------- | ---- | -------- | ----------- |
| `host` | string | 是 | SFTP 服务器主机名或 IP 地址 |
| `port` | number | 是 | SFTP 服务器端口默认22 |
| `username` | string | 是 | SFTP 用户名 |
| `password` | string | 否 | 用于身份验证的密码(如果未使用私钥) |
| `privateKey` | string | 否 | 用于身份验证的私钥OpenSSH 格式) |
| `passphrase` | string | 否 | 加密私钥的密码短语 |
| `remotePath` | string | 是 | 远程服务器上文件的路径 |
| `encoding` | string | 否 | 输出编码utf-8 表示文本base64 表示二进制默认utf-8 |
#### 输出
| 参数 | 类型 | 描述 |
| --------- | ---- | ----------- |
| `success` | boolean | 下载是否成功 |
| `fileName` | string | 下载文件的名称 |
| `content` | string | 文件内容(文本或 base64 编码) |
| `size` | number | 文件大小(字节) |
| `encoding` | string | 内容编码utf-8 或 base64 |
| `message` | string | 操作状态消息 |
### `sftp_list`
列出远程 SFTP 服务器上的文件和目录
#### 输入
| 参数 | 类型 | 必需 | 描述 |
| --------- | ---- | -------- | ----------- |
| `host` | string | 是 | SFTP 服务器主机名或 IP 地址 |
| `port` | number | 是 | SFTP 服务器端口默认22 |
| `username` | string | 是 | SFTP 用户名 |
| `password` | string | 否 | 用于身份验证的密码(如果未使用私钥) |
| `privateKey` | string | 否 | 用于身份验证的私钥OpenSSH 格式) |
| `passphrase` | string | 否 | 加密私钥的密码短语 |
| `remotePath` | string | 是 | 远程服务器上的目录路径 |
| `detailed` | boolean | 否 | 是否包含详细的文件信息(大小、权限、修改日期) |
#### 输出
| 参数 | 类型 | 描述 |
| --------- | ---- | ----------- |
| `success` | boolean | 操作是否成功 |
| `path` | string | 被列出的目录路径 |
| `entries` | json | 包含名称、类型、大小、权限、修改时间的目录条目数组 |
| `count` | number | 目录中的条目数量 |
| `message` | string | 操作状态消息 |
### `sftp_delete`
删除远程 SFTP 服务器上的文件或目录
#### 输入
| 参数 | 类型 | 必需 | 描述 |
| --------- | ---- | -------- | ----------- |
| `host` | string | 是 | SFTP 服务器主机名或 IP 地址 |
| `port` | number | 是 | SFTP 服务器端口 \(默认值: 22\) |
| `username` | string | 是 | SFTP 用户名 |
| `password` | string | 否 | 用于身份验证的密码 \(如果未使用私钥\) |
| `privateKey` | string | 否 | 用于身份验证的私钥 \(OpenSSH 格式\) |
| `passphrase` | string | 否 | 加密私钥的密码短语 |
| `remotePath` | string | 是 | 要删除的文件或目录的路径 |
| `recursive` | boolean | 否 | 是否递归删除目录 |
#### 输出
| 参数 | 类型 | 描述 |
| --------- | ---- | ----------- |
| `success` | boolean | 删除是否成功 |
| `deletedPath` | string | 被删除的路径 |
| `message` | string | 操作状态消息 |
### `sftp_mkdir`
在远程 SFTP 服务器上创建一个目录
#### 输入
| 参数 | 类型 | 必需 | 描述 |
| --------- | ---- | -------- | ----------- |
| `host` | string | 是 | SFTP 服务器主机名或 IP 地址 |
| `port` | number | 是 | SFTP 服务器端口 \(默认值: 22\) |
| `username` | string | 是 | SFTP 用户名 |
| `password` | string | 否 | 用于身份验证的密码 \(如果未使用私钥\) |
| `privateKey` | string | 否 | 用于身份验证的私钥 \(OpenSSH 格式\) |
| `passphrase` | string | 否 | 加密私钥的密码短语 |
| `remotePath` | string | 是 | 新目录的路径 |
| `recursive` | boolean | 否 | 如果父目录不存在,是否创建父目录 |
#### 输出
| 参数 | 类型 | 描述 |
| --------- | ---- | ----------- |
| `success` | boolean | 目录是否成功创建 |
| `createdPath` | string | 创建的目录路径 |
| `message` | string | 操作状态消息 |
## 注意事项
- 分类: `tools`
- 类型: `sftp`

View File

@@ -7,7 +7,7 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="smtp"
color="#4A5568"
color="#2D3748"
/>
{/* MANUAL-CONTENT-START:intro */}

View File

@@ -21,24 +21,28 @@ import { Image } from '@/components/ui/image'
使用 Start 块处理从编辑器、部署到 API 或部署到聊天的所有操作。其他触发器可用于事件驱动的工作流:
<Cards>
<Card title="Start" href="/triggers/start">
<Card title="开始" href="/triggers/start">
支持编辑器运行、API 部署和聊天部署的统一入口点
</Card>
<Card title="Webhook" href="/triggers/webhook">
接收外部 webhook 负载
</Card>
<Card title="Schedule" href="/triggers/schedule">
<Card title="计划" href="/triggers/schedule">
基于 Cron 或间隔的执行
</Card>
<Card title="RSS 源" href="/triggers/rss">
监控 RSS 和 Atom 源的新内容
</Card>
</Cards>
## 快速对比
| 触发器 | 启动条件 |
|---------|-----------------|
| **Start** | 编辑器运行、部署到 API 请求或聊天消息 |
| **Schedule** | 在 Schedule 块中管理的计时器 |
| **Webhook** | 收到入站 HTTP 请求 |
| **开始** | 编辑器运行、部署到 API 请求或聊天消息 |
| **计划** | 在计划块中管理的计时器 |
| **Webhook** | 收到入站 HTTP 请求 |
| **RSS 源** | 源中发布了新项目 |
> Start 块始终公开 `input`、`conversationId` 和 `files` 字段。通过向输入格式添加自定义字段来增加结构化数据。

View File

@@ -0,0 +1,49 @@
---
title: RSS 订阅源
---
import { Callout } from 'fumadocs-ui/components/callout'
import { Image } from '@/components/ui/image'
RSS 订阅源模块监控 RSS 和 Atom 订阅源——当有新内容发布时,您的工作流会自动触发。
<div className="flex justify-center">
<Image
src="/static/blocks/rss.png"
alt="RSS 订阅源模块"
width={500}
height={400}
className="my-6"
/>
</div>
## 配置
1. **添加 RSS 订阅源模块** - 拖动 RSS 订阅源模块以开始您的工作流
2. **输入订阅源 URL** - 粘贴任意 RSS 或 Atom 订阅源的 URL
3. **部署** - 部署您的工作流以激活轮询
部署后,订阅源每分钟检查一次是否有新内容。
## 输出字段
| 字段 | 类型 | 描述 |
|-------|------|-------------|
| `title` | string | 内容标题 |
| `link` | string | 内容链接 |
| `pubDate` | string | 发布日期 |
| `item` | object | 包含所有字段的原始内容 |
| `feed` | object | 原始订阅源元数据 |
可以直接访问映射字段 (`<rss.title>`),或者使用原始对象访问任意字段 (`<rss.item.author>`, `<rss.feed.language>`)。
## 使用场景
- **内容监控** - 跟踪博客、新闻网站或竞争对手的更新
- **播客自动化** - 当新剧集发布时触发工作流
- **版本跟踪** - 监控 GitHub 发布、更新日志或产品更新
- **社交聚合** - 收集支持 RSS 订阅源的平台内容
<Callout>
RSS 触发器仅对您保存触发器后发布的内容生效。现有的订阅源内容不会被处理。
</Callout>

View File

@@ -5760,9 +5760,9 @@ checksums:
content/1: e71056df0f7b2eb3b2f271f21d0052cc
content/2: da2b445db16c149f56558a4ea876a5f0
content/3: cec18f48b2cd7974eb556880e6604f7f
content/4: c187ae3362455acfe43282399f0d163a
content/4: b200402d6a01ab565fd56d113c530ef6
content/5: 4c3a5708af82c1ee42a12d14fd34e950
content/6: 12a43b499c1e8bb06b050964053ebde3
content/6: 64fbd5b16f4cff18ba976492a275c05e
content/7: a28151eeb5ba3518b33809055b04f0f6
content/8: cffe5b901d78ebf2000d07dc7579533e
content/9: 73486253d24eeff7ac44dfd0c8868d87
@@ -47243,7 +47243,7 @@ checksums:
meta/title: cba6e4eab965c94b8973e60e9ea10c05
meta/description: 366d196f8f11ecd0e96516bb9181f8d5
content/0: 1b031fb0c62c46b177aeed5c3d3f8f80
content/1: dcf2843a8d5eb40192a44104c9c788a4
content/1: e152030e17bf42c8c007a7b64082108c
content/2: 36ce181b1ca29664a1b6ddf4090623ae
content/3: 0a9d2b209e2a8b8fadda104bc42ea92e
content/4: 646bc61a952c9733ad296f441ae5ed9e
@@ -49232,3 +49232,85 @@ checksums:
content/52: bcb37c2bc190c3c12e5c721d376909f7
content/53: b3f310d5ef115bea5a8b75bf25d7ea9a
content/54: dafdefed393d3f02fe15ef832c922450
c9be5cc608340116679fe327fbe63480:
meta/title: aa4b66dbba98434a4db6d610ca890294
meta/description: 257605ee0390330ef9eab6e37af91194
content/0: 1b031fb0c62c46b177aeed5c3d3f8f80
content/1: 4bf86a30616d0d72abc566853303b76b
content/2: 2e930315ec421d2a3b1bfdb4772a1cf1
content/3: b3f6c9d26d40474f23c0807242efa241
content/4: c7f52e83abe327e76611283536d1eab5
content/5: 50fdbcb70ad91301c147b15e3e820ec0
content/6: 821e6394b0a953e2b0842b04ae8f3105
content/7: d0319d7cb966b70ee0c02a95cff46f93
content/8: 9c8aa3f09c9b2bd50ea4cdff3598ea4e
content/9: a1fe169a3a18363fb213703cc030bd88
content/10: f321c7ba0733abff259e6cb67e28206c
content/11: 371d0e46b4bd2c23f559b8bc112f6955
content/12: c6f1ef4078a4dd6a275b7d35c4c4111c
content/13: bcadfc362b69078beee0088e5936c98b
content/14: d4f59eb404e3b9bb1a435017f1a0b59f
content/15: b3f310d5ef115bea5a8b75bf25d7ea9a
content/16: 724d99e69acb7e708fd374d48bfcc10f
c9f7b791abaf0d87cf84a72d272d3b06:
meta/title: 7de8ba470a0c9dec4744b3c3cc177649
meta/description: 1d912a560e6b4a91dd606e3411636114
content/0: 1b031fb0c62c46b177aeed5c3d3f8f80
content/1: 0e15635e5f8b9a9e2784d7437016732e
content/2: efdaf30231cd82038af969ee2e4d5893
content/3: 13e8cc6de77c95b362e47cfb5e784df5
content/4: 5a1625be72ab706aa5e5df10b0f14cd8
content/5: 24bb72eb803058206443ee6f04961ed0
content/6: 82b5a7ad9b8222bab8a7d90e40a7016f
content/7: 6a1b76137145b1359c7614aca381e217
content/8: 899d98f7957916b99affdca5f5f0b95d
content/9: fbc8be7912092ba5bb3939699f353b5b
content/10: 821e6394b0a953e2b0842b04ae8f3105
content/11: 5e5da9369cae02d9b99d74fa04f946f7
content/12: 9c8aa3f09c9b2bd50ea4cdff3598ea4e
content/13: c6caf38bc019cd301adff09db02f10ec
content/14: 76e738d08d55e3cb175d72a00da780da
content/15: 371d0e46b4bd2c23f559b8bc112f6955
content/16: 405fb5a3b5ccf556769b7f54038cbafd
content/17: bcadfc362b69078beee0088e5936c98b
content/18: 7d1fc963936fd278098980231cd741d6
content/19: 5d4837312f813cf934b2c9aee8179ec8
content/20: bc83de0badce9a1d471c97872ac0b550
content/21: 371d0e46b4bd2c23f559b8bc112f6955
content/22: 261139b68ea4de9b50743a402db8168d
content/23: bcadfc362b69078beee0088e5936c98b
content/24: a55ffa4e204bcc53131f42b02ee0f812
content/25: d16d2c9c4fa2a6e9c8b308192b0b3dc8
content/26: 8eaa96c0ba2fb77c023692a5e4334616
content/27: 371d0e46b4bd2c23f559b8bc112f6955
content/28: bcf30844e3d152515f817efb953ed5b0
content/29: bcadfc362b69078beee0088e5936c98b
content/30: f3eddb7e55dcefcc3f971b4836487b45
content/31: 601453f757ae944030dbd93f3afd1575
content/32: 8a62582ec6c6b17957b70076b5834c08
content/33: 371d0e46b4bd2c23f559b8bc112f6955
content/34: 1f4f6f2120ce67e63e8b8976759c05a3
content/35: bcadfc362b69078beee0088e5936c98b
content/36: 0cc0f238ca3ec3d1f3b9f16e04aa8138
content/37: d131798eeae12126287a483831da2d83
content/38: af12f8b3cc617981fb20e3e7de06f723
content/39: 371d0e46b4bd2c23f559b8bc112f6955
content/40: 4c4d76bbf61f52f83b6530322fa87d2e
content/41: bcadfc362b69078beee0088e5936c98b
content/42: dc2cfed837ea55adfa23bd7c87d5299d
content/43: b3f310d5ef115bea5a8b75bf25d7ea9a
content/44: df2ef65659b8ea0a13916358943f965b
ebed3bd73520bf81399749586796f9d0:
meta/title: 1763bebd6001500cdfc1b5127b0c1cde
content/0: eb0ed7078f192304703144f4cac3442f
content/1: ba5ba29787a0eb35c46dacb3544bafe1
content/2: 5ed74bf0e91235f71eeceb25712ad2d3
content/3: 0441638444240cd20a6c69ea1d3afbb1
content/4: ef102e10f1402df7290680c1e9df8a5e
content/5: 95afa83a30cb01724b932b19dd69f20b
content/6: 8ebc5e005f61d253c006824168abaf22
content/7: df81a49b54d378523fb74aa0b0fb8be1
content/8: c5fb77d31bae86aa85f2b2b84ce0beab
content/9: 7a3be8a3771ee428ecf09008e42c0e2e
content/10: 42e4caf9b036a8d7726a8968f3ed201f
content/11: e74f8ee79105babdaa8dfec520ecdf74

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View File

@@ -364,7 +364,7 @@ describe('Chat Identifier API Route', () => {
error: {
message: 'Workflow is not deployed',
statusCode: 403,
logCreated: true,
logCreated: false,
},
})

View File

@@ -4,7 +4,9 @@ import { getSession } from '@/lib/auth'
import { SIM_AGENT_API_URL_DEFAULT } from '@/lib/copilot/constants'
import { env } from '@/lib/core/config/env'
const GenerateApiKeySchema = z.object({}).optional()
const GenerateApiKeySchema = z.object({
name: z.string().min(1, 'Name is required').max(255, 'Name is too long'),
})
export async function POST(req: NextRequest) {
try {
@@ -31,13 +33,15 @@ export async function POST(req: NextRequest) {
)
}
const { name } = validationResult.data
const res = await fetch(`${SIM_AGENT_API_URL}/api/validate-key/generate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(env.COPILOT_API_KEY ? { 'x-api-key': env.COPILOT_API_KEY } : {}),
},
body: JSON.stringify({ userId }),
body: JSON.stringify({ userId, name }),
})
if (!res.ok) {

View File

@@ -27,7 +27,9 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: 'Failed to get keys' }, { status: res.status || 500 })
}
const apiKeys = (await res.json().catch(() => null)) as { id: string; apiKey: string }[] | null
const apiKeys = (await res.json().catch(() => null)) as
| { id: string; apiKey: string; name?: string; createdAt?: string; lastUsed?: string }[]
| null
if (!Array.isArray(apiKeys)) {
return NextResponse.json({ error: 'Invalid response from Sim Agent' }, { status: 500 })
@@ -37,7 +39,13 @@ export async function GET(request: NextRequest) {
const value = typeof k.apiKey === 'string' ? k.apiKey : ''
const last6 = value.slice(-6)
const displayKey = `•••••${last6}`
return { id: k.id, displayKey }
return {
id: k.id,
displayKey,
name: k.name || null,
createdAt: k.createdAt || null,
lastUsed: k.lastUsed || null,
}
})
return NextResponse.json({ keys }, { status: 200 })

View File

@@ -353,10 +353,10 @@ export async function POST(req: NextRequest) {
executeLocally: true,
},
]
// Fetch user credentials (OAuth + API keys)
// Fetch user credentials (OAuth + API keys) - pass workflowId to get workspace env vars
try {
const rawCredentials = await getCredentialsServerTool.execute(
{},
{ workflowId },
{ userId: authenticatedUserId }
)
@@ -840,9 +840,36 @@ export async function POST(req: NextRequest) {
}
} catch (error) {
logger.error(`[${tracker.requestId}] Error processing stream:`, error)
controller.error(error)
// Send an error event to the client before closing so it knows what happened
try {
const errorMessage =
error instanceof Error && error.message === 'terminated'
? 'Connection to AI service was interrupted. Please try again.'
: 'An unexpected error occurred while processing the response.'
const encoder = new TextEncoder()
// Send error as content so it shows in the chat
controller.enqueue(
encoder.encode(
`data: ${JSON.stringify({ type: 'content', data: `\n\n_${errorMessage}_` })}\n\n`
)
)
// Send done event to properly close the stream on client
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'done' })}\n\n`))
} catch (enqueueError) {
// Stream might already be closed, that's ok
logger.warn(
`[${tracker.requestId}] Could not send error event to client:`,
enqueueError
)
}
} finally {
controller.close()
try {
controller.close()
} catch {
// Controller might already be closed
}
}
},
})

View File

@@ -1,9 +1,10 @@
import { db } from '@sim/db'
import { member, subscription } from '@sim/db/schema'
import { member, organization, subscription } from '@sim/db/schema'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
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/environment'
import { createLogger } from '@/lib/logs/console/logger'
@@ -172,6 +173,39 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
})
.where(eq(subscription.id, orgSubscription.id))
// Update orgUsageLimit to reflect new seat count (seats × basePrice as minimum)
const { basePrice } = getPlanPricing('team')
const newMinimumLimit = newSeatCount * basePrice
const orgData = await db
.select({ orgUsageLimit: organization.orgUsageLimit })
.from(organization)
.where(eq(organization.id, organizationId))
.limit(1)
const currentOrgLimit =
orgData.length > 0 && orgData[0].orgUsageLimit
? Number.parseFloat(orgData[0].orgUsageLimit)
: 0
// Update if new minimum is higher than current limit
if (newMinimumLimit > currentOrgLimit) {
await db
.update(organization)
.set({
orgUsageLimit: newMinimumLimit.toFixed(2),
updatedAt: new Date(),
})
.where(eq(organization.id, organizationId))
logger.info('Updated organization usage limit for seat change', {
organizationId,
newSeatCount,
newMinimumLimit,
previousLimit: currentOrgLimit,
})
}
logger.info('Successfully updated seat count', {
organizationId,
stripeSubscriptionId: orgSubscription.stripeSubscriptionId,
@@ -224,74 +258,3 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
/**
* GET /api/organizations/[id]/seats
* Get current seat information for an organization
*/
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id: organizationId } = await params
// Verify user has access to this organization
const memberEntry = await db
.select()
.from(member)
.where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id)))
.limit(1)
if (memberEntry.length === 0) {
return NextResponse.json(
{ error: 'Forbidden - Not a member of this organization' },
{ status: 403 }
)
}
// Get subscription data
const subscriptionRecord = await db
.select()
.from(subscription)
.where(and(eq(subscription.referenceId, organizationId), eq(subscription.status, 'active')))
.limit(1)
if (subscriptionRecord.length === 0) {
return NextResponse.json({ error: 'No active subscription found' }, { status: 404 })
}
// Get member count
const memberCount = await db
.select({ userId: member.userId })
.from(member)
.where(eq(member.organizationId, organizationId))
const orgSubscription = subscriptionRecord[0]
const maxSeats = orgSubscription.seats || 1
const usedSeats = memberCount.length
const availableSeats = Math.max(0, maxSeats - usedSeats)
return NextResponse.json({
success: true,
data: {
maxSeats,
usedSeats,
availableSeats,
plan: orgSubscription.plan,
canModifySeats: orgSubscription.plan === 'team',
},
})
} catch (error) {
const { id: organizationId } = await params
logger.error('Failed to get organization seats', {
organizationId,
error,
})
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -0,0 +1,188 @@
import { type NextRequest, NextResponse } from 'next/server'
import type { SFTPWrapper } from 'ssh2'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { createLogger } from '@/lib/logs/console/logger'
import {
createSftpConnection,
getFileType,
getSftp,
isPathSafe,
sanitizePath,
sftpIsDirectory,
} from '@/app/api/tools/sftp/utils'
export const dynamic = 'force-dynamic'
const logger = createLogger('SftpDeleteAPI')
const DeleteSchema = z.object({
host: z.string().min(1, 'Host is required'),
port: z.coerce.number().int().positive().default(22),
username: z.string().min(1, 'Username is required'),
password: z.string().nullish(),
privateKey: z.string().nullish(),
passphrase: z.string().nullish(),
remotePath: z.string().min(1, 'Remote path is required'),
recursive: z.boolean().default(false),
})
/**
* Recursively deletes a directory and all its contents
*/
async function deleteRecursive(sftp: SFTPWrapper, dirPath: string): Promise<void> {
const entries = await new Promise<Array<{ filename: string; attrs: any }>>((resolve, reject) => {
sftp.readdir(dirPath, (err, list) => {
if (err) {
reject(err)
} else {
resolve(list)
}
})
})
for (const entry of entries) {
if (entry.filename === '.' || entry.filename === '..') continue
const entryPath = `${dirPath}/${entry.filename}`
const entryType = getFileType(entry.attrs)
if (entryType === 'directory') {
await deleteRecursive(sftp, entryPath)
} else {
await new Promise<void>((resolve, reject) => {
sftp.unlink(entryPath, (err) => {
if (err) reject(err)
else resolve()
})
})
}
}
await new Promise<void>((resolve, reject) => {
sftp.rmdir(dirPath, (err) => {
if (err) reject(err)
else resolve()
})
})
}
export async function POST(request: NextRequest) {
const requestId = generateRequestId()
try {
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
if (!authResult.success) {
logger.warn(`[${requestId}] Unauthorized SFTP delete attempt: ${authResult.error}`)
return NextResponse.json(
{ success: false, error: authResult.error || 'Authentication required' },
{ status: 401 }
)
}
logger.info(`[${requestId}] Authenticated SFTP delete request via ${authResult.authType}`, {
userId: authResult.userId,
})
const body = await request.json()
const params = DeleteSchema.parse(body)
if (!params.password && !params.privateKey) {
return NextResponse.json(
{ error: 'Either password or privateKey must be provided' },
{ status: 400 }
)
}
if (!isPathSafe(params.remotePath)) {
logger.warn(`[${requestId}] Path traversal attempt detected in remotePath`)
return NextResponse.json(
{ error: 'Invalid remote path: path traversal sequences are not allowed' },
{ status: 400 }
)
}
logger.info(`[${requestId}] Connecting to SFTP server ${params.host}:${params.port}`)
const client = await createSftpConnection({
host: params.host,
port: params.port,
username: params.username,
password: params.password,
privateKey: params.privateKey,
passphrase: params.passphrase,
})
try {
const sftp = await getSftp(client)
const remotePath = sanitizePath(params.remotePath)
logger.info(`[${requestId}] Deleting ${remotePath} (recursive: ${params.recursive})`)
const isDir = await sftpIsDirectory(sftp, remotePath)
if (isDir) {
if (params.recursive) {
await deleteRecursive(sftp, remotePath)
} else {
await new Promise<void>((resolve, reject) => {
sftp.rmdir(remotePath, (err) => {
if (err) {
if (err.message.includes('not empty')) {
reject(
new Error(
'Directory is not empty. Use recursive: true to delete non-empty directories.'
)
)
} else {
reject(err)
}
} else {
resolve()
}
})
})
}
} else {
await new Promise<void>((resolve, reject) => {
sftp.unlink(remotePath, (err) => {
if (err) {
if (err.message.includes('No such file')) {
reject(new Error(`File not found: ${remotePath}`))
} else {
reject(err)
}
} else {
resolve()
}
})
})
}
logger.info(`[${requestId}] Successfully deleted ${remotePath}`)
return NextResponse.json({
success: true,
deletedPath: remotePath,
message: `Successfully deleted ${remotePath}`,
})
} finally {
client.end()
}
} catch (error) {
if (error instanceof z.ZodError) {
logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors })
return NextResponse.json(
{ error: 'Invalid request data', details: error.errors },
{ status: 400 }
)
}
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'
logger.error(`[${requestId}] SFTP delete failed:`, error)
return NextResponse.json({ error: `SFTP delete failed: ${errorMessage}` }, { status: 500 })
}
}

View File

@@ -0,0 +1,149 @@
import path from 'path'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { createLogger } from '@/lib/logs/console/logger'
import { createSftpConnection, getSftp, isPathSafe, sanitizePath } from '@/app/api/tools/sftp/utils'
export const dynamic = 'force-dynamic'
const logger = createLogger('SftpDownloadAPI')
const DownloadSchema = z.object({
host: z.string().min(1, 'Host is required'),
port: z.coerce.number().int().positive().default(22),
username: z.string().min(1, 'Username is required'),
password: z.string().nullish(),
privateKey: z.string().nullish(),
passphrase: z.string().nullish(),
remotePath: z.string().min(1, 'Remote path is required'),
encoding: z.enum(['utf-8', 'base64']).default('utf-8'),
})
export async function POST(request: NextRequest) {
const requestId = generateRequestId()
try {
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
if (!authResult.success) {
logger.warn(`[${requestId}] Unauthorized SFTP download attempt: ${authResult.error}`)
return NextResponse.json(
{ success: false, error: authResult.error || 'Authentication required' },
{ status: 401 }
)
}
logger.info(`[${requestId}] Authenticated SFTP download request via ${authResult.authType}`, {
userId: authResult.userId,
})
const body = await request.json()
const params = DownloadSchema.parse(body)
if (!params.password && !params.privateKey) {
return NextResponse.json(
{ error: 'Either password or privateKey must be provided' },
{ status: 400 }
)
}
if (!isPathSafe(params.remotePath)) {
logger.warn(`[${requestId}] Path traversal attempt detected in remotePath`)
return NextResponse.json(
{ error: 'Invalid remote path: path traversal sequences are not allowed' },
{ status: 400 }
)
}
logger.info(`[${requestId}] Connecting to SFTP server ${params.host}:${params.port}`)
const client = await createSftpConnection({
host: params.host,
port: params.port,
username: params.username,
password: params.password,
privateKey: params.privateKey,
passphrase: params.passphrase,
})
try {
const sftp = await getSftp(client)
const remotePath = sanitizePath(params.remotePath)
const stats = await new Promise<{ size: number }>((resolve, reject) => {
sftp.stat(remotePath, (err, stats) => {
if (err) {
if (err.message.includes('No such file')) {
reject(new Error(`File not found: ${remotePath}`))
} else {
reject(err)
}
} else {
resolve(stats)
}
})
})
const maxSize = 50 * 1024 * 1024
if (stats.size > maxSize) {
const sizeMB = (stats.size / (1024 * 1024)).toFixed(2)
return NextResponse.json(
{ success: false, error: `File size (${sizeMB}MB) exceeds download limit of 50MB` },
{ status: 400 }
)
}
logger.info(`[${requestId}] Downloading file ${remotePath} (${stats.size} bytes)`)
const chunks: Buffer[] = []
await new Promise<void>((resolve, reject) => {
const readStream = sftp.createReadStream(remotePath)
readStream.on('data', (chunk: Buffer) => {
chunks.push(chunk)
})
readStream.on('end', () => resolve())
readStream.on('error', reject)
})
const buffer = Buffer.concat(chunks)
const fileName = path.basename(remotePath)
let content: string
if (params.encoding === 'base64') {
content = buffer.toString('base64')
} else {
content = buffer.toString('utf-8')
}
logger.info(`[${requestId}] Downloaded ${fileName} (${buffer.length} bytes)`)
return NextResponse.json({
success: true,
fileName,
content,
size: buffer.length,
encoding: params.encoding,
message: `Successfully downloaded ${fileName}`,
})
} finally {
client.end()
}
} catch (error) {
if (error instanceof z.ZodError) {
logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors })
return NextResponse.json(
{ error: 'Invalid request data', details: error.errors },
{ status: 400 }
)
}
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'
logger.error(`[${requestId}] SFTP download failed:`, error)
return NextResponse.json({ error: `SFTP download failed: ${errorMessage}` }, { status: 500 })
}
}

View File

@@ -0,0 +1,156 @@
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { createLogger } from '@/lib/logs/console/logger'
import {
createSftpConnection,
getFileType,
getSftp,
isPathSafe,
parsePermissions,
sanitizePath,
} from '@/app/api/tools/sftp/utils'
export const dynamic = 'force-dynamic'
const logger = createLogger('SftpListAPI')
const ListSchema = z.object({
host: z.string().min(1, 'Host is required'),
port: z.coerce.number().int().positive().default(22),
username: z.string().min(1, 'Username is required'),
password: z.string().nullish(),
privateKey: z.string().nullish(),
passphrase: z.string().nullish(),
remotePath: z.string().min(1, 'Remote path is required'),
detailed: z.boolean().default(false),
})
export async function POST(request: NextRequest) {
const requestId = generateRequestId()
try {
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
if (!authResult.success) {
logger.warn(`[${requestId}] Unauthorized SFTP list attempt: ${authResult.error}`)
return NextResponse.json(
{ success: false, error: authResult.error || 'Authentication required' },
{ status: 401 }
)
}
logger.info(`[${requestId}] Authenticated SFTP list request via ${authResult.authType}`, {
userId: authResult.userId,
})
const body = await request.json()
const params = ListSchema.parse(body)
if (!params.password && !params.privateKey) {
return NextResponse.json(
{ error: 'Either password or privateKey must be provided' },
{ status: 400 }
)
}
if (!isPathSafe(params.remotePath)) {
logger.warn(`[${requestId}] Path traversal attempt detected in remotePath`)
return NextResponse.json(
{ error: 'Invalid remote path: path traversal sequences are not allowed' },
{ status: 400 }
)
}
logger.info(`[${requestId}] Connecting to SFTP server ${params.host}:${params.port}`)
const client = await createSftpConnection({
host: params.host,
port: params.port,
username: params.username,
password: params.password,
privateKey: params.privateKey,
passphrase: params.passphrase,
})
try {
const sftp = await getSftp(client)
const remotePath = sanitizePath(params.remotePath)
logger.info(`[${requestId}] Listing directory ${remotePath}`)
const fileList = await new Promise<Array<{ filename: string; longname: string; attrs: any }>>(
(resolve, reject) => {
sftp.readdir(remotePath, (err, list) => {
if (err) {
if (err.message.includes('No such file')) {
reject(new Error(`Directory not found: ${remotePath}`))
} else {
reject(err)
}
} else {
resolve(list)
}
})
}
)
const entries = fileList
.filter((item) => item.filename !== '.' && item.filename !== '..')
.map((item) => {
const entry: {
name: string
type: 'file' | 'directory' | 'symlink' | 'other'
size?: number
permissions?: string
modifiedAt?: string
} = {
name: item.filename,
type: getFileType(item.attrs),
}
if (params.detailed) {
entry.size = item.attrs.size
entry.permissions = parsePermissions(item.attrs.mode)
if (item.attrs.mtime) {
entry.modifiedAt = new Date(item.attrs.mtime * 1000).toISOString()
}
}
return entry
})
entries.sort((a, b) => {
if (a.type === 'directory' && b.type !== 'directory') return -1
if (a.type !== 'directory' && b.type === 'directory') return 1
return a.name.localeCompare(b.name)
})
logger.info(`[${requestId}] Listed ${entries.length} entries in ${remotePath}`)
return NextResponse.json({
success: true,
path: remotePath,
entries,
count: entries.length,
message: `Found ${entries.length} entries in ${remotePath}`,
})
} finally {
client.end()
}
} catch (error) {
if (error instanceof z.ZodError) {
logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors })
return NextResponse.json(
{ error: 'Invalid request data', details: error.errors },
{ status: 400 }
)
}
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'
logger.error(`[${requestId}] SFTP list failed:`, error)
return NextResponse.json({ error: `SFTP list failed: ${errorMessage}` }, { status: 500 })
}
}

View File

@@ -0,0 +1,168 @@
import { type NextRequest, NextResponse } from 'next/server'
import type { SFTPWrapper } from 'ssh2'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { createLogger } from '@/lib/logs/console/logger'
import {
createSftpConnection,
getSftp,
isPathSafe,
sanitizePath,
sftpExists,
} from '@/app/api/tools/sftp/utils'
export const dynamic = 'force-dynamic'
const logger = createLogger('SftpMkdirAPI')
const MkdirSchema = z.object({
host: z.string().min(1, 'Host is required'),
port: z.coerce.number().int().positive().default(22),
username: z.string().min(1, 'Username is required'),
password: z.string().nullish(),
privateKey: z.string().nullish(),
passphrase: z.string().nullish(),
remotePath: z.string().min(1, 'Remote path is required'),
recursive: z.boolean().default(false),
})
/**
* Creates directory recursively (like mkdir -p)
*/
async function mkdirRecursive(sftp: SFTPWrapper, dirPath: string): Promise<void> {
const parts = dirPath.split('/').filter(Boolean)
let currentPath = dirPath.startsWith('/') ? '' : ''
for (const part of parts) {
currentPath = currentPath
? `${currentPath}/${part}`
: dirPath.startsWith('/')
? `/${part}`
: part
const exists = await sftpExists(sftp, currentPath)
if (!exists) {
await new Promise<void>((resolve, reject) => {
sftp.mkdir(currentPath, (err) => {
if (err && !err.message.includes('already exists')) {
reject(err)
} else {
resolve()
}
})
})
}
}
}
export async function POST(request: NextRequest) {
const requestId = generateRequestId()
try {
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
if (!authResult.success) {
logger.warn(`[${requestId}] Unauthorized SFTP mkdir attempt: ${authResult.error}`)
return NextResponse.json(
{ success: false, error: authResult.error || 'Authentication required' },
{ status: 401 }
)
}
logger.info(`[${requestId}] Authenticated SFTP mkdir request via ${authResult.authType}`, {
userId: authResult.userId,
})
const body = await request.json()
const params = MkdirSchema.parse(body)
if (!params.password && !params.privateKey) {
return NextResponse.json(
{ error: 'Either password or privateKey must be provided' },
{ status: 400 }
)
}
if (!isPathSafe(params.remotePath)) {
logger.warn(`[${requestId}] Path traversal attempt detected in remotePath`)
return NextResponse.json(
{ error: 'Invalid remote path: path traversal sequences are not allowed' },
{ status: 400 }
)
}
logger.info(`[${requestId}] Connecting to SFTP server ${params.host}:${params.port}`)
const client = await createSftpConnection({
host: params.host,
port: params.port,
username: params.username,
password: params.password,
privateKey: params.privateKey,
passphrase: params.passphrase,
})
try {
const sftp = await getSftp(client)
const remotePath = sanitizePath(params.remotePath)
logger.info(
`[${requestId}] Creating directory ${remotePath} (recursive: ${params.recursive})`
)
if (params.recursive) {
await mkdirRecursive(sftp, remotePath)
} else {
const exists = await sftpExists(sftp, remotePath)
if (exists) {
return NextResponse.json(
{ error: `Directory already exists: ${remotePath}` },
{ status: 409 }
)
}
await new Promise<void>((resolve, reject) => {
sftp.mkdir(remotePath, (err) => {
if (err) {
if (err.message.includes('No such file')) {
reject(
new Error(
'Parent directory does not exist. Use recursive: true to create parent directories.'
)
)
} else {
reject(err)
}
} else {
resolve()
}
})
})
}
logger.info(`[${requestId}] Successfully created directory ${remotePath}`)
return NextResponse.json({
success: true,
createdPath: remotePath,
message: `Successfully created directory ${remotePath}`,
})
} finally {
client.end()
}
} catch (error) {
if (error instanceof z.ZodError) {
logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors })
return NextResponse.json(
{ error: 'Invalid request data', details: error.errors },
{ status: 400 }
)
}
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'
logger.error(`[${requestId}] SFTP mkdir failed:`, error)
return NextResponse.json({ error: `SFTP mkdir failed: ${errorMessage}` }, { status: 500 })
}
}

View File

@@ -0,0 +1,242 @@
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { createLogger } from '@/lib/logs/console/logger'
import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils'
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
import {
createSftpConnection,
getSftp,
isPathSafe,
sanitizeFileName,
sanitizePath,
sftpExists,
} from '@/app/api/tools/sftp/utils'
export const dynamic = 'force-dynamic'
const logger = createLogger('SftpUploadAPI')
const UploadSchema = z.object({
host: z.string().min(1, 'Host is required'),
port: z.coerce.number().int().positive().default(22),
username: z.string().min(1, 'Username is required'),
password: z.string().nullish(),
privateKey: z.string().nullish(),
passphrase: z.string().nullish(),
remotePath: z.string().min(1, 'Remote path is required'),
files: z
.union([z.array(z.any()), z.string(), z.number(), z.null(), z.undefined()])
.transform((val) => {
if (Array.isArray(val)) return val
if (val === null || val === undefined || val === '') return undefined
return undefined
})
.nullish(),
fileContent: z.string().nullish(),
fileName: z.string().nullish(),
overwrite: z.boolean().default(true),
permissions: z.string().nullish(),
})
export async function POST(request: NextRequest) {
const requestId = generateRequestId()
try {
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
if (!authResult.success) {
logger.warn(`[${requestId}] Unauthorized SFTP upload attempt: ${authResult.error}`)
return NextResponse.json(
{ success: false, error: authResult.error || 'Authentication required' },
{ status: 401 }
)
}
logger.info(`[${requestId}] Authenticated SFTP upload request via ${authResult.authType}`, {
userId: authResult.userId,
})
const body = await request.json()
const params = UploadSchema.parse(body)
if (!params.password && !params.privateKey) {
return NextResponse.json(
{ error: 'Either password or privateKey must be provided' },
{ status: 400 }
)
}
const hasFiles = params.files && params.files.length > 0
const hasDirectContent = params.fileContent && params.fileName
if (!hasFiles && !hasDirectContent) {
return NextResponse.json(
{ error: 'Either files or fileContent with fileName must be provided' },
{ status: 400 }
)
}
if (!isPathSafe(params.remotePath)) {
logger.warn(`[${requestId}] Path traversal attempt detected in remotePath`)
return NextResponse.json(
{ error: 'Invalid remote path: path traversal sequences are not allowed' },
{ status: 400 }
)
}
logger.info(`[${requestId}] Connecting to SFTP server ${params.host}:${params.port}`)
const client = await createSftpConnection({
host: params.host,
port: params.port,
username: params.username,
password: params.password,
privateKey: params.privateKey,
passphrase: params.passphrase,
})
try {
const sftp = await getSftp(client)
const remotePath = sanitizePath(params.remotePath)
const uploadedFiles: Array<{ name: string; remotePath: string; size: number }> = []
if (hasFiles) {
const rawFiles = params.files!
logger.info(`[${requestId}] Processing ${rawFiles.length} file(s) for upload`)
const userFiles = processFilesToUserFiles(rawFiles, requestId, logger)
const totalSize = userFiles.reduce((sum, file) => sum + file.size, 0)
const maxSize = 100 * 1024 * 1024
if (totalSize > maxSize) {
const sizeMB = (totalSize / (1024 * 1024)).toFixed(2)
return NextResponse.json(
{ success: false, error: `Total file size (${sizeMB}MB) exceeds limit of 100MB` },
{ status: 400 }
)
}
for (const file of userFiles) {
try {
logger.info(
`[${requestId}] Downloading file for upload: ${file.name} (${file.size} bytes)`
)
const buffer = await downloadFileFromStorage(file, requestId, logger)
const safeFileName = sanitizeFileName(file.name)
const fullRemotePath = remotePath.endsWith('/')
? `${remotePath}${safeFileName}`
: `${remotePath}/${safeFileName}`
const sanitizedRemotePath = sanitizePath(fullRemotePath)
if (!params.overwrite) {
const exists = await sftpExists(sftp, sanitizedRemotePath)
if (exists) {
logger.warn(`[${requestId}] File ${sanitizedRemotePath} already exists, skipping`)
continue
}
}
await new Promise<void>((resolve, reject) => {
const writeStream = sftp.createWriteStream(sanitizedRemotePath, {
mode: params.permissions ? Number.parseInt(params.permissions, 8) : 0o644,
})
writeStream.on('error', reject)
writeStream.on('close', () => resolve())
writeStream.end(buffer)
})
uploadedFiles.push({
name: safeFileName,
remotePath: sanitizedRemotePath,
size: buffer.length,
})
logger.info(`[${requestId}] Uploaded ${safeFileName} to ${sanitizedRemotePath}`)
} catch (error) {
logger.error(`[${requestId}] Failed to upload file ${file.name}:`, error)
throw new Error(
`Failed to upload file "${file.name}": ${error instanceof Error ? error.message : 'Unknown error'}`
)
}
}
}
if (hasDirectContent) {
const safeFileName = sanitizeFileName(params.fileName!)
const fullRemotePath = remotePath.endsWith('/')
? `${remotePath}${safeFileName}`
: `${remotePath}/${safeFileName}`
const sanitizedRemotePath = sanitizePath(fullRemotePath)
if (!params.overwrite) {
const exists = await sftpExists(sftp, sanitizedRemotePath)
if (exists) {
return NextResponse.json(
{ error: 'File already exists and overwrite is disabled' },
{ status: 409 }
)
}
}
let content: Buffer
try {
content = Buffer.from(params.fileContent!, 'base64')
const reEncoded = content.toString('base64')
if (reEncoded !== params.fileContent) {
content = Buffer.from(params.fileContent!, 'utf-8')
}
} catch {
content = Buffer.from(params.fileContent!, 'utf-8')
}
await new Promise<void>((resolve, reject) => {
const writeStream = sftp.createWriteStream(sanitizedRemotePath, {
mode: params.permissions ? Number.parseInt(params.permissions, 8) : 0o644,
})
writeStream.on('error', reject)
writeStream.on('close', () => resolve())
writeStream.end(content)
})
uploadedFiles.push({
name: safeFileName,
remotePath: sanitizedRemotePath,
size: content.length,
})
logger.info(`[${requestId}] Uploaded direct content to ${sanitizedRemotePath}`)
}
logger.info(`[${requestId}] SFTP upload completed: ${uploadedFiles.length} file(s)`)
return NextResponse.json({
success: true,
uploadedFiles,
message: `Successfully uploaded ${uploadedFiles.length} file(s)`,
})
} finally {
client.end()
}
} catch (error) {
if (error instanceof z.ZodError) {
logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors })
return NextResponse.json(
{ error: 'Invalid request data', details: error.errors },
{ status: 400 }
)
}
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'
logger.error(`[${requestId}] SFTP upload failed:`, error)
return NextResponse.json({ error: `SFTP upload failed: ${errorMessage}` }, { status: 500 })
}
}

View File

@@ -0,0 +1,275 @@
import { type Attributes, Client, type ConnectConfig, type SFTPWrapper } from 'ssh2'
const S_IFMT = 0o170000
const S_IFDIR = 0o040000
const S_IFREG = 0o100000
const S_IFLNK = 0o120000
export interface SftpConnectionConfig {
host: string
port: number
username: string
password?: string | null
privateKey?: string | null
passphrase?: string | null
timeout?: number
keepaliveInterval?: number
readyTimeout?: number
}
/**
* Formats SSH/SFTP errors with helpful troubleshooting context
*/
function formatSftpError(err: Error, config: { host: string; port: number }): Error {
const errorMessage = err.message.toLowerCase()
const { host, port } = config
if (errorMessage.includes('econnrefused') || errorMessage.includes('connection refused')) {
return new Error(
`Connection refused to ${host}:${port}. ` +
`Please verify: (1) SSH/SFTP server is running, ` +
`(2) Port ${port} is correct, ` +
`(3) Firewall allows connections.`
)
}
if (errorMessage.includes('econnreset') || errorMessage.includes('connection reset')) {
return new Error(
`Connection reset by ${host}:${port}. ` +
`This usually means: (1) Wrong port number, ` +
`(2) Server rejected the connection, ` +
`(3) Network/firewall interrupted the connection.`
)
}
if (errorMessage.includes('etimedout') || errorMessage.includes('timeout')) {
return new Error(
`Connection timed out to ${host}:${port}. ` +
`Please verify: (1) Host is reachable, ` +
`(2) No firewall is blocking the connection, ` +
`(3) The SFTP server is responding.`
)
}
if (errorMessage.includes('enotfound') || errorMessage.includes('getaddrinfo')) {
return new Error(
`Could not resolve hostname "${host}". Please verify the hostname or IP address is correct.`
)
}
if (errorMessage.includes('authentication') || errorMessage.includes('auth')) {
return new Error(
`Authentication failed on ${host}:${port}. ` +
`Please verify: (1) Username is correct, ` +
`(2) Password or private key is valid, ` +
`(3) User has SFTP access on the server.`
)
}
if (
errorMessage.includes('key') &&
(errorMessage.includes('parse') || errorMessage.includes('invalid'))
) {
return new Error(
`Invalid private key format. ` +
`Please ensure you're using a valid OpenSSH private key ` +
`(starts with "-----BEGIN" and ends with "-----END").`
)
}
if (errorMessage.includes('host key') || errorMessage.includes('hostkey')) {
return new Error(
`Host key verification issue for ${host}. ` +
`This may be the first connection or the server's key has changed.`
)
}
return new Error(`SFTP connection to ${host}:${port} failed: ${err.message}`)
}
/**
* Creates an SSH connection for SFTP using the provided configuration.
* Uses ssh2 library defaults which align with OpenSSH standards.
*/
export function createSftpConnection(config: SftpConnectionConfig): Promise<Client> {
return new Promise((resolve, reject) => {
const client = new Client()
const port = config.port || 22
const host = config.host
if (!host || host.trim() === '') {
reject(new Error('Host is required. Please provide a valid hostname or IP address.'))
return
}
const hasPassword = config.password && config.password.trim() !== ''
const hasPrivateKey = config.privateKey && config.privateKey.trim() !== ''
if (!hasPassword && !hasPrivateKey) {
reject(new Error('Authentication required. Please provide either a password or private key.'))
return
}
const connectConfig: ConnectConfig = {
host: host.trim(),
port,
username: config.username,
}
if (config.readyTimeout !== undefined) {
connectConfig.readyTimeout = config.readyTimeout
}
if (config.keepaliveInterval !== undefined) {
connectConfig.keepaliveInterval = config.keepaliveInterval
}
if (hasPrivateKey) {
connectConfig.privateKey = config.privateKey!
if (config.passphrase && config.passphrase.trim() !== '') {
connectConfig.passphrase = config.passphrase
}
} else if (hasPassword) {
connectConfig.password = config.password!
}
client.on('ready', () => {
resolve(client)
})
client.on('error', (err) => {
reject(formatSftpError(err, { host, port }))
})
try {
client.connect(connectConfig)
} catch (err) {
reject(formatSftpError(err instanceof Error ? err : new Error(String(err)), { host, port }))
}
})
}
/**
* Gets SFTP subsystem from SSH client
*/
export function getSftp(client: Client): Promise<SFTPWrapper> {
return new Promise((resolve, reject) => {
client.sftp((err, sftp) => {
if (err) {
reject(new Error(`Failed to start SFTP session: ${err.message}`))
} else {
resolve(sftp)
}
})
})
}
/**
* Sanitizes a remote path to prevent path traversal attacks.
* Removes null bytes, normalizes path separators, and collapses traversal sequences.
* Based on OWASP Path Traversal prevention guidelines.
*/
export function sanitizePath(path: string): string {
let sanitized = path
sanitized = sanitized.replace(/\0/g, '')
sanitized = decodeURIComponent(sanitized)
sanitized = sanitized.replace(/\\/g, '/')
sanitized = sanitized.replace(/\/+/g, '/')
sanitized = sanitized.trim()
return sanitized
}
/**
* Sanitizes a filename to prevent path traversal and injection attacks.
* Removes directory traversal sequences, path separators, null bytes, and dangerous patterns.
* Based on OWASP Input Validation Cheat Sheet recommendations.
*/
export function sanitizeFileName(fileName: string): string {
let sanitized = fileName
sanitized = sanitized.replace(/\0/g, '')
try {
sanitized = decodeURIComponent(sanitized)
} catch {
// Keep original if decode fails (malformed encoding)
}
sanitized = sanitized.replace(/\.\.[/\\]?/g, '')
sanitized = sanitized.replace(/[/\\]/g, '_')
sanitized = sanitized.replace(/^\.+/, '')
sanitized = sanitized.replace(/[\x00-\x1f\x7f]/g, '')
sanitized = sanitized.trim()
return sanitized || 'unnamed_file'
}
/**
* Validates that a path doesn't contain traversal sequences.
* Returns true if the path is safe, false if it contains potential traversal attacks.
*/
export function isPathSafe(path: string): boolean {
const normalizedPath = path.replace(/\\/g, '/')
if (normalizedPath.includes('../') || normalizedPath.includes('..\\')) {
return false
}
try {
const decoded = decodeURIComponent(normalizedPath)
if (decoded.includes('../') || decoded.includes('..\\')) {
return false
}
} catch {
return false
}
if (normalizedPath.includes('\0')) {
return false
}
return true
}
/**
* Parses file permissions from mode bits to octal string representation.
*/
export function parsePermissions(mode: number): string {
return `0${(mode & 0o777).toString(8)}`
}
/**
* Determines file type from SFTP attributes mode bits.
*/
export function getFileType(attrs: Attributes): 'file' | 'directory' | 'symlink' | 'other' {
const fileType = attrs.mode & S_IFMT
if (fileType === S_IFDIR) return 'directory'
if (fileType === S_IFREG) return 'file'
if (fileType === S_IFLNK) return 'symlink'
return 'other'
}
/**
* Checks if a path exists on the SFTP server.
*/
export function sftpExists(sftp: SFTPWrapper, path: string): Promise<boolean> {
return new Promise((resolve) => {
sftp.stat(path, (err) => {
resolve(!err)
})
})
}
/**
* Checks if a path is a directory on the SFTP server.
*/
export function sftpIsDirectory(sftp: SFTPWrapper, path: string): Promise<boolean> {
return new Promise((resolve) => {
sftp.stat(path, (err, stats) => {
if (err) {
resolve(false)
} else {
resolve(getFileType(stats) === 'directory')
}
})
})
}

View File

@@ -4,9 +4,9 @@ import { checkServerSideUsageLimits } from '@/lib/billing'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import { getEffectiveCurrentPeriodCost } from '@/lib/billing/core/usage'
import { getUserStorageLimit, getUserStorageUsage } from '@/lib/billing/storage'
import { RateLimiter } from '@/lib/core/rate-limiter'
import { createLogger } from '@/lib/logs/console/logger'
import { createErrorResponse } from '@/app/api/workflows/utils'
import { RateLimiter } from '@/services/queue'
const logger = createLogger('UsageLimitsAPI')

View File

@@ -13,7 +13,6 @@
* GET /api/v1/admin/users/:id - Get user details
* GET /api/v1/admin/users/:id/billing - Get user billing info
* PATCH /api/v1/admin/users/:id/billing - Update user billing (limit, blocked)
* POST /api/v1/admin/users/:id/billing/move-to-org - Move user to organization
*
* Workspaces:
* GET /api/v1/admin/workspaces - List all workspaces
@@ -36,7 +35,7 @@
* 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 member to organization
* 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

View File

@@ -13,13 +13,16 @@
*
* Add a user to an organization with full billing logic.
* Handles Pro usage snapshot and subscription cancellation like the invitation flow.
* If user is already a member, updates their role if different.
*
* Body:
* - userId: string - User ID to add
* - role: string - Role ('admin' | 'member')
* - skipBillingLogic?: boolean - Skip Pro cancellation (default: false)
*
* Response: AdminSingleResponse<AdminMember>
* Response: AdminSingleResponse<AdminMember & {
* action: 'created' | 'updated' | 'already_member',
* billingActions: { proUsageSnapshotted, proCancelledAtPeriodEnd }
* }>
*/
import { db } from '@sim/db'
@@ -129,8 +132,6 @@ export const POST = withAdminAuthParams<RouteParams>(async (request, context) =>
return badRequestResponse('role must be "admin" or "member"')
}
const skipBillingLogic = body.skipBillingLogic === true
const [orgData] = await db
.select({ id: organization.id, name: organization.name })
.from(organization)
@@ -151,11 +152,71 @@ export const POST = withAdminAuthParams<RouteParams>(async (request, context) =>
return notFoundResponse('User')
}
const [existingMember] = await db
.select({
id: member.id,
role: member.role,
createdAt: member.createdAt,
organizationId: member.organizationId,
})
.from(member)
.where(eq(member.userId, body.userId))
.limit(1)
if (existingMember) {
if (existingMember.organizationId === organizationId) {
if (existingMember.role !== body.role) {
await db.update(member).set({ role: body.role }).where(eq(member.id, existingMember.id))
logger.info(
`Admin API: Updated user ${body.userId} role in organization ${organizationId}`,
{
previousRole: existingMember.role,
newRole: body.role,
}
)
return singleResponse({
id: existingMember.id,
userId: body.userId,
organizationId,
role: body.role,
createdAt: existingMember.createdAt.toISOString(),
userName: userData.name,
userEmail: userData.email,
action: 'updated' as const,
billingActions: {
proUsageSnapshotted: false,
proCancelledAtPeriodEnd: false,
},
})
}
return singleResponse({
id: existingMember.id,
userId: body.userId,
organizationId,
role: existingMember.role,
createdAt: existingMember.createdAt.toISOString(),
userName: userData.name,
userEmail: userData.email,
action: 'already_member' as const,
billingActions: {
proUsageSnapshotted: false,
proCancelledAtPeriodEnd: false,
},
})
}
return badRequestResponse(
`User is already a member of another organization. Users can only belong to one organization at a time.`
)
}
const result = await addUserToOrganization({
userId: body.userId,
organizationId,
role: body.role,
skipBillingLogic,
})
if (!result.success) {
@@ -176,11 +237,11 @@ export const POST = withAdminAuthParams<RouteParams>(async (request, context) =>
role: body.role,
memberId: result.memberId,
billingActions: result.billingActions,
skipBillingLogic,
})
return singleResponse({
...data,
action: 'created' as const,
billingActions: {
proUsageSnapshotted: result.billingActions.proUsageSnapshotted,
proCancelledAtPeriodEnd: result.billingActions.proCancelledAtPeriodEnd,

View File

@@ -12,7 +12,6 @@
* Body:
* - name?: string - Organization name
* - slug?: string - Organization slug
* - orgUsageLimit?: number - Usage limit (null to clear)
*
* Response: AdminSingleResponse<AdminOrganization>
*/
@@ -112,14 +111,10 @@ export const PATCH = withAdminAuthParams<RouteParams>(async (request, context) =
updateData.slug = body.slug.trim()
}
if (body.orgUsageLimit !== undefined) {
if (body.orgUsageLimit === null) {
updateData.orgUsageLimit = null
} else if (typeof body.orgUsageLimit === 'number' && body.orgUsageLimit >= 0) {
updateData.orgUsageLimit = body.orgUsageLimit.toFixed(2)
} else {
return badRequestResponse('orgUsageLimit must be a non-negative number or null')
}
if (Object.keys(updateData).length === 1) {
return badRequestResponse(
'No valid fields to update. Use /billing endpoint for orgUsageLimit.'
)
}
const [updated] = await db

View File

@@ -7,17 +7,18 @@
*
* PATCH /api/v1/admin/organizations/[id]/seats
*
* Update organization seat count (for admin override of enterprise seats).
* Update organization seat count with Stripe sync (matches user flow).
*
* Body:
* - seats: number - New seat count (for enterprise metadata.seats)
* - seats: number - New seat count (positive integer)
*
* Response: AdminSingleResponse<{ success: true, seats: number }>
* 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'
@@ -105,11 +106,14 @@ export const PATCH = withAdminAuthParams<RouteParams>(async (request, context) =
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: body.seats,
seats: newSeatCount,
}
await db
@@ -118,23 +122,72 @@ export const PATCH = withAdminAuthParams<RouteParams>(async (request, context) =
.where(eq(subscription.id, subData.id))
logger.info(`Admin API: Updated enterprise seats for organization ${organizationId}`, {
seats: body.seats,
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: body.seats })
.set({ seats: newSeatCount })
.where(eq(subscription.id, subData.id))
logger.info(`Admin API: Updated team seats for organization ${organizationId}`, {
seats: body.seats,
logger.info(`Admin API: Updated seats for organization ${organizationId}`, {
seats: newSeatCount,
plan: subData.plan,
})
}
return singleResponse({
success: true,
seats: body.seats,
seats: newSeatCount,
plan: subData.plan,
stripeUpdated,
})
} catch (error) {
logger.error('Admin API: Failed to update organization seats', { error, organizationId })

View File

@@ -1,160 +0,0 @@
/**
* POST /api/v1/admin/users/[id]/billing/move-to-org
*
* Move a user to an organization with full billing logic.
* Enforces single-org constraint, handles Pro snapshot/cancellation.
*
* Body:
* - organizationId: string - Target organization ID
* - role?: string - Role in organization ('admin' | 'member'), defaults to 'member'
* - skipBillingLogic?: boolean - Skip Pro handling (default: false)
*
* Response: AdminSingleResponse<{
* success: true,
* memberId: string,
* organizationId: string,
* role: string,
* action: 'created' | 'updated' | 'already_member',
* billingActions: { proUsageSnapshotted, proCancelledAtPeriodEnd }
* }>
*/
import { db } from '@sim/db'
import { member, organization, user } from '@sim/db/schema'
import { eq } from 'drizzle-orm'
import { addUserToOrganization } from '@/lib/billing/organizations/membership'
import { createLogger } from '@/lib/logs/console/logger'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
badRequestResponse,
internalErrorResponse,
notFoundResponse,
singleResponse,
} from '@/app/api/v1/admin/responses'
const logger = createLogger('AdminUserMoveToOrgAPI')
interface RouteParams {
id: string
}
export const POST = withAdminAuthParams<RouteParams>(async (request, context) => {
const { id: userId } = await context.params
try {
const body = await request.json()
if (!body.organizationId || typeof body.organizationId !== 'string') {
return badRequestResponse('organizationId is required')
}
const role = body.role || 'member'
if (!['admin', 'member'].includes(role)) {
return badRequestResponse('role must be "admin" or "member"')
}
const skipBillingLogic = body.skipBillingLogic === true
const [userData] = await db
.select({ id: user.id })
.from(user)
.where(eq(user.id, userId))
.limit(1)
if (!userData) {
return notFoundResponse('User')
}
const [orgData] = await db
.select({ id: organization.id, name: organization.name })
.from(organization)
.where(eq(organization.id, body.organizationId))
.limit(1)
if (!orgData) {
return notFoundResponse('Organization')
}
const existingMemberships = await db
.select({ id: member.id, organizationId: member.organizationId, role: member.role })
.from(member)
.where(eq(member.userId, userId))
const existingInThisOrg = existingMemberships.find(
(m) => m.organizationId === body.organizationId
)
if (existingInThisOrg) {
if (existingInThisOrg.role !== role) {
await db.update(member).set({ role }).where(eq(member.id, existingInThisOrg.id))
logger.info(
`Admin API: Updated user ${userId} role in organization ${body.organizationId}`,
{
previousRole: existingInThisOrg.role,
newRole: role,
}
)
return singleResponse({
success: true,
memberId: existingInThisOrg.id,
organizationId: body.organizationId,
organizationName: orgData.name,
role,
action: 'updated',
billingActions: {
proUsageSnapshotted: false,
proCancelledAtPeriodEnd: false,
},
})
}
return singleResponse({
success: true,
memberId: existingInThisOrg.id,
organizationId: body.organizationId,
organizationName: orgData.name,
role: existingInThisOrg.role,
action: 'already_member',
billingActions: {
proUsageSnapshotted: false,
proCancelledAtPeriodEnd: false,
},
})
}
const result = await addUserToOrganization({
userId,
organizationId: body.organizationId,
role,
skipBillingLogic,
})
if (!result.success) {
return badRequestResponse(result.error || 'Failed to move user to organization')
}
logger.info(`Admin API: Moved user ${userId} to organization ${body.organizationId}`, {
role,
memberId: result.memberId,
billingActions: result.billingActions,
skipBillingLogic,
})
return singleResponse({
success: true,
memberId: result.memberId,
organizationId: body.organizationId,
organizationName: orgData.name,
role,
action: 'created',
billingActions: {
proUsageSnapshotted: result.billingActions.proUsageSnapshotted,
proCancelledAtPeriodEnd: result.billingActions.proCancelledAtPeriodEnd,
},
})
} catch (error) {
logger.error('Admin API: Failed to move user to organization', { error, userId })
return internalErrorResponse('Failed to move user to organization')
}
})

View File

@@ -1,7 +1,7 @@
import { checkServerSideUsageLimits } from '@/lib/billing'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import { getEffectiveCurrentPeriodCost } from '@/lib/billing/core/usage'
import { RateLimiter } from '@/services/queue'
import { RateLimiter } from '@/lib/core/rate-limiter'
export interface UserLimits {
workflowExecutionRateLimit: {

View File

@@ -1,8 +1,8 @@
import { type NextRequest, NextResponse } from 'next/server'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import { RateLimiter } from '@/lib/core/rate-limiter/rate-limiter'
import { createLogger } from '@/lib/logs/console/logger'
import { authenticateV1Request } from '@/app/api/v1/auth'
import { RateLimiter } from '@/services/queue/RateLimiter'
const logger = createLogger('V1Middleware')
const rateLimiter = new RateLimiter()

View File

@@ -0,0 +1,66 @@
import { nanoid } from 'nanoid'
import { type NextRequest, NextResponse } from 'next/server'
import { verifyCronAuth } from '@/lib/auth/internal'
import { acquireLock, releaseLock } from '@/lib/core/config/redis'
import { createLogger } from '@/lib/logs/console/logger'
import { pollRssWebhooks } from '@/lib/webhooks/rss-polling-service'
const logger = createLogger('RssPollingAPI')
export const dynamic = 'force-dynamic'
export const maxDuration = 180 // Allow up to 3 minutes for polling to complete
const LOCK_KEY = 'rss-polling-lock'
const LOCK_TTL_SECONDS = 180 // Same as maxDuration (3 min)
export async function GET(request: NextRequest) {
const requestId = nanoid()
logger.info(`RSS webhook polling triggered (${requestId})`)
let lockValue: string | undefined
try {
const authError = verifyCronAuth(request, 'RSS webhook polling')
if (authError) {
return authError
}
lockValue = requestId
const locked = await acquireLock(LOCK_KEY, lockValue, LOCK_TTL_SECONDS)
if (!locked) {
return NextResponse.json(
{
success: true,
message: 'Polling already in progress skipped',
requestId,
status: 'skip',
},
{ status: 202 }
)
}
const results = await pollRssWebhooks()
return NextResponse.json({
success: true,
message: 'RSS polling completed',
requestId,
status: 'completed',
...results,
})
} catch (error) {
logger.error(`Error during RSS polling (${requestId}):`, error)
return NextResponse.json(
{
success: false,
message: 'RSS polling failed',
error: error instanceof Error ? error.message : 'Unknown error',
requestId,
},
{ status: 500 }
)
} finally {
await releaseLock(LOCK_KEY).catch(() => {})
}
}

View File

@@ -544,6 +544,43 @@ export async function POST(request: NextRequest) {
}
// --- End Outlook specific logic ---
// --- RSS webhook setup ---
if (savedWebhook && provider === 'rss') {
logger.info(`[${requestId}] RSS provider detected. Setting up RSS webhook configuration.`)
try {
const { configureRssPolling } = await import('@/lib/webhooks/utils.server')
const success = await configureRssPolling(savedWebhook, requestId)
if (!success) {
logger.error(`[${requestId}] Failed to configure RSS polling, rolling back webhook`)
await db.delete(webhook).where(eq(webhook.id, savedWebhook.id))
return NextResponse.json(
{
error: 'Failed to configure RSS polling',
details: 'Please try again',
},
{ status: 500 }
)
}
logger.info(`[${requestId}] Successfully configured RSS polling`)
} catch (err) {
logger.error(
`[${requestId}] Error setting up RSS webhook configuration, rolling back webhook`,
err
)
await db.delete(webhook).where(eq(webhook.id, savedWebhook.id))
return NextResponse.json(
{
error: 'Failed to configure RSS webhook',
details: err instanceof Error ? err.message : 'Unknown error',
},
{ status: 500 }
)
}
}
// --- End RSS specific logic ---
const status = targetWebhookId ? 200 : 201
return NextResponse.json({ webhook: savedWebhook }, { status })
} catch (error: any) {

View File

@@ -69,7 +69,11 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
let preprocessError: NextResponse | null = null
try {
preprocessError = await checkWebhookPreprocessing(foundWorkflow, foundWebhook, requestId)
// Test webhooks skip deployment check but still enforce rate limits and usage limits
// They run on live/draft state to allow testing before deployment
preprocessError = await checkWebhookPreprocessing(foundWorkflow, foundWebhook, requestId, {
isTestMode: true,
})
if (preprocessError) {
return preprocessError
}

View File

@@ -140,7 +140,7 @@ vi.mock('@/lib/workspaces/utils', async () => {
}
})
vi.mock('@/services/queue', () => ({
vi.mock('@/lib/core/rate-limiter', () => ({
RateLimiter: vi.fn().mockImplementation(() => ({
checkRateLimit: vi.fn().mockResolvedValue({
allowed: true,

View File

@@ -395,8 +395,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
triggerType: loggingTriggerType,
executionId,
requestId,
checkRateLimit: false, // Manual executions bypass rate limits
checkDeployment: !shouldUseDraftState, // Check deployment unless using draft
checkDeployment: !shouldUseDraftState,
loggingSession,
})

View File

@@ -451,15 +451,6 @@ function RunSkipButtons({
const actionInProgressRef = useRef(false)
const { setToolCallState, addAutoAllowedTool } = useCopilotStore()
const instance = getClientTool(toolCall.id)
const interruptDisplays = instance?.getInterruptDisplays?.()
const isIntegration = isIntegrationTool(toolCall.name)
// For integration tools: Allow, Always Allow, Skip
// For client tools with interrupts: Run, Skip (or custom labels)
const acceptLabel = isIntegration ? 'Allow' : interruptDisplays?.accept?.text || 'Run'
const rejectLabel = interruptDisplays?.reject?.text || 'Skip'
const onRun = async () => {
// Prevent race condition - check ref synchronously
if (actionInProgressRef.current) return
@@ -507,20 +498,19 @@ function RunSkipButtons({
if (buttonsHidden) return null
// Standardized buttons for all interrupt tools: Allow, Always Allow, Skip
return (
<div className='mt-[12px] flex gap-[6px]'>
<Button onClick={onRun} disabled={isProcessing} variant='primary'>
{isProcessing ? <Loader2 className='mr-1 h-3 w-3 animate-spin' /> : null}
{acceptLabel}
Allow
</Button>
<Button onClick={onAlwaysAllow} disabled={isProcessing} variant='default'>
{isProcessing ? <Loader2 className='mr-1 h-3 w-3 animate-spin' /> : null}
Always Allow
</Button>
{isIntegration && (
<Button onClick={onAlwaysAllow} disabled={isProcessing} variant='default'>
{isProcessing ? <Loader2 className='mr-1 h-3 w-3 animate-spin' /> : null}
Always Allow
</Button>
)}
<Button onClick={onSkip} disabled={isProcessing} variant='default'>
{rejectLabel}
Skip
</Button>
</div>
)

View File

@@ -190,15 +190,25 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
/**
* Cleanup on component unmount (page refresh, navigation, etc.)
* Uses a ref to track sending state to avoid stale closure issues
* Note: Parent workflow.tsx also has useStreamCleanup for page-level cleanup
*/
const isSendingRef = useRef(isSendingMessage)
isSendingRef.current = isSendingMessage
const abortMessageRef = useRef(abortMessage)
abortMessageRef.current = abortMessage
useEffect(() => {
return () => {
if (isSendingMessage) {
abortMessage()
// Use refs to check current values, not stale closure values
if (isSendingRef.current) {
abortMessageRef.current()
logger.info('Aborted active message streaming due to component unmount')
}
}
}, [isSendingMessage, abortMessage])
// Empty deps - only run cleanup on actual unmount, not on re-renders
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
/**
* Container-level click capture to cancel edit mode when clicking outside the current edit area

View File

@@ -363,6 +363,8 @@ export function Dropdown({
)
}, [multiSelect, multiValues, optionMap])
const isSearchable = subBlockId === 'operation'
return (
<Combobox
options={comboboxOptions}
@@ -375,7 +377,6 @@ export function Dropdown({
editable={false}
onOpenChange={(open) => {
if (open) {
// Fetch options when the dropdown is opened to ensure freshness
void fetchOptionsIfNeeded()
}
}}
@@ -383,6 +384,8 @@ export function Dropdown({
multiSelect={multiSelect}
isLoading={isLoadingOptions}
error={fetchError}
searchable={isSearchable}
searchPlaceholder='Search operations...'
/>
)
}

View File

@@ -58,6 +58,7 @@ interface CustomToolModalProps {
export interface CustomTool {
type: 'custom-tool'
id?: string
title: string
name: string
description: string
@@ -433,6 +434,8 @@ try {
}
}
let savedToolId: string | undefined
if (isEditing && toolIdToUpdate) {
await updateToolMutation.mutateAsync({
workspaceId,
@@ -443,8 +446,9 @@ try {
code: functionCode || '',
},
})
savedToolId = toolIdToUpdate
} else {
await createToolMutation.mutateAsync({
const result = await createToolMutation.mutateAsync({
workspaceId,
tool: {
title: name,
@@ -452,10 +456,13 @@ try {
code: functionCode || '',
},
})
// Get the ID from the created tool
savedToolId = result?.[0]?.id
}
const customTool: CustomTool = {
type: 'custom-tool',
id: savedToolId,
title: name,
name,
description,

View File

@@ -51,7 +51,10 @@ import {
import { ToolCredentialSelector } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tool-credential-selector'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import { getAllBlocks } from '@/blocks'
import { useCustomTools } from '@/hooks/queries/custom-tools'
import {
type CustomTool as CustomToolDefinition,
useCustomTools,
} from '@/hooks/queries/custom-tools'
import { useWorkflows } from '@/hooks/queries/workflows'
import { useMcpTools } from '@/hooks/use-mcp-tools'
import { getProviderFromModel, supportsToolUsageControl } from '@/providers/utils'
@@ -85,21 +88,28 @@ interface ToolInputProps {
/**
* Represents a tool selected and configured in the workflow
*
* @remarks
* For custom tools (new format), we only store: type, customToolId, usageControl, isExpanded.
* Everything else (title, schema, code) is loaded dynamically from the database.
* Legacy custom tools with inline schema/code are still supported for backwards compatibility.
*/
interface StoredTool {
/** Block type identifier */
type: string
/** Display title for the tool */
title: string
/** Direct tool ID for execution */
toolId: string
/** Parameter values configured by the user */
params: Record<string, string>
/** Display title for the tool (optional for new custom tool format) */
title?: string
/** Direct tool ID for execution (optional for new custom tool format) */
toolId?: string
/** Parameter values configured by the user (optional for new custom tool format) */
params?: Record<string, string>
/** Whether the tool details are expanded in UI */
isExpanded?: boolean
/** Tool schema for custom tools */
/** Database ID for custom tools (new format - reference only) */
customToolId?: string
/** Tool schema for custom tools (legacy format - inline) */
schema?: any
/** Implementation code for custom tools */
/** Implementation code for custom tools (legacy format - inline) */
code?: string
/** Selected operation for multi-operation tools */
operation?: string
@@ -107,6 +117,55 @@ interface StoredTool {
usageControl?: 'auto' | 'force' | 'none'
}
/**
* Resolves a custom tool reference to its full definition
*
* @remarks
* Custom tools can be stored in two formats:
* 1. Reference-only (new): { customToolId: "...", usageControl: "auto" } - loads from database
* 2. Inline (legacy): { schema: {...}, code: "..." } - uses embedded definition
*
* @param storedTool - The stored tool reference
* @param customToolsList - List of custom tools from the database
* @returns The resolved custom tool with full definition, or null if not found
*/
function resolveCustomToolFromReference(
storedTool: StoredTool,
customToolsList: CustomToolDefinition[]
): { schema: any; code: string; title: string } | null {
// If the tool has a customToolId (new reference format), look it up
if (storedTool.customToolId) {
const customTool = customToolsList.find((t) => t.id === storedTool.customToolId)
if (customTool) {
return {
schema: customTool.schema,
code: customTool.code,
title: customTool.title,
}
}
// If not found by ID, fall through to try other methods
logger.warn(`Custom tool not found by ID: ${storedTool.customToolId}`)
}
// Legacy format: inline schema and code
if (storedTool.schema && storedTool.code !== undefined) {
return {
schema: storedTool.schema,
code: storedTool.code,
title: storedTool.title || '',
}
}
return null
}
/**
* Checks if a stored custom tool is a reference-only format (no inline code/schema)
*/
function isCustomToolReference(storedTool: StoredTool): boolean {
return storedTool.type === 'custom-tool' && !!storedTool.customToolId && !storedTool.code
}
/**
* Generic sync wrapper that synchronizes store values with local component state
*
@@ -954,18 +1013,25 @@ export function ToolInput({
(customTool: CustomTool) => {
if (isPreview || disabled) return
const customToolId = `custom-${customTool.schema?.function?.name || 'unknown'}`
const newTool: StoredTool = {
type: 'custom-tool',
title: customTool.title,
toolId: customToolId,
params: {},
isExpanded: true,
schema: customTool.schema,
code: customTool.code || '',
usageControl: 'auto',
}
// If the tool has a database ID, store minimal reference
// Otherwise, store inline for backwards compatibility
const newTool: StoredTool = customTool.id
? {
type: 'custom-tool',
customToolId: customTool.id,
usageControl: 'auto',
isExpanded: true,
}
: {
type: 'custom-tool',
title: customTool.title,
toolId: `custom-${customTool.schema?.function?.name || 'unknown'}`,
params: {},
isExpanded: true,
schema: customTool.schema,
code: customTool.code || '',
usageControl: 'auto',
}
setStoreValue([...selectedTools.map((tool) => ({ ...tool, isExpanded: false })), newTool])
},
@@ -975,12 +1041,21 @@ export function ToolInput({
const handleEditCustomTool = useCallback(
(toolIndex: number) => {
const tool = selectedTools[toolIndex]
if (tool.type !== 'custom-tool' || !tool.schema) return
if (tool.type !== 'custom-tool') return
// For reference-only tools, we need to resolve the tool from the database
// The modal will handle loading the full definition
const resolved = resolveCustomToolFromReference(tool, customTools)
if (!resolved && !tool.schema) {
// Tool not found and no inline definition - can't edit
logger.warn('Cannot edit custom tool - not found in database and no inline definition')
return
}
setEditingToolIndex(toolIndex)
setCustomToolModalOpen(true)
},
[selectedTools]
[selectedTools, customTools]
)
const handleSaveCustomTool = useCallback(
@@ -988,17 +1063,26 @@ export function ToolInput({
if (isPreview || disabled) return
if (editingToolIndex !== null) {
const existingTool = selectedTools[editingToolIndex]
// If the tool has a database ID, convert to minimal reference format
// Otherwise keep inline for backwards compatibility
const updatedTool: StoredTool = customTool.id
? {
type: 'custom-tool',
customToolId: customTool.id,
usageControl: existingTool.usageControl || 'auto',
isExpanded: existingTool.isExpanded,
}
: {
...existingTool,
title: customTool.title,
schema: customTool.schema,
code: customTool.code || '',
}
setStoreValue(
selectedTools.map((tool, index) =>
index === editingToolIndex
? {
...tool,
title: customTool.title,
schema: customTool.schema,
code: customTool.code || '',
}
: tool
)
selectedTools.map((tool, index) => (index === editingToolIndex ? updatedTool : tool))
)
setEditingToolIndex(null)
} else {
@@ -1019,8 +1103,15 @@ export function ToolInput({
const handleDeleteTool = useCallback(
(toolId: string) => {
const updatedTools = selectedTools.filter((tool) => {
if (tool.type !== 'custom-tool') return true
// New format: check customToolId
if (tool.customToolId === toolId) {
return false
}
// Legacy format: check by function name match
if (
tool.type === 'custom-tool' &&
tool.schema?.function?.name &&
customTools.some(
(customTool) =>
@@ -1083,12 +1174,12 @@ export function ToolInput({
const initialParams = initializeToolParams(newToolId, toolParams.userInputParameters, blockId)
const oldToolParams = getToolParametersConfig(tool.toolId, tool.type)
const oldToolParams = tool.toolId ? getToolParametersConfig(tool.toolId, tool.type) : null
const oldParamIds = new Set(oldToolParams?.userInputParameters.map((p) => p.id) || [])
const newParamIds = new Set(toolParams.userInputParameters.map((p) => p.id))
const preservedParams: Record<string, string> = {}
Object.entries(tool.params).forEach(([paramId, value]) => {
Object.entries(tool.params || {}).forEach(([paramId, value]) => {
if (newParamIds.has(paramId) && value) {
preservedParams[paramId] = value
}
@@ -1666,15 +1757,13 @@ export function ToolInput({
key={customTool.id}
value={customTool.title}
onSelect={() => {
// Store minimal reference - only ID, usageControl, isExpanded
// Everything else (title, toolId, params) loaded dynamically
const newTool: StoredTool = {
type: 'custom-tool',
title: customTool.title,
toolId: `custom-${customTool.schema?.function?.name || 'unknown'}`,
params: {},
isExpanded: true,
schema: customTool.schema,
code: customTool.code,
customToolId: customTool.id,
usageControl: 'auto',
isExpanded: true,
}
setStoreValue([
@@ -1757,22 +1846,33 @@ export function ToolInput({
// Get the current tool ID (may change based on operation)
const currentToolId =
!isCustomTool && !isMcpTool
? getToolIdForOperation(tool.type, tool.operation) || tool.toolId
: tool.toolId
? getToolIdForOperation(tool.type, tool.operation) || tool.toolId || ''
: tool.toolId || ''
// Get tool parameters using the new utility with block type for UI components
const toolParams =
!isCustomTool && !isMcpTool ? getToolParametersConfig(currentToolId, tool.type) : null
!isCustomTool && !isMcpTool && currentToolId
? getToolParametersConfig(currentToolId, tool.type)
: null
// For custom tools, extract parameters from schema
// For custom tools, resolve from reference (new format) or use inline (legacy)
const resolvedCustomTool = isCustomTool
? resolveCustomToolFromReference(tool, customTools)
: null
// Derive title and schema from resolved tool or inline data
const customToolTitle = isCustomTool
? tool.title || resolvedCustomTool?.title || 'Unknown Tool'
: null
const customToolSchema = isCustomTool ? tool.schema || resolvedCustomTool?.schema : null
const customToolParams =
isCustomTool && tool.schema && tool.schema.function?.parameters?.properties
? Object.entries(tool.schema.function.parameters.properties || {}).map(
isCustomTool && customToolSchema?.function?.parameters?.properties
? Object.entries(customToolSchema.function.parameters.properties || {}).map(
([paramId, param]: [string, any]) => ({
id: paramId,
type: param.type || 'string',
description: param.description || '',
visibility: (tool.schema.function.parameters.required?.includes(paramId)
visibility: (customToolSchema.function.parameters.required?.includes(paramId)
? 'user-or-llm'
: 'user-only') as 'user-or-llm' | 'user-only' | 'llm-only' | 'hidden',
})
@@ -1805,9 +1905,12 @@ export function ToolInput({
: toolParams?.userInputParameters || []
// Check if tool requires OAuth
const requiresOAuth = !isCustomTool && !isMcpTool && toolRequiresOAuth(currentToolId)
const requiresOAuth =
!isCustomTool && !isMcpTool && currentToolId && toolRequiresOAuth(currentToolId)
const oauthConfig =
!isCustomTool && !isMcpTool ? getToolOAuthConfig(currentToolId) : null
!isCustomTool && !isMcpTool && currentToolId
? getToolOAuthConfig(currentToolId)
: null
// Tools are always expandable so users can access the interface
const isExpandedForDisplay = isPreview
@@ -1816,7 +1919,7 @@ export function ToolInput({
return (
<div
key={`${tool.toolId}-${toolIndex}`}
key={`${tool.customToolId || tool.toolId || toolIndex}-${toolIndex}`}
className={cn(
'group relative flex flex-col overflow-visible rounded-[4px] border border-[var(--border-strong)] bg-[var(--surface-4)] transition-all duration-200 ease-in-out',
draggedIndex === toolIndex ? 'scale-95 opacity-40' : '',
@@ -1872,7 +1975,7 @@ export function ToolInput({
)}
</div>
<span className='truncate font-medium text-[13px] text-[var(--text-primary)]'>
{tool.title}
{isCustomTool ? customToolTitle : tool.title}
</span>
</div>
<div className='flex flex-shrink-0 items-center gap-[8px]'>
@@ -1968,7 +2071,7 @@ export function ToolInput({
</div>
<div className='w-full min-w-0'>
<ToolCredentialSelector
value={tool.params.credential || ''}
value={tool.params?.credential || ''}
onChange={(value) => handleParamChange(toolIndex, 'credential', value)}
provider={oauthConfig.provider as OAuthProvider}
requiredScopes={
@@ -2016,7 +2119,7 @@ export function ToolInput({
const firstParam = params[0] as ToolParameterConfig
const groupValue = JSON.stringify(
params.reduce(
(acc, p) => ({ ...acc, [p.id]: tool.params[p.id] === 'true' }),
(acc, p) => ({ ...acc, [p.id]: tool.params?.[p.id] === 'true' }),
{}
)
)
@@ -2075,10 +2178,10 @@ export function ToolInput({
{param.uiComponent ? (
renderParameterInput(
param,
tool.params[param.id] || '',
tool.params?.[param.id] || '',
(value) => handleParamChange(toolIndex, param.id, value),
toolIndex,
tool.params
tool.params || {}
)
) : (
<ShortInput
@@ -2094,7 +2197,7 @@ export function ToolInput({
type: 'short-input',
title: param.id,
}}
value={tool.params[param.id] || ''}
value={tool.params?.[param.id] || ''}
onChange={(value) =>
handleParamChange(toolIndex, param.id, value)
}
@@ -2267,15 +2370,35 @@ export function ToolInput({
blockId={blockId}
initialValues={
editingToolIndex !== null && selectedTools[editingToolIndex]?.type === 'custom-tool'
? {
id: customTools.find(
(tool) =>
tool.schema?.function?.name ===
selectedTools[editingToolIndex].schema?.function?.name
)?.id,
schema: selectedTools[editingToolIndex].schema,
code: selectedTools[editingToolIndex].code || '',
}
? (() => {
const storedTool = selectedTools[editingToolIndex]
// Resolve the full tool definition from reference or inline
const resolved = resolveCustomToolFromReference(storedTool, customTools)
if (resolved) {
// Find the database ID
const dbTool = storedTool.customToolId
? customTools.find((t) => t.id === storedTool.customToolId)
: customTools.find(
(t) => t.schema?.function?.name === resolved.schema?.function?.name
)
return {
id: dbTool?.id,
schema: resolved.schema,
code: resolved.code,
}
}
// Fallback to inline definition (legacy format)
return {
id: customTools.find(
(tool) => tool.schema?.function?.name === storedTool.schema?.function?.name
)?.id,
schema: storedTool.schema,
code: storedTool.code || '',
}
})()
: undefined
}
/>

View File

@@ -1,7 +1,9 @@
import { v4 as uuidv4 } from 'uuid'
import type { ExecutionResult, StreamingExecution } from '@/executor/types'
import { useExecutionStore } from '@/stores/execution/store'
import { useTerminalConsoleStore } from '@/stores/terminal'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
export interface WorkflowExecutionOptions {
workflowInput?: any
@@ -26,6 +28,11 @@ export async function executeWorkflowWithFullLogging(
const executionId = options.executionId || uuidv4()
const { addConsole } = useTerminalConsoleStore.getState()
const { setActiveBlocks, setBlockRunStatus, setEdgeRunStatus } = useExecutionStore.getState()
const workflowEdges = useWorkflowStore.getState().edges
// Track active blocks for pulsing animation
const activeBlocksSet = new Set<string>()
const payload: any = {
input: options.workflowInput,
@@ -81,7 +88,29 @@ export async function executeWorkflowWithFullLogging(
const event = JSON.parse(data)
switch (event.type) {
case 'block:started': {
// Add block to active set for pulsing animation
activeBlocksSet.add(event.data.blockId)
setActiveBlocks(new Set(activeBlocksSet))
// Track edges that led to this block as soon as execution starts
const incomingEdges = workflowEdges.filter(
(edge) => edge.target === event.data.blockId
)
incomingEdges.forEach((edge) => {
setEdgeRunStatus(edge.id, 'success')
})
break
}
case 'block:completed':
// Remove block from active set
activeBlocksSet.delete(event.data.blockId)
setActiveBlocks(new Set(activeBlocksSet))
// Track successful block execution in run path
setBlockRunStatus(event.data.blockId, 'success')
addConsole({
input: event.data.input || {},
output: event.data.output,
@@ -105,6 +134,13 @@ export async function executeWorkflowWithFullLogging(
break
case 'block:error':
// Remove block from active set
activeBlocksSet.delete(event.data.blockId)
setActiveBlocks(new Set(activeBlocksSet))
// Track failed block execution in run path
setBlockRunStatus(event.data.blockId, 'error')
addConsole({
input: event.data.input || {},
output: {},
@@ -147,6 +183,8 @@ export async function executeWorkflowWithFullLogging(
}
} finally {
reader.releaseLock()
// Clear active blocks when execution ends
setActiveBlocks(new Set())
}
return executionResult

View File

@@ -2,7 +2,7 @@
import { useMemo, useState } from 'react'
import { Check, Copy, Plus, Search } from 'lucide-react'
import { Button } from '@/components/emcn'
import { Button, Input as EmcnInput } from '@/components/emcn'
import {
Modal,
ModalBody,
@@ -28,7 +28,11 @@ function CopilotKeySkeleton() {
return (
<div className='flex items-center justify-between gap-[12px]'>
<div className='flex min-w-0 flex-col justify-center gap-[1px]'>
<Skeleton className='h-[13px] w-[120px]' />
<div className='flex items-center gap-[6px]'>
<Skeleton className='h-5 w-[80px]' />
<Skeleton className='h-5 w-[140px]' />
</div>
<Skeleton className='h-5 w-[100px]' />
</div>
<Skeleton className='h-[26px] w-[48px] rounded-[6px]' />
</div>
@@ -44,28 +48,50 @@ export function Copilot() {
const generateKey = useGenerateCopilotKey()
const deleteKeyMutation = useDeleteCopilotKey()
const [showNewKeyDialog, setShowNewKeyDialog] = useState(false)
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false)
const [newKeyName, setNewKeyName] = useState('')
const [newKey, setNewKey] = useState<string | null>(null)
const [showNewKeyDialog, setShowNewKeyDialog] = useState(false)
const [copySuccess, setCopySuccess] = useState(false)
const [deleteKey, setDeleteKey] = useState<CopilotKey | null>(null)
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [searchTerm, setSearchTerm] = useState('')
const [createError, setCreateError] = useState<string | null>(null)
const filteredKeys = useMemo(() => {
if (!searchTerm.trim()) return keys
const term = searchTerm.toLowerCase()
return keys.filter((key) => key.displayKey?.toLowerCase().includes(term))
return keys.filter(
(key) =>
key.name?.toLowerCase().includes(term) || key.displayKey?.toLowerCase().includes(term)
)
}, [keys, searchTerm])
const onGenerate = async () => {
const handleCreateKey = async () => {
if (!newKeyName.trim()) return
const trimmedName = newKeyName.trim()
const isDuplicate = keys.some((k) => k.name === trimmedName)
if (isDuplicate) {
setCreateError(
`A Copilot API key named "${trimmedName}" already exists. Please choose a different name.`
)
return
}
setCreateError(null)
try {
const data = await generateKey.mutateAsync()
const data = await generateKey.mutateAsync({ name: trimmedName })
if (data?.key?.apiKey) {
setNewKey(data.key.apiKey)
setShowNewKeyDialog(true)
setNewKeyName('')
setCreateError(null)
setIsCreateDialogOpen(false)
}
} catch (error) {
logger.error('Failed to generate copilot API key', { error })
setCreateError('Failed to create API key. Please check your connection and try again.')
}
}
@@ -88,6 +114,15 @@ export function Copilot() {
}
}
const formatDate = (dateString?: string | null) => {
if (!dateString) return 'Never'
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
})
}
const hasKeys = keys.length > 0
const showEmptyState = !hasKeys
const showNoResults = searchTerm.trim() && filteredKeys.length === 0 && keys.length > 0
@@ -103,20 +138,23 @@ export function Copilot() {
strokeWidth={2}
/>
<Input
placeholder='Search keys...'
placeholder='Search API keys...'
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className='h-auto flex-1 border-0 bg-transparent p-0 font-base leading-none placeholder:text-[var(--text-tertiary)] focus-visible:ring-0 focus-visible:ring-offset-0'
/>
</div>
<Button
onClick={onGenerate}
onClick={() => {
setIsCreateDialogOpen(true)
setCreateError(null)
}}
variant='primary'
disabled={isLoading || generateKey.isPending}
disabled={isLoading}
className='!bg-[var(--brand-tertiary-2)] !text-[var(--text-inverse)] hover:!bg-[var(--brand-tertiary-2)]/90 disabled:cursor-not-allowed disabled:opacity-60'
>
<Plus className='mr-[6px] h-[13px] w-[13px]' />
{generateKey.isPending ? 'Creating...' : 'Create'}
Create
</Button>
</div>
@@ -137,7 +175,15 @@ export function Copilot() {
{filteredKeys.map((key) => (
<div key={key.id} className='flex items-center justify-between gap-[12px]'>
<div className='flex min-w-0 flex-col justify-center gap-[1px]'>
<p className='truncate text-[13px] text-[var(--text-primary)]'>
<div className='flex items-center gap-[6px]'>
<span className='max-w-[280px] truncate font-medium text-[14px]'>
{key.name || 'Unnamed Key'}
</span>
<span className='text-[13px] text-[var(--text-secondary)]'>
(last used: {formatDate(key.lastUsed).toLowerCase()})
</span>
</div>
<p className='truncate text-[13px] text-[var(--text-muted)]'>
{key.displayKey}
</p>
</div>
@@ -155,7 +201,7 @@ export function Copilot() {
))}
{showNoResults && (
<div className='py-[16px] text-center text-[13px] text-[var(--text-muted)]'>
No keys found matching "{searchTerm}"
No API keys found matching "{searchTerm}"
</div>
)}
</div>
@@ -163,6 +209,60 @@ export function Copilot() {
</div>
</div>
{/* Create API Key Dialog */}
<Modal open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
<ModalContent className='w-[400px]'>
<ModalHeader>Create new API key</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-tertiary)]'>
This key will allow access to Copilot features. Make sure to copy it after creation as
you won't be able to see it again.
</p>
<div className='mt-[16px] flex flex-col gap-[8px]'>
<p className='font-medium text-[13px] text-[var(--text-secondary)]'>
Enter a name for your API key to help you identify it later.
</p>
<EmcnInput
value={newKeyName}
onChange={(e) => {
setNewKeyName(e.target.value)
if (createError) setCreateError(null)
}}
placeholder='e.g., Development, Production'
className='h-9'
autoFocus
/>
{createError && (
<p className='text-[11px] text-[var(--text-error)] leading-tight'>{createError}</p>
)}
</div>
</ModalBody>
<ModalFooter>
<Button
variant='default'
onClick={() => {
setIsCreateDialogOpen(false)
setNewKeyName('')
setCreateError(null)
}}
>
Cancel
</Button>
<Button
type='button'
variant='primary'
onClick={handleCreateKey}
disabled={!newKeyName.trim() || generateKey.isPending}
className='!bg-[var(--brand-tertiary-2)] !text-[var(--text-inverse)] hover:!bg-[var(--brand-tertiary-2)]/90'
>
{generateKey.isPending ? 'Creating...' : 'Create'}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
{/* New API Key Dialog */}
<Modal
open={showNewKeyDialog}
@@ -215,7 +315,11 @@ export function Copilot() {
<ModalHeader>Delete API key</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-tertiary)]'>
Deleting this API key will immediately revoke access for any integrations using it.{' '}
Deleting{' '}
<span className='font-medium text-[var(--text-primary)]'>
{deleteKey?.name || 'Unnamed Key'}
</span>{' '}
will immediately revoke access for any integrations using it.{' '}
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
</p>
</ModalBody>

View File

@@ -134,7 +134,7 @@ async function executeWebhookJobInternal(
const loggingSession = new LoggingSession(
payload.workflowId,
executionId,
payload.provider || 'webhook',
payload.provider,
requestId
)

View File

@@ -11,13 +11,13 @@ import { and, eq, isNull, lte, or, sql } from 'drizzle-orm'
import { v4 as uuidv4 } from 'uuid'
import { checkUsageStatus } from '@/lib/billing/calculations/usage-monitor'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import { RateLimiter } from '@/lib/core/rate-limiter'
import { decryptSecret } from '@/lib/core/security/encryption'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { createLogger } from '@/lib/logs/console/logger'
import type { TraceSpan, WorkflowExecutionLog } from '@/lib/logs/types'
import { sendEmail } from '@/lib/messaging/email/mailer'
import type { AlertConfig } from '@/lib/notifications/alert-rules'
import { RateLimiter } from '@/services/queue'
const logger = createLogger('WorkspaceNotificationDelivery')

View File

@@ -0,0 +1,59 @@
import { DuckDuckGoIcon } from '@/components/icons'
import type { BlockConfig } from '@/blocks/types'
import type { DuckDuckGoResponse } from '@/tools/duckduckgo/types'
export const DuckDuckGoBlock: BlockConfig<DuckDuckGoResponse> = {
type: 'duckduckgo',
name: 'DuckDuckGo',
description: 'Search with DuckDuckGo',
longDescription:
'Search the web using DuckDuckGo Instant Answers API. Returns instant answers, abstracts, related topics, and more. Free to use without an API key.',
docsLink: 'https://docs.sim.ai/tools/duckduckgo',
category: 'tools',
bgColor: '#FFFFFF',
icon: DuckDuckGoIcon,
subBlocks: [
{
id: 'query',
title: 'Search Query',
type: 'long-input',
placeholder: 'Enter your search query...',
required: true,
},
{
id: 'noHtml',
title: 'Remove HTML',
type: 'switch',
defaultValue: true,
},
{
id: 'skipDisambig',
title: 'Skip Disambiguation',
type: 'switch',
},
],
tools: {
access: ['duckduckgo_search'],
config: {
tool: () => 'duckduckgo_search',
},
},
inputs: {
query: { type: 'string', description: 'Search query terms' },
noHtml: { type: 'boolean', description: 'Remove HTML from text in results' },
skipDisambig: { type: 'boolean', description: 'Skip disambiguation results' },
},
outputs: {
heading: { type: 'string', description: 'The heading/title of the instant answer' },
abstract: { type: 'string', description: 'A short abstract summary of the topic' },
abstractText: { type: 'string', description: 'Plain text version of the abstract' },
abstractSource: { type: 'string', description: 'The source of the abstract' },
abstractURL: { type: 'string', description: 'URL to the source of the abstract' },
image: { type: 'string', description: 'URL to an image related to the topic' },
answer: { type: 'string', description: 'Direct answer if available' },
answerType: { type: 'string', description: 'Type of the answer' },
type: { type: 'string', description: 'Response type (A, D, C, N, E)' },
relatedTopics: { type: 'json', description: 'Array of related topics' },
results: { type: 'json', description: 'Array of external link results' },
},
}

View File

@@ -0,0 +1,36 @@
import { RssIcon } from '@/components/icons'
import type { BlockConfig } from '@/blocks/types'
import { getTrigger } from '@/triggers'
export const RssBlock: BlockConfig = {
type: 'rss',
name: 'RSS Feed',
description: 'Monitor RSS feeds and trigger workflows when new items are published',
longDescription:
'Subscribe to any RSS or Atom feed and automatically trigger your workflow when new content is published. Perfect for monitoring blogs, news sites, podcasts, and any content that publishes an RSS feed.',
category: 'triggers',
bgColor: '#F97316',
icon: RssIcon,
triggerAllowed: true,
subBlocks: [...getTrigger('rss_poller').subBlocks],
tools: {
access: [], // Trigger-only for now
},
inputs: {},
outputs: {
title: { type: 'string', description: 'Item title' },
link: { type: 'string', description: 'Item link' },
pubDate: { type: 'string', description: 'Publication date' },
item: { type: 'json', description: 'Raw item object with all fields' },
feed: { type: 'json', description: 'Raw feed object with all fields' },
},
triggers: {
enabled: true,
available: ['rss_poller'],
},
}

View File

@@ -0,0 +1,306 @@
import { SftpIcon } from '@/components/icons'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types'
import type { SftpUploadResult } from '@/tools/sftp/types'
export const SftpBlock: BlockConfig<SftpUploadResult> = {
type: 'sftp',
name: 'SFTP',
description: 'Transfer files via SFTP (SSH File Transfer Protocol)',
longDescription:
'Upload, download, list, and manage files on remote servers via SFTP. Supports both password and private key authentication for secure file transfers.',
docsLink: 'https://docs.sim.ai/tools/sftp',
category: 'tools',
bgColor: '#2D3748',
icon: SftpIcon,
authMode: AuthMode.ApiKey,
subBlocks: [
{
id: 'operation',
title: 'Operation',
type: 'dropdown',
options: [
{ label: 'Upload Files', id: 'sftp_upload' },
{ label: 'Create File', id: 'sftp_create' },
{ label: 'Download File', id: 'sftp_download' },
{ label: 'List Directory', id: 'sftp_list' },
{ label: 'Delete File/Directory', id: 'sftp_delete' },
{ label: 'Create Directory', id: 'sftp_mkdir' },
],
value: () => 'sftp_upload',
},
{
id: 'host',
title: 'SFTP Host',
type: 'short-input',
placeholder: 'sftp.example.com or 192.168.1.100',
required: true,
},
{
id: 'port',
title: 'SFTP Port',
type: 'short-input',
placeholder: '22',
value: () => '22',
},
{
id: 'username',
title: 'Username',
type: 'short-input',
placeholder: 'sftp-user',
required: true,
},
{
id: 'authMethod',
title: 'Authentication Method',
type: 'dropdown',
options: [
{ label: 'Password', id: 'password' },
{ label: 'Private Key', id: 'privateKey' },
],
value: () => 'password',
},
{
id: 'password',
title: 'Password',
type: 'short-input',
password: true,
placeholder: 'Your SFTP password',
condition: { field: 'authMethod', value: 'password' },
},
{
id: 'privateKey',
title: 'Private Key',
type: 'code',
placeholder: '-----BEGIN OPENSSH PRIVATE KEY-----\n...',
condition: { field: 'authMethod', value: 'privateKey' },
},
{
id: 'passphrase',
title: 'Passphrase',
type: 'short-input',
password: true,
placeholder: 'Passphrase for encrypted key (optional)',
condition: { field: 'authMethod', value: 'privateKey' },
},
{
id: 'remotePath',
title: 'Remote Path',
type: 'short-input',
placeholder: '/home/user/uploads',
required: true,
},
{
id: 'uploadFiles',
title: 'Files to Upload',
type: 'file-upload',
canonicalParamId: 'files',
placeholder: 'Select files to upload',
mode: 'basic',
multiple: true,
required: false,
condition: { field: 'operation', value: 'sftp_upload' },
},
{
id: 'files',
title: 'File Reference',
type: 'short-input',
canonicalParamId: 'files',
placeholder: 'Reference file from previous block (e.g., {{block_name.file}})',
mode: 'advanced',
required: false,
condition: { field: 'operation', value: 'sftp_upload' },
},
{
id: 'overwrite',
title: 'Overwrite Existing Files',
type: 'switch',
defaultValue: true,
condition: { field: 'operation', value: ['sftp_upload', 'sftp_create'] },
},
{
id: 'permissions',
title: 'File Permissions',
type: 'short-input',
placeholder: '0644',
condition: { field: 'operation', value: ['sftp_upload', 'sftp_create'] },
mode: 'advanced',
},
{
id: 'fileName',
title: 'File Name',
type: 'short-input',
placeholder: 'filename.txt',
condition: { field: 'operation', value: 'sftp_create' },
required: true,
},
{
id: 'fileContent',
title: 'File Content',
type: 'code',
placeholder: 'Text content to write to the file',
condition: { field: 'operation', value: 'sftp_create' },
required: true,
},
{
id: 'encoding',
title: 'Output Encoding',
type: 'dropdown',
options: [
{ label: 'UTF-8 (Text)', id: 'utf-8' },
{ label: 'Base64 (Binary)', id: 'base64' },
],
value: () => 'utf-8',
condition: { field: 'operation', value: 'sftp_download' },
},
{
id: 'detailed',
title: 'Show Detailed Info',
type: 'switch',
defaultValue: false,
condition: { field: 'operation', value: 'sftp_list' },
},
{
id: 'recursive',
title: 'Recursive Delete',
type: 'switch',
defaultValue: false,
condition: { field: 'operation', value: 'sftp_delete' },
},
{
id: 'mkdirRecursive',
title: 'Create Parent Directories',
type: 'switch',
defaultValue: true,
condition: { field: 'operation', value: 'sftp_mkdir' },
},
],
tools: {
access: ['sftp_upload', 'sftp_download', 'sftp_list', 'sftp_delete', 'sftp_mkdir'],
config: {
tool: (params) => {
const operation = params.operation || 'sftp_upload'
if (operation === 'sftp_create') return 'sftp_upload'
return operation
},
params: (params) => {
const connectionConfig: Record<string, unknown> = {
host: params.host,
port:
typeof params.port === 'string' ? Number.parseInt(params.port, 10) : params.port || 22,
username: params.username,
}
if (params.authMethod === 'privateKey') {
connectionConfig.privateKey = params.privateKey
if (params.passphrase) {
connectionConfig.passphrase = params.passphrase
}
} else {
connectionConfig.password = params.password
}
const operation = params.operation || 'sftp_upload'
switch (operation) {
case 'sftp_upload':
return {
...connectionConfig,
remotePath: params.remotePath,
files: params.files,
overwrite: params.overwrite !== false,
permissions: params.permissions,
}
case 'sftp_create':
return {
...connectionConfig,
remotePath: params.remotePath,
fileContent: params.fileContent,
fileName: params.fileName,
overwrite: params.overwrite !== false,
permissions: params.permissions,
}
case 'sftp_download':
return {
...connectionConfig,
remotePath: params.remotePath,
encoding: params.encoding || 'utf-8',
}
case 'sftp_list':
return {
...connectionConfig,
remotePath: params.remotePath,
detailed: params.detailed || false,
}
case 'sftp_delete':
return {
...connectionConfig,
remotePath: params.remotePath,
recursive: params.recursive || false,
}
case 'sftp_mkdir':
return {
...connectionConfig,
remotePath: params.remotePath,
recursive: params.mkdirRecursive !== false,
}
default:
return {
...connectionConfig,
remotePath: params.remotePath,
}
}
},
},
},
inputs: {
operation: { type: 'string', description: 'SFTP operation to perform' },
host: { type: 'string', description: 'SFTP server hostname' },
port: { type: 'number', description: 'SFTP server port' },
username: { type: 'string', description: 'SFTP username' },
authMethod: { type: 'string', description: 'Authentication method (password or privateKey)' },
password: { type: 'string', description: 'Password for authentication' },
privateKey: { type: 'string', description: 'Private key for authentication' },
passphrase: { type: 'string', description: 'Passphrase for encrypted key' },
remotePath: { type: 'string', description: 'Remote path on the SFTP server' },
files: { type: 'array', description: 'Files to upload (UserFile array)' },
fileContent: { type: 'string', description: 'Direct content to upload' },
fileName: { type: 'string', description: 'File name for direct content' },
overwrite: { type: 'boolean', description: 'Overwrite existing files' },
permissions: { type: 'string', description: 'File permissions (e.g., 0644)' },
encoding: { type: 'string', description: 'Output encoding for download' },
detailed: { type: 'boolean', description: 'Show detailed file info' },
recursive: { type: 'boolean', description: 'Recursive delete' },
mkdirRecursive: { type: 'boolean', description: 'Create parent directories' },
},
outputs: {
success: { type: 'boolean', description: 'Whether the operation was successful' },
uploadedFiles: { type: 'json', description: 'Array of uploaded file details' },
fileName: { type: 'string', description: 'Downloaded file name' },
content: { type: 'string', description: 'Downloaded file content' },
size: { type: 'number', description: 'File size in bytes' },
entries: { type: 'json', description: 'Directory listing entries' },
count: { type: 'number', description: 'Number of entries' },
deletedPath: { type: 'string', description: 'Path that was deleted' },
createdPath: { type: 'string', description: 'Directory that was created' },
message: { type: 'string', description: 'Operation status message' },
error: { type: 'string', description: 'Error message if operation failed' },
},
}

View File

@@ -11,7 +11,7 @@ export const SmtpBlock: BlockConfig<SmtpSendMailResult> = {
'Send emails using any SMTP server (Gmail, Outlook, custom servers, etc.). Configure SMTP connection settings and send emails with full control over content, recipients, and attachments.',
docsLink: 'https://docs.sim.ai/tools/smtp',
category: 'tools',
bgColor: '#4A5568',
bgColor: '#2D3748',
icon: SmtpIcon,
authMode: AuthMode.ApiKey,

View File

@@ -18,6 +18,7 @@ import { CursorBlock } from '@/blocks/blocks/cursor'
import { DatadogBlock } from '@/blocks/blocks/datadog'
import { DiscordBlock } from '@/blocks/blocks/discord'
import { DropboxBlock } from '@/blocks/blocks/dropbox'
import { DuckDuckGoBlock } from '@/blocks/blocks/duckduckgo'
import { DynamoDBBlock } from '@/blocks/blocks/dynamodb'
import { ElasticsearchBlock } from '@/blocks/blocks/elasticsearch'
import { ElevenLabsBlock } from '@/blocks/blocks/elevenlabs'
@@ -88,6 +89,7 @@ import { RedditBlock } from '@/blocks/blocks/reddit'
import { ResendBlock } from '@/blocks/blocks/resend'
import { ResponseBlock } from '@/blocks/blocks/response'
import { RouterBlock } from '@/blocks/blocks/router'
import { RssBlock } from '@/blocks/blocks/rss'
import { S3Block } from '@/blocks/blocks/s3'
import { SalesforceBlock } from '@/blocks/blocks/salesforce'
import { ScheduleBlock } from '@/blocks/blocks/schedule'
@@ -95,6 +97,7 @@ import { SearchBlock } from '@/blocks/blocks/search'
import { SendGridBlock } from '@/blocks/blocks/sendgrid'
import { SentryBlock } from '@/blocks/blocks/sentry'
import { SerperBlock } from '@/blocks/blocks/serper'
import { SftpBlock } from '@/blocks/blocks/sftp'
import { SharepointBlock } from '@/blocks/blocks/sharepoint'
import { ShopifyBlock } from '@/blocks/blocks/shopify'
import { SlackBlock } from '@/blocks/blocks/slack'
@@ -157,6 +160,7 @@ export const registry: Record<string, BlockConfig> = {
datadog: DatadogBlock,
discord: DiscordBlock,
dropbox: DropboxBlock,
duckduckgo: DuckDuckGoBlock,
elevenlabs: ElevenLabsBlock,
elasticsearch: ElasticsearchBlock,
evaluator: EvaluatorBlock,
@@ -226,6 +230,7 @@ export const registry: Record<string, BlockConfig> = {
reddit: RedditBlock,
resend: ResendBlock,
response: ResponseBlock,
rss: RssBlock,
router: RouterBlock,
s3: S3Block,
salesforce: SalesforceBlock,
@@ -238,6 +243,7 @@ export const registry: Record<string, BlockConfig> = {
shopify: ShopifyBlock,
slack: SlackBlock,
smtp: SmtpBlock,
sftp: SftpBlock,
ssh: SSHBlock,
stagehand: StagehandBlock,
stagehand_agent: StagehandAgentBlock,

View File

@@ -3798,6 +3798,23 @@ export function SshIcon(props: SVGProps<SVGSVGElement>) {
)
}
export function SftpIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
{...props}
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 32 32'
width='32px'
height='32px'
>
<path
d='M 6 3 L 6 29 L 26 29 L 26 9.59375 L 25.71875 9.28125 L 19.71875 3.28125 L 19.40625 3 Z M 8 5 L 18 5 L 18 11 L 24 11 L 24 27 L 8 27 Z M 20 6.4375 L 22.5625 9 L 20 9 Z'
fill='currentColor'
/>
</svg>
)
}
export function ApifyIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
@@ -4129,3 +4146,56 @@ export function CursorIcon(props: SVGProps<SVGSVGElement>) {
</svg>
)
}
export function DuckDuckGoIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='-108 -108 216 216'>
<circle r='108' fill='#d53' />
<circle r='96' fill='none' stroke='#ffffff' stroke-width='7' />
<path
d='M-32-55C-62-48-51-6-51-6l19 93 7 3M-39-73h-8l11 4s-11 0-11 7c24-1 35 5 35 5'
fill='#ddd'
/>
<path d='M25 95S1 57 1 32c0-47 31-7 31-44S1-58 1-58c-15-19-44-15-44-15l7 4s-7 2-9 4 19-3 28 5c-37 3-31 33-31 33l21 120' />
<path d='M25-1l38-10c34 5-29 24-33 23C0 7 9 32 45 24s9 20-24 9C-26 20-1-3 25-1' fill='#fc0' />
<path
d='M15 78l2-3c22 8 23 11 22-9s0-20-23-3c0-5-13-3-15 0-21-9-23-12-22 2 2 29 1 24 21 14'
fill='#6b5'
/>
<path d='M-1 67v12c1 2 17 2 17-2s-8 3-13 1-2-13-2-13' fill='#4a4' />
<path
d='M-23-32c-5-6-18-1-15 7 1-4 8-10 15-7m32 0c1-6 11-7 14-1-4-2-10-2-14 1m-33 16a2 2 0 1 1 0 1m-8 3a7 7 0 1 0 0-1m52-6a2 2 0 1 1 0 1m-6 3a6 6 0 1 0 0-1'
fill='#148'
/>
</svg>
)
}
export function RssIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
{...props}
width='24'
height='24'
viewBox='0 0 24 24'
fill='none'
xmlns='http://www.w3.org/2000/svg'
>
<path
d='M4 11C6.38695 11 8.67613 11.9482 10.364 13.636C12.0518 15.3239 13 17.6131 13 20'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
/>
<path
d='M4 4C8.24346 4 12.3131 5.68571 15.3137 8.68629C18.3143 11.6869 20 15.7565 20 20'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
/>
<circle cx='5' cy='19' r='1' fill='currentColor' />
</svg>
)
}

View File

@@ -158,8 +158,8 @@ export const HTTP = {
export const AGENT = {
DEFAULT_MODEL: 'claude-sonnet-4-5',
DEFAULT_FUNCTION_TIMEOUT: 5000,
REQUEST_TIMEOUT: 120000,
DEFAULT_FUNCTION_TIMEOUT: 600000, // 10 minutes for custom tool code execution
REQUEST_TIMEOUT: 600000, // 10 minutes for LLM API requests
CUSTOM_TOOL_PREFIX: 'custom_',
} as const

View File

@@ -127,7 +127,8 @@ export class AgentBlockHandler implements BlockHandler {
})
.map(async (tool) => {
try {
if (tool.type === 'custom-tool' && tool.schema) {
// Handle custom tools - either inline (schema) or reference (customToolId)
if (tool.type === 'custom-tool' && (tool.schema || tool.customToolId)) {
return await this.createCustomTool(ctx, tool)
}
if (tool.type === 'mcp') {
@@ -151,24 +152,47 @@ export class AgentBlockHandler implements BlockHandler {
private async createCustomTool(ctx: ExecutionContext, tool: ToolInput): Promise<any> {
const userProvidedParams = tool.params || {}
// Resolve tool definition - either inline or from database reference
let schema = tool.schema
let code = tool.code
let title = tool.title
// If this is a reference-only tool (has customToolId but no schema), fetch from API
if (tool.customToolId && !schema) {
const resolved = await this.fetchCustomToolById(ctx, tool.customToolId)
if (!resolved) {
logger.error(`Custom tool not found: ${tool.customToolId}`)
return null
}
schema = resolved.schema
code = resolved.code
title = resolved.title
}
// Validate we have the required data
if (!schema?.function) {
logger.error('Custom tool missing schema:', { customToolId: tool.customToolId, title })
return null
}
const { filterSchemaForLLM, mergeToolParameters } = await import('@/tools/params')
const filteredSchema = filterSchemaForLLM(tool.schema.function.parameters, userProvidedParams)
const filteredSchema = filterSchemaForLLM(schema.function.parameters, userProvidedParams)
const toolId = `${AGENT.CUSTOM_TOOL_PREFIX}${tool.title}`
const toolId = `${AGENT.CUSTOM_TOOL_PREFIX}${title}`
const base: any = {
id: toolId,
name: tool.schema.function.name,
description: tool.schema.function.description || '',
name: schema.function.name,
description: schema.function.description || '',
params: userProvidedParams,
parameters: {
...filteredSchema,
type: tool.schema.function.parameters.type,
type: schema.function.parameters.type,
},
usageControl: tool.usageControl || 'auto',
}
if (tool.code) {
if (code) {
base.executeFunction = async (callParams: Record<string, any>) => {
const mergedParams = mergeToolParameters(userProvidedParams, callParams)
@@ -177,7 +201,7 @@ export class AgentBlockHandler implements BlockHandler {
const result = await executeTool(
'function_execute',
{
code: tool.code,
code,
...mergedParams,
timeout: tool.timeout ?? AGENT.DEFAULT_FUNCTION_TIMEOUT,
envVars: ctx.environmentVariables || {},
@@ -205,6 +229,78 @@ export class AgentBlockHandler implements BlockHandler {
return base
}
/**
* Fetches a custom tool definition from the database by ID
* Uses Zustand store in browser, API call on server
*/
private async fetchCustomToolById(
ctx: ExecutionContext,
customToolId: string
): Promise<{ schema: any; code: string; title: string } | null> {
// In browser, use the Zustand store which has cached data from React Query
if (typeof window !== 'undefined') {
try {
const { useCustomToolsStore } = await import('@/stores/custom-tools/store')
const tool = useCustomToolsStore.getState().getTool(customToolId)
if (tool) {
return {
schema: tool.schema,
code: tool.code || '',
title: tool.title,
}
}
logger.warn(`Custom tool not found in store: ${customToolId}`)
} catch (error) {
logger.error('Error accessing custom tools store:', { error })
}
}
// Server-side: fetch from API
try {
const headers = await buildAuthHeaders()
const params: Record<string, string> = {}
if (ctx.workspaceId) {
params.workspaceId = ctx.workspaceId
}
if (ctx.workflowId) {
params.workflowId = ctx.workflowId
}
const url = buildAPIUrl('/api/tools/custom', params)
const response = await fetch(url.toString(), {
method: 'GET',
headers,
})
if (!response.ok) {
logger.error(`Failed to fetch custom tools: ${response.status}`)
return null
}
const data = await response.json()
if (!data.data || !Array.isArray(data.data)) {
logger.error('Invalid custom tools API response')
return null
}
const tool = data.data.find((t: any) => t.id === customToolId)
if (!tool) {
logger.warn(`Custom tool not found by ID: ${customToolId}`)
return null
}
return {
schema: tool.schema,
code: tool.code || '',
title: tool.title,
}
} catch (error) {
logger.error('Error fetching custom tool:', { customToolId, error })
return null
}
}
private async createMcpTool(ctx: ExecutionContext, tool: ToolInput): Promise<any> {
const { serverId, toolName, ...userProvidedParams } = tool.params || {}

View File

@@ -32,6 +32,8 @@ export interface ToolInput {
timeout?: number
usageControl?: 'auto' | 'force' | 'none'
operation?: string
/** Database ID for custom tools (new reference format) */
customToolId?: string
}
export interface Message {

View File

@@ -245,9 +245,11 @@ export class LoopOrchestrator {
}
/**
* Evaluates the initial condition for while loops at the sentinel start.
* For while loops, the condition must be checked BEFORE the first iteration.
* If the condition is false, the loop body should be skipped entirely.
* Evaluates the initial condition for loops at the sentinel start.
* - For while loops, the condition must be checked BEFORE the first iteration.
* - For forEach loops, skip if the items array is empty.
* - For for loops, skip if maxIterations is 0.
* - For doWhile loops, always execute at least once.
*
* @returns true if the loop should execute, false if it should be skipped
*/
@@ -258,27 +260,47 @@ export class LoopOrchestrator {
return true
}
// Only while loops need an initial condition check
// - for/forEach: always execute based on iteration count/items
// - doWhile: always execute at least once, check condition after
// - while: check condition before first iteration
if (scope.loopType !== 'while') {
// forEach: skip if items array is empty
if (scope.loopType === 'forEach') {
if (!scope.items || scope.items.length === 0) {
logger.info('ForEach loop has empty items, skipping loop body', { loopId })
return false
}
return true
}
if (!scope.condition) {
logger.warn('No condition defined for while loop', { loopId })
return false
// for: skip if maxIterations is 0
if (scope.loopType === 'for') {
if (scope.maxIterations === 0) {
logger.info('For loop has 0 iterations, skipping loop body', { loopId })
return false
}
return true
}
const result = this.evaluateWhileCondition(ctx, scope.condition, scope)
logger.info('While loop initial condition evaluation', {
loopId,
condition: scope.condition,
result,
})
// doWhile: always execute at least once
if (scope.loopType === 'doWhile') {
return true
}
return result
// while: check condition before first iteration
if (scope.loopType === 'while') {
if (!scope.condition) {
logger.warn('No condition defined for while loop', { loopId })
return false
}
const result = this.evaluateWhileCondition(ctx, scope.condition, scope)
logger.info('While loop initial condition evaluation', {
loopId,
condition: scope.condition,
result,
})
return result
}
return true
}
shouldExecuteLoopNode(_ctx: ExecutionContext, _nodeId: string, _loopId: string): boolean {

View File

@@ -38,17 +38,42 @@ export function extractLoopIdFromSentinel(sentinelId: string): string | null {
/**
* Parse distribution items from parallel config
* Handles: arrays, JSON strings, and references
* Handles: arrays, JSON strings, objects, and references
* Note: References (starting with '<') cannot be resolved at DAG construction time,
* they must be resolved at runtime. This function returns [] for references.
*/
export function parseDistributionItems(config: SerializedParallel): any[] {
const rawItems = config.distribution ?? []
if (typeof rawItems === 'string' && rawItems.startsWith(REFERENCE.START)) {
return []
// Already an array - return as-is
if (Array.isArray(rawItems)) {
return rawItems
}
// Object - convert to entries array (consistent with loop forEach behavior)
if (typeof rawItems === 'object' && rawItems !== null) {
return Object.entries(rawItems)
}
// String handling
if (typeof rawItems === 'string') {
// References cannot be resolved at DAG construction time
if (rawItems.startsWith(REFERENCE.START) && rawItems.endsWith(REFERENCE.END)) {
return []
}
// Try to parse as JSON
try {
const normalizedJSON = rawItems.replace(/'/g, '"')
return JSON.parse(normalizedJSON)
const parsed = JSON.parse(normalizedJSON)
if (Array.isArray(parsed)) {
return parsed
}
// Parsed to non-array (e.g. object) - convert to entries
if (typeof parsed === 'object' && parsed !== null) {
return Object.entries(parsed)
}
return []
} catch (error) {
logger.error('Failed to parse distribution items', {
rawItems,
@@ -57,12 +82,7 @@ export function parseDistributionItems(config: SerializedParallel): any[] {
return []
}
}
if (Array.isArray(rawItems)) {
return rawItems
}
if (typeof rawItems === 'object' && rawItems !== null) {
return [rawItems]
}
return []
}
/**

View File

@@ -98,16 +98,43 @@ export class ParallelResolver implements Resolver {
return undefined
}
private getDistributionItems(parallelConfig: any): any {
let distributionItems = parallelConfig.distributionItems || parallelConfig.distribution || []
if (typeof distributionItems === 'string' && !distributionItems.startsWith('<')) {
private getDistributionItems(parallelConfig: any): any[] {
const rawItems = parallelConfig.distributionItems || parallelConfig.distribution || []
// Already an array - return as-is
if (Array.isArray(rawItems)) {
return rawItems
}
// Object - convert to entries array (consistent with loop forEach behavior)
if (typeof rawItems === 'object' && rawItems !== null) {
return Object.entries(rawItems)
}
// String handling
if (typeof rawItems === 'string') {
// Skip references - they should be resolved by the variable resolver
if (rawItems.startsWith('<')) {
return []
}
// Try to parse as JSON
try {
distributionItems = JSON.parse(distributionItems.replace(/'/g, '"'))
const parsed = JSON.parse(rawItems.replace(/'/g, '"'))
if (Array.isArray(parsed)) {
return parsed
}
// Parsed to non-array (e.g. object) - convert to entries
if (typeof parsed === 'object' && parsed !== null) {
return Object.entries(parsed)
}
return []
} catch (e) {
logger.error('Failed to parse distribution items', { distributionItems })
logger.error('Failed to parse distribution items', { rawItems })
return []
}
}
return distributionItems
return []
}
}

View File

@@ -18,6 +18,9 @@ export const copilotKeysKeys = {
export interface CopilotKey {
id: string
displayKey: string // "•••••{last6}"
name: string | null
createdAt: string | null
lastUsed: string | null
}
/**
@@ -58,6 +61,13 @@ export function useCopilotKeys() {
})
}
/**
* Generate key params
*/
interface GenerateKeyParams {
name: string
}
/**
* Generate new Copilot API key mutation
*/
@@ -65,12 +75,13 @@ export function useGenerateCopilotKey() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (): Promise<GenerateKeyResponse> => {
mutationFn: async ({ name }: GenerateKeyParams): Promise<GenerateKeyResponse> => {
const response = await fetch('/api/copilot/api-keys/generate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ name }),
})
if (!response.ok) {

View File

@@ -13,12 +13,11 @@ import {
oneTimeToken,
organization,
} from 'better-auth/plugins'
import { and, eq } from 'drizzle-orm'
import { eq } from 'drizzle-orm'
import { headers } from 'next/headers'
import Stripe from 'stripe'
import {
getEmailSubject,
renderInvitationEmail,
renderOTPEmail,
renderPasswordResetEmail,
} from '@/components/emails/render-email'
@@ -2068,79 +2067,6 @@ export const auth = betterAuth({
return hasTeamPlan
},
// Set a fixed membership limit of 50, but the actual limit will be enforced in the invitation flow
membershipLimit: 50,
// Validate seat limits before sending invitations
beforeInvite: async ({ organization }: { organization: { id: string } }) => {
const subscriptions = await db
.select()
.from(schema.subscription)
.where(
and(
eq(schema.subscription.referenceId, organization.id),
eq(schema.subscription.status, 'active')
)
)
const teamOrEnterpriseSubscription = subscriptions.find(
(sub) => sub.plan === 'team' || sub.plan === 'enterprise'
)
if (!teamOrEnterpriseSubscription) {
throw new Error('No active team or enterprise subscription for this organization')
}
const members = await db
.select()
.from(schema.member)
.where(eq(schema.member.organizationId, organization.id))
const pendingInvites = await db
.select()
.from(schema.invitation)
.where(
and(
eq(schema.invitation.organizationId, organization.id),
eq(schema.invitation.status, 'pending')
)
)
const totalCount = members.length + pendingInvites.length
const seatLimit = teamOrEnterpriseSubscription.seats || 1
if (totalCount >= seatLimit) {
throw new Error(`Organization has reached its seat limit of ${seatLimit}`)
}
},
sendInvitationEmail: async (data: any) => {
try {
const { invitation, organization, inviter } = data
const inviteUrl = `${getBaseUrl()}/invite/${invitation.id}`
const inviterName = inviter.user?.name || 'A team member'
const html = await renderInvitationEmail(
inviterName,
organization.name,
inviteUrl,
invitation.email
)
const result = await sendEmail({
to: invitation.email,
subject: `${inviterName} has invited you to join ${organization.name} on Sim`,
html,
from: getFromEmailAddress(),
emailType: 'transactional',
})
if (!result.success) {
logger.error('Failed to send organization invitation email:', result.message)
}
} catch (error) {
logger.error('Error sending invitation email', { error })
}
},
organizationCreation: {
afterCreate: async ({ organization, user }) => {
logger.info('[organizationCreation.afterCreate] Organization created', {

View File

@@ -1,7 +1,6 @@
import { db } from '@sim/db'
import { member, organization, userStats } from '@sim/db/schema'
import { and, eq, inArray } from 'drizzle-orm'
import { getOrganizationSubscription, getPlanPricing } from '@/lib/billing/core/billing'
import { getUserUsageLimit } from '@/lib/billing/core/usage'
import { isBillingEnabled } from '@/lib/core/config/environment'
import { createLogger } from '@/lib/logs/console/logger'
@@ -108,19 +107,10 @@ export async function checkUsageStatus(userId: string): Promise<UsageData> {
)
}
}
// Determine org cap
let orgCap = org.orgUsageLimit ? Number.parseFloat(String(org.orgUsageLimit)) : 0
// Determine org cap from orgUsageLimit (should always be set for team/enterprise)
const orgCap = org.orgUsageLimit ? Number.parseFloat(String(org.orgUsageLimit)) : 0
if (!orgCap || Number.isNaN(orgCap)) {
// Fall back to minimum billing amount from Stripe subscription
const orgSub = await getOrganizationSubscription(org.id)
if (orgSub?.seats) {
const { basePrice } = getPlanPricing(orgSub.plan)
orgCap = (orgSub.seats ?? 0) * basePrice
} else {
// If no subscription, use team default
const { basePrice } = getPlanPricing('team')
orgCap = basePrice // Default to 1 seat minimum
}
logger.warn('Organization missing usage limit', { orgId: org.id })
}
if (pooledUsage >= orgCap) {
isExceeded = true

View File

@@ -22,6 +22,56 @@ import { getEmailPreferences } from '@/lib/messaging/email/unsubscribe'
const logger = createLogger('UsageManagement')
export interface OrgUsageLimitResult {
limit: number
minimum: number
}
/**
* Calculates the effective usage limit for a team or enterprise organization.
* - Enterprise: Uses orgUsageLimit directly (fixed pricing)
* - Team: Uses orgUsageLimit but never below seats × basePrice
*/
export async function getOrgUsageLimit(
organizationId: string,
plan: string,
seats: number | null
): Promise<OrgUsageLimitResult> {
const orgData = await db
.select({ orgUsageLimit: organization.orgUsageLimit })
.from(organization)
.where(eq(organization.id, organizationId))
.limit(1)
const configured =
orgData.length > 0 && orgData[0].orgUsageLimit
? Number.parseFloat(orgData[0].orgUsageLimit)
: null
if (plan === 'enterprise') {
// Enterprise: Use configured limit directly (no per-seat minimum)
if (configured !== null) {
return { limit: configured, minimum: configured }
}
logger.warn('Enterprise org missing usage limit', { orgId: organizationId })
return { limit: 0, minimum: 0 }
}
const { basePrice } = getPlanPricing(plan)
const minimum = (seats ?? 0) * basePrice
if (configured !== null) {
return { limit: Math.max(configured, minimum), minimum }
}
logger.warn('Team org missing usage limit, using seats × basePrice fallback', {
orgId: organizationId,
seats,
minimum,
})
return { limit: minimum, minimum }
}
/**
* Handle new user setup when they join the platform
* Creates userStats record with default free credits
@@ -87,22 +137,13 @@ export async function getUserUsageData(userId: string): Promise<UsageData> {
? Number.parseFloat(stats.currentUsageLimit)
: getFreeTierLimit()
} else {
// Team/Enterprise: Use organization limit but never below minimum (seats × cost per seat)
const orgData = await db
.select({ orgUsageLimit: organization.orgUsageLimit })
.from(organization)
.where(eq(organization.id, subscription.referenceId))
.limit(1)
const { basePrice } = getPlanPricing(subscription.plan)
const minimum = (subscription.seats ?? 0) * basePrice
if (orgData.length > 0 && orgData[0].orgUsageLimit) {
const configured = Number.parseFloat(orgData[0].orgUsageLimit)
limit = Math.max(configured, minimum)
} else {
limit = minimum
}
// Team/Enterprise: Use organization limit
const orgLimit = await getOrgUsageLimit(
subscription.referenceId,
subscription.plan,
subscription.seats
)
limit = orgLimit.limit
}
const percentUsed = limit > 0 ? Math.min((currentUsage / limit) * 100, 100) : 0
@@ -159,24 +200,15 @@ export async function getUserUsageLimitInfo(userId: string): Promise<UsageLimitI
minimumLimit = getPerUserMinimumLimit(subscription)
canEdit = canEditUsageLimit(subscription)
} else {
// Team/Enterprise: Use organization limits (users cannot edit)
const orgData = await db
.select({ orgUsageLimit: organization.orgUsageLimit })
.from(organization)
.where(eq(organization.id, subscription.referenceId))
.limit(1)
const { basePrice } = getPlanPricing(subscription.plan)
const minimum = (subscription.seats ?? 0) * basePrice
if (orgData.length > 0 && orgData[0].orgUsageLimit) {
const configured = Number.parseFloat(orgData[0].orgUsageLimit)
currentLimit = Math.max(configured, minimum)
} else {
currentLimit = minimum
}
minimumLimit = minimum
canEdit = false // Team/enterprise members cannot edit limits
// Team/Enterprise: Use organization limits
const orgLimit = await getOrgUsageLimit(
subscription.referenceId,
subscription.plan,
subscription.seats
)
currentLimit = orgLimit.limit
minimumLimit = orgLimit.minimum
canEdit = false
}
return {
@@ -323,27 +355,23 @@ export async function getUserUsageLimit(userId: string): Promise<number> {
return Number.parseFloat(userStatsQuery[0].currentUsageLimit)
}
// Team/Enterprise: Use organization limit but never below minimum
const orgData = await db
.select({ orgUsageLimit: organization.orgUsageLimit })
// Team/Enterprise: Verify org exists then use organization limit
const orgExists = await db
.select({ id: organization.id })
.from(organization)
.where(eq(organization.id, subscription.referenceId))
.limit(1)
if (orgData.length === 0) {
if (orgExists.length === 0) {
throw new Error(`Organization not found: ${subscription.referenceId} for user: ${userId}`)
}
if (orgData[0].orgUsageLimit) {
const configured = Number.parseFloat(orgData[0].orgUsageLimit)
const { basePrice } = getPlanPricing(subscription.plan)
const minimum = (subscription.seats ?? 0) * basePrice
return Math.max(configured, minimum)
}
// If org hasn't set a custom limit, use minimum (seats × cost per seat)
const { basePrice } = getPlanPricing(subscription.plan)
return (subscription.seats ?? 0) * basePrice
const orgLimit = await getOrgUsageLimit(
subscription.referenceId,
subscription.plan,
subscription.seats
)
return orgLimit.limit
}
/**

View File

@@ -1,6 +1,7 @@
import { db } from '@sim/db'
import * as schema from '@sim/db/schema'
import { and, eq } from 'drizzle-orm'
import { getPlanPricing } from '@/lib/billing/core/billing'
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
import { createLogger } from '@/lib/logs/console/logger'
@@ -145,11 +146,52 @@ export async function syncSubscriptionUsageLimits(subscription: SubscriptionData
plan: subscription.plan,
})
} else {
// Organization subscription - sync usage limits for all members
// Organization subscription - set org usage limit and sync member limits
const organizationId = subscription.referenceId
// Set orgUsageLimit for team plans (enterprise is set via webhook with custom pricing)
if (subscription.plan === 'team') {
const { basePrice } = getPlanPricing(subscription.plan)
const seats = subscription.seats ?? 1
const orgLimit = seats * basePrice
// Only set if not already set or if updating to a higher value based on seats
const orgData = await db
.select({ orgUsageLimit: schema.organization.orgUsageLimit })
.from(schema.organization)
.where(eq(schema.organization.id, organizationId))
.limit(1)
const currentLimit =
orgData.length > 0 && orgData[0].orgUsageLimit
? Number.parseFloat(orgData[0].orgUsageLimit)
: 0
// Update if no limit set, or if new seat-based minimum is higher
if (currentLimit < orgLimit) {
await db
.update(schema.organization)
.set({
orgUsageLimit: orgLimit.toFixed(2),
updatedAt: new Date(),
})
.where(eq(schema.organization.id, organizationId))
logger.info('Set organization usage limit for team plan', {
organizationId,
seats,
basePrice,
orgLimit,
previousLimit: currentLimit,
})
}
}
// Sync usage limits for all members
const members = await db
.select({ userId: schema.member.userId })
.from(schema.member)
.where(eq(schema.member.organizationId, subscription.referenceId))
.where(eq(schema.member.organizationId, organizationId))
if (members.length > 0) {
for (const member of members) {
@@ -158,7 +200,7 @@ export async function syncSubscriptionUsageLimits(subscription: SubscriptionData
} catch (memberError) {
logger.error('Failed to sync usage limits for organization member', {
userId: member.userId,
organizationId: subscription.referenceId,
organizationId,
subscriptionId: subscription.id,
error: memberError,
})
@@ -166,7 +208,7 @@ export async function syncSubscriptionUsageLimits(subscription: SubscriptionData
}
logger.info('Synced usage limits for organization members', {
organizationId: subscription.referenceId,
organizationId,
memberCount: members.length,
subscriptionId: subscription.id,
plan: subscription.plan,

View File

@@ -31,6 +31,7 @@ export const ToolIds = z.enum([
'check_deployment_status',
'navigate_ui',
'knowledge_base',
'manage_custom_tool',
])
export type ToolId = z.infer<typeof ToolIds>
@@ -187,6 +188,45 @@ export const ToolArgSchemas = {
}),
knowledge_base: KnowledgeBaseArgsSchema,
manage_custom_tool: z.object({
operation: z
.enum(['add', 'edit', 'delete'])
.describe('The operation to perform: add (create new), edit (update existing), or delete'),
toolId: z
.string()
.optional()
.describe(
'Required for edit and delete operations. The database ID of the custom tool (e.g., "0robnW7_JUVwZrDkq1mqj"). Use get_workflow_data with data_type "custom_tools" to get the list of tools and their IDs. Do NOT use the function name - use the actual "id" field from the tool.'
),
title: z
.string()
.optional()
.describe(
'The display title of the custom tool. Required for add. Should always be provided for edit/delete so the user knows which tool is being modified.'
),
schema: z
.object({
type: z.literal('function'),
function: z.object({
name: z.string().describe('The function name (camelCase, e.g. getWeather)'),
description: z.string().optional().describe('What the function does'),
parameters: z.object({
type: z.string(),
properties: z.record(z.any()),
required: z.array(z.string()).optional(),
}),
}),
})
.optional()
.describe('Required for add. The OpenAI function calling format schema.'),
code: z
.string()
.optional()
.describe(
'Required for add. The JavaScript function body code. Use {{ENV_VAR}} for environment variables and reference parameters directly by name.'
),
}),
} as const
export type ToolArgSchemaMap = typeof ToolArgSchemas
@@ -251,6 +291,7 @@ export const ToolSSESchemas = {
),
navigate_ui: toolCallSSEFor('navigate_ui', ToolArgSchemas.navigate_ui),
knowledge_base: toolCallSSEFor('knowledge_base', ToolArgSchemas.knowledge_base),
manage_custom_tool: toolCallSSEFor('manage_custom_tool', ToolArgSchemas.manage_custom_tool),
} as const
export type ToolSSESchemaMap = typeof ToolSSESchemas
@@ -471,6 +512,13 @@ export const ToolResultSchemas = {
navigated: z.boolean(),
}),
knowledge_base: KnowledgeBaseResultSchema,
manage_custom_tool: z.object({
success: z.boolean(),
operation: z.enum(['add', 'edit', 'delete']),
toolId: z.string().optional(),
title: z.string().optional(),
message: z.string().optional(),
}),
} as const
export type ToolResultSchemaMap = typeof ToolResultSchemas

View File

@@ -4,6 +4,12 @@ import { createLogger } from '@/lib/logs/console/logger'
const baseToolLogger = createLogger('BaseClientTool')
/** Default timeout for tool execution (5 minutes) */
const DEFAULT_TOOL_TIMEOUT_MS = 2 * 60 * 1000
/** Timeout for tools that run workflows (10 minutes) */
export const WORKFLOW_EXECUTION_TIMEOUT_MS = 10 * 60 * 1000
// Client tool call states used by the new runtime
export enum ClientToolCallState {
generating = 'generating',
@@ -52,6 +58,8 @@ export class BaseClientTool {
readonly name: string
protected state: ClientToolCallState
protected metadata: BaseClientToolMetadata
protected isMarkedComplete = false
protected timeoutMs: number = DEFAULT_TOOL_TIMEOUT_MS
constructor(toolCallId: string, name: string, metadata: BaseClientToolMetadata) {
this.toolCallId = toolCallId
@@ -60,14 +68,98 @@ export class BaseClientTool {
this.state = ClientToolCallState.generating
}
/**
* Set a custom timeout for this tool (in milliseconds)
*/
setTimeoutMs(ms: number): void {
this.timeoutMs = ms
}
/**
* Check if this tool has been marked complete
*/
hasBeenMarkedComplete(): boolean {
return this.isMarkedComplete
}
/**
* Ensure the tool is marked complete. If not already marked, marks it with error.
* This should be called in finally blocks to prevent leaked tool calls.
*/
async ensureMarkedComplete(
fallbackMessage = 'Tool execution did not complete properly'
): Promise<void> {
if (!this.isMarkedComplete) {
baseToolLogger.warn('Tool was not marked complete, marking with error', {
toolCallId: this.toolCallId,
toolName: this.name,
state: this.state,
})
await this.markToolComplete(500, fallbackMessage)
this.setState(ClientToolCallState.error)
}
}
/**
* Execute with timeout protection. Wraps the execution in a timeout and ensures
* markToolComplete is always called.
*/
async executeWithTimeout(executeFn: () => Promise<void>, timeoutMs?: number): Promise<void> {
const timeout = timeoutMs ?? this.timeoutMs
let timeoutId: NodeJS.Timeout | null = null
try {
await Promise.race([
executeFn(),
new Promise<never>((_, reject) => {
timeoutId = setTimeout(() => {
reject(new Error(`Tool execution timed out after ${timeout / 1000} seconds`))
}, timeout)
}),
])
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
baseToolLogger.error('Tool execution failed or timed out', {
toolCallId: this.toolCallId,
toolName: this.name,
error: message,
})
// Only mark complete if not already marked
if (!this.isMarkedComplete) {
await this.markToolComplete(500, message)
this.setState(ClientToolCallState.error)
}
} finally {
if (timeoutId) clearTimeout(timeoutId)
// Ensure tool is always marked complete
await this.ensureMarkedComplete()
}
}
// Intentionally left empty - specific tools can override
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async execute(_args?: Record<string, any>): Promise<void> {
return
}
// Mark a tool as complete on the server (proxies to server-side route)
/**
* Mark a tool as complete on the server (proxies to server-side route).
* Once called, the tool is considered complete and won't be marked again.
*/
async markToolComplete(status: number, message?: any, data?: any): Promise<boolean> {
// Prevent double-marking
if (this.isMarkedComplete) {
baseToolLogger.warn('markToolComplete called but tool already marked complete', {
toolCallId: this.toolCallId,
toolName: this.name,
existingState: this.state,
attemptedStatus: status,
})
return true
}
this.isMarkedComplete = true
try {
baseToolLogger.info('markToolComplete called', {
toolCallId: this.toolCallId,
@@ -78,6 +170,7 @@ export class BaseClientTool {
hasData: data !== undefined,
})
} catch {}
try {
const res = await fetch('/api/copilot/tools/mark-complete', {
method: 'POST',
@@ -104,7 +197,11 @@ export class BaseClientTool {
const json = (await res.json()) as { success?: boolean }
return json?.success === true
} catch (e) {
// Default failure path
// Default failure path - but tool is still marked complete locally
baseToolLogger.error('Failed to mark tool complete on server', {
toolCallId: this.toolCallId,
error: e instanceof Error ? e.message : String(e),
})
return false
}
}

View File

@@ -197,9 +197,15 @@ export class EditWorkflowClientTool extends BaseClientTool {
async execute(args?: EditWorkflowArgs): Promise<void> {
const logger = createLogger('EditWorkflowClientTool')
try {
// Use timeout protection to ensure tool always completes
await this.executeWithTimeout(async () => {
if (this.hasExecuted) {
logger.info('execute skipped (already executed)', { toolCallId: this.toolCallId })
// Even if skipped, ensure we mark complete
if (!this.hasBeenMarkedComplete()) {
await this.markToolComplete(200, 'Tool already executed')
}
return
}
this.hasExecuted = true
@@ -252,137 +258,136 @@ export class EditWorkflowClientTool extends BaseClientTool {
}
}
const res = await fetch('/api/copilot/execute-copilot-server-tool', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
toolName: 'edit_workflow',
payload: {
operations,
workflowId,
...(currentUserWorkflow ? { currentUserWorkflow } : {}),
},
}),
})
if (!res.ok) {
const errorText = await res.text().catch(() => '')
try {
const errorJson = JSON.parse(errorText)
throw new Error(errorJson.error || errorText || `Server error (${res.status})`)
} catch {
throw new Error(errorText || `Server error (${res.status})`)
}
}
// Fetch with AbortController for timeout support
const controller = new AbortController()
const fetchTimeout = setTimeout(() => controller.abort(), 60000) // 60s fetch timeout
const json = await res.json()
const parsed = ExecuteResponseSuccessSchema.parse(json)
const result = parsed.result as any
this.lastResult = result
logger.info('server result parsed', {
hasWorkflowState: !!result?.workflowState,
blocksCount: result?.workflowState
? Object.keys(result.workflowState.blocks || {}).length
: 0,
hasSkippedItems: !!result?.skippedItems,
skippedItemsCount: result?.skippedItems?.length || 0,
hasInputValidationErrors: !!result?.inputValidationErrors,
inputValidationErrorsCount: result?.inputValidationErrors?.length || 0,
})
// Log skipped items and validation errors for visibility
if (result?.skippedItems?.length > 0) {
logger.warn('Some operations were skipped during edit_workflow', {
skippedItems: result.skippedItems,
try {
const res = await fetch('/api/copilot/execute-copilot-server-tool', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
toolName: 'edit_workflow',
payload: {
operations,
workflowId,
...(currentUserWorkflow ? { currentUserWorkflow } : {}),
},
}),
signal: controller.signal,
})
}
if (result?.inputValidationErrors?.length > 0) {
logger.warn('Some inputs were rejected during edit_workflow', {
inputValidationErrors: result.inputValidationErrors,
})
}
// Update diff directly with workflow state - no YAML conversion needed!
// The diff engine may transform the workflow state (e.g., assign new IDs), so we must use
// the returned proposedState rather than the original result.workflowState
let actualDiffWorkflow: WorkflowState | null = null
clearTimeout(fetchTimeout)
if (result.workflowState) {
try {
if (!this.hasAppliedDiff) {
const diffStore = useWorkflowDiffStore.getState()
// setProposedChanges applies the state directly to the workflow store
await diffStore.setProposedChanges(result.workflowState)
logger.info('diff proposed changes set for edit_workflow with direct workflow state')
this.hasAppliedDiff = true
// Read back the applied state from the workflow store
const workflowStore = useWorkflowStore.getState()
actualDiffWorkflow = workflowStore.getWorkflowState()
} else {
logger.info('skipping diff apply (already applied)')
// If we already applied, read from workflow store
const workflowStore = useWorkflowStore.getState()
actualDiffWorkflow = workflowStore.getWorkflowState()
if (!res.ok) {
const errorText = await res.text().catch(() => '')
try {
const errorJson = JSON.parse(errorText)
throw new Error(errorJson.error || errorText || `Server error (${res.status})`)
} catch {
throw new Error(errorText || `Server error (${res.status})`)
}
} catch (e) {
logger.warn('Failed to set proposed changes in diff store', e as any)
throw new Error('Failed to create workflow diff')
}
} else {
throw new Error('No workflow state returned from server')
}
if (!actualDiffWorkflow) {
throw new Error('Failed to retrieve workflow from diff store after setting changes')
}
const json = await res.json()
const parsed = ExecuteResponseSuccessSchema.parse(json)
const result = parsed.result as any
this.lastResult = result
logger.info('server result parsed', {
hasWorkflowState: !!result?.workflowState,
blocksCount: result?.workflowState
? Object.keys(result.workflowState.blocks || {}).length
: 0,
hasSkippedItems: !!result?.skippedItems,
skippedItemsCount: result?.skippedItems?.length || 0,
hasInputValidationErrors: !!result?.inputValidationErrors,
inputValidationErrorsCount: result?.inputValidationErrors?.length || 0,
})
// Get the workflow state that was just applied, merge subblocks, and sanitize
// This matches what get_user_workflow would return (the true state after edits were applied)
const workflowJson = this.getSanitizedWorkflowJson(actualDiffWorkflow)
// Build sanitized data including workflow JSON and any skipped/validation info
const sanitizedData: Record<string, any> = {}
if (workflowJson) {
sanitizedData.userWorkflow = workflowJson
}
// Include skipped items and validation errors in the response for LLM feedback
if (result?.skippedItems?.length > 0) {
sanitizedData.skippedItems = result.skippedItems
sanitizedData.skippedItemsMessage = result.skippedItemsMessage
}
if (result?.inputValidationErrors?.length > 0) {
sanitizedData.inputValidationErrors = result.inputValidationErrors
sanitizedData.inputValidationMessage = result.inputValidationMessage
}
// Build a message that includes info about skipped items
let completeMessage = 'Workflow diff ready for review'
if (result?.skippedItems?.length > 0 || result?.inputValidationErrors?.length > 0) {
const parts: string[] = []
// Log skipped items and validation errors for visibility
if (result?.skippedItems?.length > 0) {
parts.push(`${result.skippedItems.length} operation(s) skipped`)
logger.warn('Some operations were skipped during edit_workflow', {
skippedItems: result.skippedItems,
})
}
if (result?.inputValidationErrors?.length > 0) {
parts.push(`${result.inputValidationErrors.length} input(s) rejected`)
logger.warn('Some inputs were rejected during edit_workflow', {
inputValidationErrors: result.inputValidationErrors,
})
}
completeMessage = `Workflow diff ready for review. Note: ${parts.join(', ')}.`
// Update diff directly with workflow state - no YAML conversion needed!
if (!result.workflowState) {
throw new Error('No workflow state returned from server')
}
let actualDiffWorkflow: WorkflowState | null = null
if (!this.hasAppliedDiff) {
const diffStore = useWorkflowDiffStore.getState()
// setProposedChanges applies the state optimistically to the workflow store
await diffStore.setProposedChanges(result.workflowState)
logger.info('diff proposed changes set for edit_workflow with direct workflow state')
this.hasAppliedDiff = true
}
// Read back the applied state from the workflow store
const workflowStore = useWorkflowStore.getState()
actualDiffWorkflow = workflowStore.getWorkflowState()
if (!actualDiffWorkflow) {
throw new Error('Failed to retrieve workflow state after applying changes')
}
// Get the workflow state that was just applied, merge subblocks, and sanitize
// This matches what get_user_workflow would return (the true state after edits were applied)
const workflowJson = this.getSanitizedWorkflowJson(actualDiffWorkflow)
// Build sanitized data including workflow JSON and any skipped/validation info
const sanitizedData: Record<string, any> = {}
if (workflowJson) {
sanitizedData.userWorkflow = workflowJson
}
// Include skipped items and validation errors in the response for LLM feedback
if (result?.skippedItems?.length > 0) {
sanitizedData.skippedItems = result.skippedItems
sanitizedData.skippedItemsMessage = result.skippedItemsMessage
}
if (result?.inputValidationErrors?.length > 0) {
sanitizedData.inputValidationErrors = result.inputValidationErrors
sanitizedData.inputValidationMessage = result.inputValidationMessage
}
// Build a message that includes info about skipped items
let completeMessage = 'Workflow diff ready for review'
if (result?.skippedItems?.length > 0 || result?.inputValidationErrors?.length > 0) {
const parts: string[] = []
if (result?.skippedItems?.length > 0) {
parts.push(`${result.skippedItems.length} operation(s) skipped`)
}
if (result?.inputValidationErrors?.length > 0) {
parts.push(`${result.inputValidationErrors.length} input(s) rejected`)
}
completeMessage = `Workflow diff ready for review. Note: ${parts.join(', ')}.`
}
// Mark complete early to unblock LLM stream
await this.markToolComplete(
200,
completeMessage,
Object.keys(sanitizedData).length > 0 ? sanitizedData : undefined
)
// Move into review state
this.setState(ClientToolCallState.review, { result })
} catch (fetchError: any) {
clearTimeout(fetchTimeout)
if (fetchError.name === 'AbortError') {
throw new Error('Server request timed out')
}
throw fetchError
}
// Mark complete early to unblock LLM stream
await this.markToolComplete(
200,
completeMessage,
Object.keys(sanitizedData).length > 0 ? sanitizedData : undefined
)
// Move into review state
this.setState(ClientToolCallState.review, { result })
} catch (error: any) {
const message = error instanceof Error ? error.message : String(error)
logger.error('execute error', { message })
await this.markToolComplete(500, message)
this.setState(ClientToolCallState.error)
}
})
}
}

View File

@@ -0,0 +1,387 @@
import { Check, Loader2, Plus, X, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import { createLogger } from '@/lib/logs/console/logger'
import { useCustomToolsStore } from '@/stores/custom-tools/store'
import { useCopilotStore } from '@/stores/panel/copilot/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
interface CustomToolSchema {
type: 'function'
function: {
name: string
description?: string
parameters: {
type: string
properties: Record<string, any>
required?: string[]
}
}
}
interface ManageCustomToolArgs {
operation: 'add' | 'edit' | 'delete'
toolId?: string
title?: string
schema?: CustomToolSchema
code?: string
}
const API_ENDPOINT = '/api/tools/custom'
/**
* Client tool for creating, editing, and deleting custom tools via the copilot.
*/
export class ManageCustomToolClientTool extends BaseClientTool {
static readonly id = 'manage_custom_tool'
private currentArgs?: ManageCustomToolArgs
constructor(toolCallId: string) {
super(toolCallId, ManageCustomToolClientTool.id, ManageCustomToolClientTool.metadata)
}
static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: {
text: 'Managing custom tool',
icon: Loader2,
},
[ClientToolCallState.pending]: { text: 'Manage custom tool?', icon: Plus },
[ClientToolCallState.executing]: { text: 'Managing custom tool', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Managed custom tool', icon: Check },
[ClientToolCallState.error]: { text: 'Failed to manage custom tool', icon: X },
[ClientToolCallState.aborted]: {
text: 'Aborted managing custom tool',
icon: XCircle,
},
[ClientToolCallState.rejected]: {
text: 'Skipped managing custom tool',
icon: XCircle,
},
},
interrupt: {
accept: { text: 'Allow', icon: Check },
reject: { text: 'Skip', icon: XCircle },
},
getDynamicText: (params, state) => {
const operation = params?.operation as 'add' | 'edit' | 'delete' | undefined
// Return undefined if no operation yet - use static defaults
if (!operation) return undefined
// Get tool name from params, or look it up from the store by toolId
let toolName = params?.title || params?.schema?.function?.name
if (!toolName && params?.toolId) {
try {
const tool = useCustomToolsStore.getState().getTool(params.toolId)
toolName = tool?.title || tool?.schema?.function?.name
} catch {
// Ignore errors accessing store
}
}
const getActionText = (verb: 'present' | 'past' | 'gerund') => {
switch (operation) {
case 'add':
return verb === 'present' ? 'Create' : verb === 'past' ? 'Created' : 'Creating'
case 'edit':
return verb === 'present' ? 'Edit' : verb === 'past' ? 'Edited' : 'Editing'
case 'delete':
return verb === 'present' ? 'Delete' : verb === 'past' ? 'Deleted' : 'Deleting'
}
}
// For add: only show tool name in past tense (success)
// For edit/delete: always show tool name
const shouldShowToolName = (currentState: ClientToolCallState) => {
if (operation === 'add') {
return currentState === ClientToolCallState.success
}
return true // edit and delete always show tool name
}
const nameText = shouldShowToolName(state) && toolName ? ` ${toolName}` : ' custom tool'
switch (state) {
case ClientToolCallState.success:
return `${getActionText('past')}${nameText}`
case ClientToolCallState.executing:
return `${getActionText('gerund')}${nameText}`
case ClientToolCallState.generating:
return `${getActionText('gerund')}${nameText}`
case ClientToolCallState.pending:
return `${getActionText('present')}${nameText}?`
case ClientToolCallState.error:
return `Failed to ${getActionText('present')?.toLowerCase()}${nameText}`
case ClientToolCallState.aborted:
return `Aborted ${getActionText('gerund')?.toLowerCase()}${nameText}`
case ClientToolCallState.rejected:
return `Skipped ${getActionText('gerund')?.toLowerCase()}${nameText}`
}
return undefined
},
}
/**
* Gets the tool call args from the copilot store (needed before execute() is called)
*/
private getArgsFromStore(): ManageCustomToolArgs | undefined {
try {
const { toolCallsById } = useCopilotStore.getState()
const toolCall = toolCallsById[this.toolCallId]
return (toolCall as any)?.params as ManageCustomToolArgs | undefined
} catch {
return undefined
}
}
/**
* Override getInterruptDisplays to only show confirmation for edit and delete operations.
* Add operations execute directly without confirmation.
*/
getInterruptDisplays(): BaseClientToolMetadata['interrupt'] | undefined {
// Try currentArgs first, then fall back to store (for when called before execute())
const args = this.currentArgs || this.getArgsFromStore()
const operation = args?.operation
if (operation === 'edit' || operation === 'delete') {
return this.metadata.interrupt
}
return undefined
}
async handleReject(): Promise<void> {
await super.handleReject()
this.setState(ClientToolCallState.rejected)
}
async handleAccept(args?: ManageCustomToolArgs): Promise<void> {
const logger = createLogger('ManageCustomToolClientTool')
try {
this.setState(ClientToolCallState.executing)
await this.executeOperation(args, logger)
} catch (e: any) {
logger.error('execute failed', { message: e?.message })
this.setState(ClientToolCallState.error)
await this.markToolComplete(500, e?.message || 'Failed to manage custom tool')
}
}
async execute(args?: ManageCustomToolArgs): Promise<void> {
this.currentArgs = args
// For add operation, execute directly without confirmation
// For edit/delete, the copilot store will check hasInterrupt() and wait for confirmation
if (args?.operation === 'add') {
await this.handleAccept(args)
}
// edit/delete will wait for user confirmation via handleAccept
}
/**
* Executes the custom tool operation (add, edit, or delete)
*/
private async executeOperation(
args: ManageCustomToolArgs | undefined,
logger: ReturnType<typeof createLogger>
): Promise<void> {
if (!args?.operation) {
throw new Error('Operation is required')
}
const { operation, toolId, title, schema, code } = args
// Get workspace ID from the workflow registry
const { hydration } = useWorkflowRegistry.getState()
const workspaceId = hydration.workspaceId
if (!workspaceId) {
throw new Error('No active workspace found')
}
logger.info(`Executing custom tool operation: ${operation}`, {
operation,
toolId,
title,
workspaceId,
})
switch (operation) {
case 'add':
await this.addCustomTool({ title, schema, code, workspaceId }, logger)
break
case 'edit':
await this.editCustomTool({ toolId, title, schema, code, workspaceId }, logger)
break
case 'delete':
await this.deleteCustomTool({ toolId, workspaceId }, logger)
break
default:
throw new Error(`Unknown operation: ${operation}`)
}
}
/**
* Creates a new custom tool
*/
private async addCustomTool(
params: {
title?: string
schema?: CustomToolSchema
code?: string
workspaceId: string
},
logger: ReturnType<typeof createLogger>
): Promise<void> {
const { title, schema, code, workspaceId } = params
if (!title) {
throw new Error('Title is required for adding a custom tool')
}
if (!schema) {
throw new Error('Schema is required for adding a custom tool')
}
if (!code) {
throw new Error('Code is required for adding a custom tool')
}
const response = await fetch(API_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
tools: [{ title, schema, code }],
workspaceId,
}),
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Failed to create custom tool')
}
if (!data.data || !Array.isArray(data.data) || data.data.length === 0) {
throw new Error('Invalid API response: missing tool data')
}
const createdTool = data.data[0]
logger.info(`Created custom tool: ${title}`, { toolId: createdTool.id })
this.setState(ClientToolCallState.success)
await this.markToolComplete(200, `Created custom tool "${title}"`, {
success: true,
operation: 'add',
toolId: createdTool.id,
title,
})
}
/**
* Updates an existing custom tool
*/
private async editCustomTool(
params: {
toolId?: string
title?: string
schema?: CustomToolSchema
code?: string
workspaceId: string
},
logger: ReturnType<typeof createLogger>
): Promise<void> {
const { toolId, title, schema, code, workspaceId } = params
if (!toolId) {
throw new Error('Tool ID is required for editing a custom tool')
}
// At least one of title, schema, or code must be provided
if (!title && !schema && !code) {
throw new Error('At least one of title, schema, or code must be provided for editing')
}
// We need to send the full tool data to the API for updates
// First, fetch the existing tool to merge with updates
const existingResponse = await fetch(`${API_ENDPOINT}?workspaceId=${workspaceId}`)
const existingData = await existingResponse.json()
if (!existingResponse.ok) {
throw new Error(existingData.error || 'Failed to fetch existing tools')
}
const existingTool = existingData.data?.find((t: any) => t.id === toolId)
if (!existingTool) {
throw new Error(`Tool with ID ${toolId} not found`)
}
// Merge updates with existing tool
const updatedTool = {
id: toolId,
title: title ?? existingTool.title,
schema: schema ?? existingTool.schema,
code: code ?? existingTool.code,
}
const response = await fetch(API_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
tools: [updatedTool],
workspaceId,
}),
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Failed to update custom tool')
}
logger.info(`Updated custom tool: ${updatedTool.title}`, { toolId })
this.setState(ClientToolCallState.success)
await this.markToolComplete(200, `Updated custom tool "${updatedTool.title}"`, {
success: true,
operation: 'edit',
toolId,
title: updatedTool.title,
})
}
/**
* Deletes a custom tool
*/
private async deleteCustomTool(
params: {
toolId?: string
workspaceId: string
},
logger: ReturnType<typeof createLogger>
): Promise<void> {
const { toolId, workspaceId } = params
if (!toolId) {
throw new Error('Tool ID is required for deleting a custom tool')
}
const url = `${API_ENDPOINT}?id=${toolId}&workspaceId=${workspaceId}`
const response = await fetch(url, {
method: 'DELETE',
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Failed to delete custom tool')
}
logger.info(`Deleted custom tool: ${toolId}`)
this.setState(ClientToolCallState.success)
await this.markToolComplete(200, `Deleted custom tool`, {
success: true,
operation: 'delete',
toolId,
})
}
}

View File

@@ -4,6 +4,7 @@ import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
WORKFLOW_EXECUTION_TIMEOUT_MS,
} from '@/lib/copilot/tools/client/base-tool'
import { createLogger } from '@/lib/logs/console/logger'
import { executeWorkflowWithFullLogging } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils'
@@ -74,7 +75,9 @@ export class RunWorkflowClientTool extends BaseClientTool {
async handleAccept(args?: RunWorkflowArgs): Promise<void> {
const logger = createLogger('RunWorkflowClientTool')
try {
// Use longer timeout for workflow execution (10 minutes)
await this.executeWithTimeout(async () => {
const params = args || {}
logger.debug('handleAccept() called', {
toolCallId: this.toolCallId,
@@ -124,60 +127,54 @@ export class RunWorkflowClientTool extends BaseClientTool {
toolCallId: this.toolCallId,
})
const result = await executeWorkflowWithFullLogging({
workflowInput,
executionId,
})
setIsExecuting(false)
// Determine success for both non-streaming and streaming executions
let succeeded = true
let errorMessage: string | undefined
try {
if (result && typeof result === 'object' && 'success' in (result as any)) {
succeeded = Boolean((result as any).success)
if (!succeeded) {
errorMessage = (result as any)?.error || (result as any)?.output?.error
}
} else if (
result &&
typeof result === 'object' &&
'execution' in (result as any) &&
(result as any).execution &&
typeof (result as any).execution === 'object'
) {
succeeded = Boolean((result as any).execution.success)
if (!succeeded) {
errorMessage =
(result as any).execution?.error || (result as any).execution?.output?.error
const result = await executeWorkflowWithFullLogging({
workflowInput,
executionId,
})
// Determine success for both non-streaming and streaming executions
let succeeded = true
let errorMessage: string | undefined
try {
if (result && typeof result === 'object' && 'success' in (result as any)) {
succeeded = Boolean((result as any).success)
if (!succeeded) {
errorMessage = (result as any)?.error || (result as any)?.output?.error
}
} else if (
result &&
typeof result === 'object' &&
'execution' in (result as any) &&
(result as any).execution &&
typeof (result as any).execution === 'object'
) {
succeeded = Boolean((result as any).execution.success)
if (!succeeded) {
errorMessage =
(result as any).execution?.error || (result as any).execution?.output?.error
}
}
} catch {}
if (succeeded) {
logger.debug('Workflow execution finished with success')
this.setState(ClientToolCallState.success)
await this.markToolComplete(
200,
`Workflow execution completed. Started at: ${executionStartTime}`
)
} else {
const msg = errorMessage || 'Workflow execution failed'
logger.error('Workflow execution finished with failure', { message: msg })
this.setState(ClientToolCallState.error)
await this.markToolComplete(500, msg)
}
} catch {}
if (succeeded) {
logger.debug('Workflow execution finished with success')
this.setState(ClientToolCallState.success)
await this.markToolComplete(
200,
`Workflow execution completed. Started at: ${executionStartTime}`
)
} else {
const msg = errorMessage || 'Workflow execution failed'
logger.error('Workflow execution finished with failure', { message: msg })
this.setState(ClientToolCallState.error)
await this.markToolComplete(500, msg)
} finally {
// Always clean up execution state
setIsExecuting(false)
}
} catch (error: any) {
const message = error instanceof Error ? error.message : String(error)
const failedDependency = typeof message === 'string' && /dependency/i.test(message)
const status = failedDependency ? 424 : 500
logger.error('Run workflow failed', { message })
this.setState(ClientToolCallState.error)
await this.markToolComplete(status, failedDependency ? undefined : message)
}
}, WORKFLOW_EXECUTION_TIMEOUT_MS)
}
async execute(args?: RunWorkflowArgs): Promise<void> {

View File

@@ -5,7 +5,7 @@ import { jwtDecode } from 'jwt-decode'
import { createPermissionError, verifyWorkflowAccess } from '@/lib/copilot/auth/permissions'
import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool'
import { generateRequestId } from '@/lib/core/utils/request'
import { getEnvironmentVariableKeys } from '@/lib/environment/utils'
import { getPersonalAndWorkspaceEnv } from '@/lib/environment/utils'
import { createLogger } from '@/lib/logs/console/logger'
import { getAllOAuthServices } from '@/lib/oauth/oauth'
import { refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils'
@@ -26,8 +26,13 @@ export const getCredentialsServerTool: BaseServerTool<GetCredentialsParams, any>
const authenticatedUserId = context.userId
let workspaceId: string | undefined
if (params?.workflowId) {
const { hasAccess } = await verifyWorkflowAccess(authenticatedUserId, params.workflowId)
const { hasAccess, workspaceId: wId } = await verifyWorkflowAccess(
authenticatedUserId,
params.workflowId
)
if (!hasAccess) {
const errorMessage = createPermissionError('access credentials in')
@@ -37,6 +42,8 @@ export const getCredentialsServerTool: BaseServerTool<GetCredentialsParams, any>
})
throw new Error(errorMessage)
}
workspaceId = wId
}
const userId = authenticatedUserId
@@ -122,14 +129,23 @@ export const getCredentialsServerTool: BaseServerTool<GetCredentialsParams, any>
baseProvider: service.baseProvider,
}))
// Fetch environment variables
const envResult = await getEnvironmentVariableKeys(userId)
// Fetch environment variables from both personal and workspace
const envResult = await getPersonalAndWorkspaceEnv(userId, workspaceId)
// Get all unique variable names from both personal and workspace
const personalVarNames = Object.keys(envResult.personalEncrypted)
const workspaceVarNames = Object.keys(envResult.workspaceEncrypted)
const allVarNames = [...new Set([...personalVarNames, ...workspaceVarNames])]
logger.info('Fetched credentials', {
userId,
workspaceId,
connectedCount: connectedCredentials.length,
notConnectedCount: notConnectedServices.length,
envVarCount: envResult.count,
personalEnvVarCount: personalVarNames.length,
workspaceEnvVarCount: workspaceVarNames.length,
totalEnvVarCount: allVarNames.length,
conflicts: envResult.conflicts,
})
return {
@@ -144,8 +160,11 @@ export const getCredentialsServerTool: BaseServerTool<GetCredentialsParams, any>
},
},
environment: {
variableNames: envResult.variableNames,
count: envResult.count,
variableNames: allVarNames,
count: allVarNames.length,
personalVariables: personalVarNames,
workspaceVariables: workspaceVarNames,
conflicts: envResult.conflicts,
},
}
},

View File

@@ -1,5 +1,5 @@
import { db } from '@sim/db'
import { environment } from '@sim/db/schema'
import { workspaceEnvironment } from '@sim/db/schema'
import { eq } from 'drizzle-orm'
import { z } from 'zod'
import { createPermissionError, verifyWorkflowAccess } from '@/lib/copilot/auth/permissions'
@@ -52,28 +52,33 @@ export const setEnvironmentVariablesServerTool: BaseServerTool<SetEnvironmentVar
const authenticatedUserId = context.userId
const { variables, workflowId } = params || ({} as SetEnvironmentVariablesParams)
if (workflowId) {
const { hasAccess } = await verifyWorkflowAccess(authenticatedUserId, workflowId)
if (!hasAccess) {
const errorMessage = createPermissionError('modify environment variables in')
logger.error('Unauthorized attempt to set environment variables', {
workflowId,
authenticatedUserId,
})
throw new Error(errorMessage)
}
if (!workflowId) {
throw new Error('workflowId is required to set workspace environment variables')
}
const userId = authenticatedUserId
const { hasAccess, workspaceId } = await verifyWorkflowAccess(authenticatedUserId, workflowId)
if (!hasAccess) {
const errorMessage = createPermissionError('modify environment variables in')
logger.error('Unauthorized attempt to set environment variables', {
workflowId,
authenticatedUserId,
})
throw new Error(errorMessage)
}
if (!workspaceId) {
throw new Error('Could not determine workspace for this workflow')
}
const normalized = normalizeVariables(variables || {})
const { variables: validatedVariables } = EnvVarSchema.parse({ variables: normalized })
// Fetch existing workspace environment variables
const existingData = await db
.select()
.from(environment)
.where(eq(environment.userId, userId))
.from(workspaceEnvironment)
.where(eq(workspaceEnvironment.workspaceId, workspaceId))
.limit(1)
const existingEncrypted = (existingData[0]?.variables as Record<string, string>) || {}
@@ -109,26 +114,36 @@ export const setEnvironmentVariablesServerTool: BaseServerTool<SetEnvironmentVar
const finalEncrypted = { ...existingEncrypted, ...newlyEncrypted }
// Save to workspace environment variables
await db
.insert(environment)
.insert(workspaceEnvironment)
.values({
id: crypto.randomUUID(),
userId,
workspaceId,
variables: finalEncrypted,
updatedAt: new Date(),
})
.onConflictDoUpdate({
target: [environment.userId],
target: [workspaceEnvironment.workspaceId],
set: { variables: finalEncrypted, updatedAt: new Date() },
})
logger.info('Saved workspace environment variables', {
workspaceId,
workflowId,
addedCount: added.length,
updatedCount: updated.length,
totalCount: Object.keys(finalEncrypted).length,
})
return {
message: `Successfully processed ${Object.keys(validatedVariables).length} environment variable(s): ${added.length} added, ${updated.length} updated`,
message: `Successfully processed ${Object.keys(validatedVariables).length} workspace environment variable(s): ${added.length} added, ${updated.length} updated`,
variableCount: Object.keys(validatedVariables).length,
variableNames: Object.keys(validatedVariables),
totalVariableCount: Object.keys(finalEncrypted).length,
addedVariables: added,
updatedVariables: updated,
workspaceId,
}
},
}

View File

@@ -645,19 +645,29 @@ function createBlockFromParams(
function normalizeTools(tools: any[]): any[] {
return tools.map((tool) => {
if (tool.type === 'custom-tool') {
// Reconstruct sanitized custom tool fields
// New reference format: minimal fields only
if (tool.customToolId && !tool.schema && !tool.code) {
return {
type: tool.type,
customToolId: tool.customToolId,
usageControl: tool.usageControl || 'auto',
isExpanded: tool.isExpanded ?? true,
}
}
// Legacy inline format: include all fields
const normalized: any = {
...tool,
params: tool.params || {},
isExpanded: tool.isExpanded ?? true,
}
// Ensure schema has proper structure
// Ensure schema has proper structure (for inline format)
if (normalized.schema?.function) {
normalized.schema = {
type: 'function',
function: {
name: tool.title, // Derive name from title
name: normalized.schema.function.name || tool.title, // Preserve name or derive from title
description: normalized.schema.function.description,
parameters: normalized.schema.function.parameters,
},

View File

@@ -0,0 +1,7 @@
export { RateLimiter } from '@/lib/core/rate-limiter/rate-limiter'
export type {
RateLimitConfig,
SubscriptionPlan,
TriggerType,
} from '@/lib/core/rate-limiter/types'
export { RATE_LIMITS, RateLimitError } from '@/lib/core/rate-limiter/types'

View File

@@ -0,0 +1,309 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { RateLimiter } from '@/lib/core/rate-limiter/rate-limiter'
import { MANUAL_EXECUTION_LIMIT, RATE_LIMITS } from '@/lib/core/rate-limiter/types'
vi.mock('@sim/db', () => ({
db: {
select: vi.fn(),
insert: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
}))
vi.mock('drizzle-orm', () => ({
eq: vi.fn((field, value) => ({ field, value })),
sql: vi.fn((strings, ...values) => ({ sql: strings.join('?'), values })),
and: vi.fn((...conditions) => ({ and: conditions })),
}))
vi.mock('@/lib/core/config/redis', () => ({
getRedisClient: vi.fn().mockReturnValue(null),
}))
import { db } from '@sim/db'
import { getRedisClient } from '@/lib/core/config/redis'
describe('RateLimiter', () => {
const rateLimiter = new RateLimiter()
const testUserId = 'test-user-123'
const freeSubscription = { plan: 'free', referenceId: testUserId }
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(getRedisClient).mockReturnValue(null)
})
describe('checkRateLimitWithSubscription', () => {
it('should allow unlimited requests for manual trigger type', async () => {
const result = await rateLimiter.checkRateLimitWithSubscription(
testUserId,
freeSubscription,
'manual',
false
)
expect(result.allowed).toBe(true)
expect(result.remaining).toBe(MANUAL_EXECUTION_LIMIT)
expect(result.resetAt).toBeInstanceOf(Date)
expect(db.select).not.toHaveBeenCalled()
})
it('should allow first API request for sync execution (DB fallback)', async () => {
vi.mocked(db.select).mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([]),
}),
}),
} as any)
vi.mocked(db.insert).mockReturnValue({
values: vi.fn().mockReturnValue({
onConflictDoUpdate: vi.fn().mockReturnValue({
returning: vi.fn().mockResolvedValue([
{
syncApiRequests: 1,
asyncApiRequests: 0,
apiEndpointRequests: 0,
windowStart: new Date(),
},
]),
}),
}),
} as any)
const result = await rateLimiter.checkRateLimitWithSubscription(
testUserId,
freeSubscription,
'api',
false
)
expect(result.allowed).toBe(true)
expect(result.remaining).toBe(RATE_LIMITS.free.syncApiExecutionsPerMinute - 1)
expect(result.resetAt).toBeInstanceOf(Date)
})
it('should allow first API request for async execution (DB fallback)', async () => {
vi.mocked(db.select).mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([]),
}),
}),
} as any)
vi.mocked(db.insert).mockReturnValue({
values: vi.fn().mockReturnValue({
onConflictDoUpdate: vi.fn().mockReturnValue({
returning: vi.fn().mockResolvedValue([
{
syncApiRequests: 0,
asyncApiRequests: 1,
apiEndpointRequests: 0,
windowStart: new Date(),
},
]),
}),
}),
} as any)
const result = await rateLimiter.checkRateLimitWithSubscription(
testUserId,
freeSubscription,
'api',
true
)
expect(result.allowed).toBe(true)
expect(result.remaining).toBe(RATE_LIMITS.free.asyncApiExecutionsPerMinute - 1)
expect(result.resetAt).toBeInstanceOf(Date)
})
it('should work for all trigger types except manual (DB fallback)', async () => {
const triggerTypes = ['api', 'webhook', 'schedule', 'chat'] as const
for (const triggerType of triggerTypes) {
vi.mocked(db.select).mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([]),
}),
}),
} as any)
vi.mocked(db.insert).mockReturnValue({
values: vi.fn().mockReturnValue({
onConflictDoUpdate: vi.fn().mockReturnValue({
returning: vi.fn().mockResolvedValue([
{
syncApiRequests: 1,
asyncApiRequests: 0,
apiEndpointRequests: 0,
windowStart: new Date(),
},
]),
}),
}),
} as any)
const result = await rateLimiter.checkRateLimitWithSubscription(
testUserId,
freeSubscription,
triggerType,
false
)
expect(result.allowed).toBe(true)
expect(result.remaining).toBe(RATE_LIMITS.free.syncApiExecutionsPerMinute - 1)
}
})
it('should use Redis when available', async () => {
const mockRedis = {
eval: vi.fn().mockResolvedValue(1), // Lua script returns count after INCR
}
vi.mocked(getRedisClient).mockReturnValue(mockRedis as any)
const result = await rateLimiter.checkRateLimitWithSubscription(
testUserId,
freeSubscription,
'api',
false
)
expect(result.allowed).toBe(true)
expect(result.remaining).toBe(RATE_LIMITS.free.syncApiExecutionsPerMinute - 1)
expect(mockRedis.eval).toHaveBeenCalled()
expect(db.select).not.toHaveBeenCalled()
})
it('should deny requests when Redis rate limit exceeded', async () => {
const mockRedis = {
eval: vi.fn().mockResolvedValue(RATE_LIMITS.free.syncApiExecutionsPerMinute + 1),
}
vi.mocked(getRedisClient).mockReturnValue(mockRedis as any)
const result = await rateLimiter.checkRateLimitWithSubscription(
testUserId,
freeSubscription,
'api',
false
)
expect(result.allowed).toBe(false)
expect(result.remaining).toBe(0)
})
it('should fall back to DB when Redis fails', async () => {
const mockRedis = {
eval: vi.fn().mockRejectedValue(new Error('Redis connection failed')),
}
vi.mocked(getRedisClient).mockReturnValue(mockRedis as any)
vi.mocked(db.select).mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([]),
}),
}),
} as any)
vi.mocked(db.insert).mockReturnValue({
values: vi.fn().mockReturnValue({
onConflictDoUpdate: vi.fn().mockReturnValue({
returning: vi.fn().mockResolvedValue([
{
syncApiRequests: 1,
asyncApiRequests: 0,
apiEndpointRequests: 0,
windowStart: new Date(),
},
]),
}),
}),
} as any)
const result = await rateLimiter.checkRateLimitWithSubscription(
testUserId,
freeSubscription,
'api',
false
)
expect(result.allowed).toBe(true)
expect(db.select).toHaveBeenCalled()
})
})
describe('getRateLimitStatusWithSubscription', () => {
it('should return unlimited for manual trigger type', async () => {
const status = await rateLimiter.getRateLimitStatusWithSubscription(
testUserId,
freeSubscription,
'manual',
false
)
expect(status.used).toBe(0)
expect(status.limit).toBe(MANUAL_EXECUTION_LIMIT)
expect(status.remaining).toBe(MANUAL_EXECUTION_LIMIT)
expect(status.resetAt).toBeInstanceOf(Date)
})
it('should return sync API limits for API trigger type (DB fallback)', async () => {
vi.mocked(db.select).mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([]),
}),
}),
} as any)
const status = await rateLimiter.getRateLimitStatusWithSubscription(
testUserId,
freeSubscription,
'api',
false
)
expect(status.used).toBe(0)
expect(status.limit).toBe(RATE_LIMITS.free.syncApiExecutionsPerMinute)
expect(status.remaining).toBe(RATE_LIMITS.free.syncApiExecutionsPerMinute)
expect(status.resetAt).toBeInstanceOf(Date)
})
it('should use Redis for status when available', async () => {
const mockRedis = {
get: vi.fn().mockResolvedValue('5'),
}
vi.mocked(getRedisClient).mockReturnValue(mockRedis as any)
const status = await rateLimiter.getRateLimitStatusWithSubscription(
testUserId,
freeSubscription,
'api',
false
)
expect(status.used).toBe(5)
expect(status.limit).toBe(RATE_LIMITS.free.syncApiExecutionsPerMinute)
expect(status.remaining).toBe(RATE_LIMITS.free.syncApiExecutionsPerMinute - 5)
expect(mockRedis.get).toHaveBeenCalled()
expect(db.select).not.toHaveBeenCalled()
})
})
describe('resetRateLimit', () => {
it('should delete rate limit record for user', async () => {
vi.mocked(db.delete).mockReturnValue({
where: vi.fn().mockResolvedValue({}),
} as any)
await rateLimiter.resetRateLimit(testUserId)
expect(db.delete).toHaveBeenCalled()
})
})
})

View File

@@ -1,8 +1,8 @@
import { db } from '@sim/db'
import { userRateLimits } from '@sim/db/schema'
import { eq, sql } from 'drizzle-orm'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import { createLogger } from '@/lib/logs/console/logger'
import type Redis from 'ioredis'
import { getRedisClient } from '@/lib/core/config/redis'
import {
MANUAL_EXECUTION_LIMIT,
RATE_LIMIT_WINDOW_MS,
@@ -10,7 +10,8 @@ import {
type RateLimitCounterType,
type SubscriptionPlan,
type TriggerType,
} from '@/services/queue/types'
} from '@/lib/core/rate-limiter/types'
import { createLogger } from '@/lib/logs/console/logger'
const logger = createLogger('RateLimiter')
@@ -88,6 +89,69 @@ export class RateLimiter {
}
}
/**
* Check rate limit using Redis (faster, single atomic operation)
* Uses fixed window algorithm with INCR + EXPIRE
*/
private async checkRateLimitRedis(
redis: Redis,
rateLimitKey: string,
counterType: RateLimitCounterType,
limit: number
): Promise<{ allowed: boolean; remaining: number; resetAt: Date }> {
const windowMs = RATE_LIMIT_WINDOW_MS
const windowKey = Math.floor(Date.now() / windowMs)
const key = `ratelimit:${rateLimitKey}:${counterType}:${windowKey}`
const ttlSeconds = Math.ceil(windowMs / 1000)
// Atomic increment + expire
const count = (await redis.eval(
'local c = redis.call("INCR", KEYS[1]) if c == 1 then redis.call("EXPIRE", KEYS[1], ARGV[1]) end return c',
1,
key,
ttlSeconds
)) as number
const resetAt = new Date((windowKey + 1) * windowMs)
if (count > limit) {
logger.info(`Rate limit exceeded (Redis) - request ${count} > limit ${limit}`, {
rateLimitKey,
counterType,
limit,
count,
})
return { allowed: false, remaining: 0, resetAt }
}
return { allowed: true, remaining: limit - count, resetAt }
}
/**
* Get rate limit status using Redis (read-only, doesn't increment)
*/
private async getRateLimitStatusRedis(
redis: Redis,
rateLimitKey: string,
counterType: RateLimitCounterType,
limit: number
): Promise<{ used: number; limit: number; remaining: number; resetAt: Date }> {
const windowMs = RATE_LIMIT_WINDOW_MS
const windowKey = Math.floor(Date.now() / windowMs)
const key = `ratelimit:${rateLimitKey}:${counterType}:${windowKey}`
const countStr = await redis.get(key)
const used = countStr ? Number.parseInt(countStr, 10) : 0
const resetAt = new Date((windowKey + 1) * windowMs)
return {
used,
limit,
remaining: Math.max(0, limit - used),
resetAt,
}
}
/**
* Check if user can execute a workflow with organization-aware rate limiting
* Manual executions bypass rate limiting entirely
@@ -114,6 +178,18 @@ export class RateLimiter {
const counterType = this.getCounterType(triggerType, isAsync)
const execLimit = this.getRateLimitForCounter(limit, counterType)
// Try Redis first for faster rate limiting
const redis = getRedisClient()
if (redis) {
try {
return await this.checkRateLimitRedis(redis, rateLimitKey, counterType, execLimit)
} catch (error) {
logger.warn('Redis rate limit check failed, falling back to DB:', { error })
// Fall through to DB implementation
}
}
// Fallback to DB implementation
const now = new Date()
const windowStart = new Date(now.getTime() - RATE_LIMIT_WINDOW_MS)
@@ -273,21 +349,6 @@ export class RateLimiter {
}
}
/**
* Legacy method - for backward compatibility
* @deprecated Use checkRateLimitWithSubscription instead
*/
async checkRateLimit(
userId: string,
subscriptionPlan: SubscriptionPlan = 'free',
triggerType: TriggerType = 'manual',
isAsync = false
): Promise<{ allowed: boolean; remaining: number; resetAt: Date }> {
// For backward compatibility, fetch the subscription
const subscription = await getHighestPrioritySubscription(userId)
return this.checkRateLimitWithSubscription(userId, subscription, triggerType, isAsync)
}
/**
* Get current rate limit status with organization awareness
* Only applies to API executions
@@ -315,6 +376,18 @@ export class RateLimiter {
const counterType = this.getCounterType(triggerType, isAsync)
const execLimit = this.getRateLimitForCounter(limit, counterType)
// Try Redis first for faster status check
const redis = getRedisClient()
if (redis) {
try {
return await this.getRateLimitStatusRedis(redis, rateLimitKey, counterType, execLimit)
} catch (error) {
logger.warn('Redis rate limit status check failed, falling back to DB:', { error })
// Fall through to DB implementation
}
}
// Fallback to DB implementation
const now = new Date()
const windowStart = new Date(now.getTime() - RATE_LIMIT_WINDOW_MS)
@@ -355,21 +428,6 @@ export class RateLimiter {
}
}
/**
* Legacy method - for backward compatibility
* @deprecated Use getRateLimitStatusWithSubscription instead
*/
async getRateLimitStatus(
userId: string,
subscriptionPlan: SubscriptionPlan = 'free',
triggerType: TriggerType = 'manual',
isAsync = false
): Promise<{ used: number; limit: number; remaining: number; resetAt: Date }> {
// For backward compatibility, fetch the subscription
const subscription = await getHighestPrioritySubscription(userId)
return this.getRateLimitStatusWithSubscription(userId, subscription, triggerType, isAsync)
}
/**
* Reset rate limit for a user or organization
*/

View File

@@ -1,10 +1,7 @@
/**
* Execution timeout constants
*
* These constants define the timeout values for code execution.
* - DEFAULT_EXECUTION_TIMEOUT_MS: The default timeout for executing user code (3 minutes)
* - MAX_EXECUTION_DURATION: The maximum duration for the API route (adds 30s buffer for overhead)
* DEFAULT_EXECUTION_TIMEOUT_MS: The default timeout for executing user code (10 minutes)
*/
export const DEFAULT_EXECUTION_TIMEOUT_MS = 180000 // 3 minutes (180 seconds)
export const MAX_EXECUTION_DURATION = 210 // 3.5 minutes (210 seconds) - includes buffer for sandbox creation
export const DEFAULT_EXECUTION_TIMEOUT_MS = 600000 // 10 minutes (600 seconds)

View File

@@ -3,10 +3,10 @@ import { workflow } from '@sim/db/schema'
import { eq } from 'drizzle-orm'
import { checkServerSideUsageLimits } from '@/lib/billing/calculations/usage-monitor'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import { RateLimiter } from '@/lib/core/rate-limiter/rate-limiter'
import { createLogger } from '@/lib/logs/console/logger'
import { LoggingSession } from '@/lib/logs/execution/logging-session'
import { getWorkspaceBilledAccountUserId } from '@/lib/workspaces/utils'
import { RateLimiter } from '@/services/queue/RateLimiter'
const logger = createLogger('ExecutionPreprocessing')
@@ -228,26 +228,18 @@ export async function preprocessExecution(
const workspaceId = workflowRecord.workspaceId || providedWorkspaceId || ''
// ========== STEP 2: Check Deployment Status ==========
// If workflow is not deployed and deployment is required, reject without logging.
// No log entry or cost should be created for calls to undeployed workflows
// since the workflow was never intended to run.
if (checkDeployment && !workflowRecord.isDeployed) {
logger.warn(`[${requestId}] Workflow not deployed: ${workflowId}`)
await logPreprocessingError({
workflowId,
executionId,
triggerType,
requestId,
userId: workflowRecord.userId || userId,
workspaceId,
errorMessage: 'Workflow is not deployed. Please deploy the workflow before triggering it.',
loggingSession: providedLoggingSession,
})
return {
success: false,
error: {
message: 'Workflow is not deployed',
statusCode: 403,
logCreated: true,
logCreated: false,
},
}
}

Some files were not shown because too many files have changed in this diff Show More