mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-09 23:17:59 -05:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52edbea659 | ||
|
|
aa1d896b38 | ||
|
|
2fcd07e82d | ||
|
|
0db5ba1b27 | ||
|
|
e390ba0491 | ||
|
|
2f0509adaf | ||
|
|
9f0584a818 | ||
|
|
6b4d76298f | ||
|
|
b7a1e8f5cf | ||
|
|
3ce2788562 | ||
|
|
17a084cd61 | ||
|
|
dafd2f5ce8 | ||
|
|
5af67d08ba | ||
|
|
209b0f1906 | ||
|
|
e067b584ee | ||
|
|
87084edbe6 | ||
|
|
99e0b81233 | ||
|
|
d480057fd3 | ||
|
|
c197b04bcc |
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
63
apps/docs/content/docs/de/tools/duckduckgo.mdx
Normal file
63
apps/docs/content/docs/de/tools/duckduckgo.mdx
Normal 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`
|
||||
183
apps/docs/content/docs/de/tools/sftp.mdx
Normal file
183
apps/docs/content/docs/de/tools/sftp.mdx
Normal 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`
|
||||
@@ -7,7 +7,7 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="smtp"
|
||||
color="#4A5568"
|
||||
color="#2D3748"
|
||||
/>
|
||||
|
||||
{/* MANUAL-CONTENT-START:intro */}
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
49
apps/docs/content/docs/de/triggers/rss.mdx
Normal file
49
apps/docs/content/docs/de/triggers/rss.mdx
Normal 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>
|
||||
68
apps/docs/content/docs/en/tools/duckduckgo.mdx
Normal file
68
apps/docs/content/docs/en/tools/duckduckgo.mdx
Normal 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 DuckDuckGo’s Instant Answers API is open and does not require an API key, it’s 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`
|
||||
@@ -15,6 +15,7 @@
|
||||
"datadog",
|
||||
"discord",
|
||||
"dropbox",
|
||||
"duckduckgo",
|
||||
"dynamodb",
|
||||
"elasticsearch",
|
||||
"elevenlabs",
|
||||
@@ -80,6 +81,7 @@
|
||||
"sendgrid",
|
||||
"sentry",
|
||||
"serper",
|
||||
"sftp",
|
||||
"sharepoint",
|
||||
"shopify",
|
||||
"slack",
|
||||
|
||||
188
apps/docs/content/docs/en/tools/sftp.mdx
Normal file
188
apps/docs/content/docs/en/tools/sftp.mdx
Normal 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 it’s 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`
|
||||
@@ -7,7 +7,7 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="smtp"
|
||||
color="#4A5568"
|
||||
color="#2D3748"
|
||||
/>
|
||||
|
||||
{/* MANUAL-CONTENT-START:intro */}
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"pages": ["index", "start", "schedule", "webhook"]
|
||||
"pages": ["index", "start", "schedule", "webhook", "rss"]
|
||||
}
|
||||
|
||||
49
apps/docs/content/docs/en/triggers/rss.mdx
Normal file
49
apps/docs/content/docs/en/triggers/rss.mdx
Normal 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>
|
||||
63
apps/docs/content/docs/es/tools/duckduckgo.mdx
Normal file
63
apps/docs/content/docs/es/tools/duckduckgo.mdx
Normal 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`
|
||||
184
apps/docs/content/docs/es/tools/sftp.mdx
Normal file
184
apps/docs/content/docs/es/tools/sftp.mdx
Normal 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`
|
||||
@@ -7,7 +7,7 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="smtp"
|
||||
color="#4A5568"
|
||||
color="#2D3748"
|
||||
/>
|
||||
|
||||
{/* MANUAL-CONTENT-START:intro */}
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
49
apps/docs/content/docs/es/triggers/rss.mdx
Normal file
49
apps/docs/content/docs/es/triggers/rss.mdx
Normal 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>
|
||||
63
apps/docs/content/docs/fr/tools/duckduckgo.mdx
Normal file
63
apps/docs/content/docs/fr/tools/duckduckgo.mdx
Normal 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`
|
||||
183
apps/docs/content/docs/fr/tools/sftp.mdx
Normal file
183
apps/docs/content/docs/fr/tools/sftp.mdx
Normal 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`
|
||||
@@ -7,7 +7,7 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="smtp"
|
||||
color="#4A5568"
|
||||
color="#2D3748"
|
||||
/>
|
||||
|
||||
{/* MANUAL-CONTENT-START:intro */}
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
49
apps/docs/content/docs/fr/triggers/rss.mdx
Normal file
49
apps/docs/content/docs/fr/triggers/rss.mdx
Normal 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>
|
||||
63
apps/docs/content/docs/ja/tools/duckduckgo.mdx
Normal file
63
apps/docs/content/docs/ja/tools/duckduckgo.mdx
Normal 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`
|
||||
183
apps/docs/content/docs/ja/tools/sftp.mdx
Normal file
183
apps/docs/content/docs/ja/tools/sftp.mdx
Normal file
@@ -0,0 +1,183 @@
|
||||
---
|
||||
title: SFTP
|
||||
description: 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)は、リモートサーバー上でファイルのアップロード、ダウンロード、管理を可能にする安全なネットワークプロトコルです。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`
|
||||
@@ -7,7 +7,7 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="smtp"
|
||||
color="#4A5568"
|
||||
color="#2D3748"
|
||||
/>
|
||||
|
||||
{/* MANUAL-CONTENT-START:intro */}
|
||||
|
||||
@@ -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` フィールドを公開します。追加の構造化データには入力フォーマットにカスタムフィールドを追加してください。
|
||||
|
||||
|
||||
49
apps/docs/content/docs/ja/triggers/rss.mdx
Normal file
49
apps/docs/content/docs/ja/triggers/rss.mdx
Normal 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>
|
||||
63
apps/docs/content/docs/zh/tools/duckduckgo.mdx
Normal file
63
apps/docs/content/docs/zh/tools/duckduckgo.mdx
Normal 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`
|
||||
183
apps/docs/content/docs/zh/tools/sftp.mdx
Normal file
183
apps/docs/content/docs/zh/tools/sftp.mdx
Normal file
@@ -0,0 +1,183 @@
|
||||
---
|
||||
title: SFTP
|
||||
description: 通过 SFTP(SSH 文件传输协议)传输文件
|
||||
---
|
||||
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="sftp"
|
||||
color="#2D3748"
|
||||
/>
|
||||
|
||||
{/* MANUAL-CONTENT-START:intro */}
|
||||
[SFTP(SSH 文件传输协议)](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`
|
||||
@@ -7,7 +7,7 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="smtp"
|
||||
color="#4A5568"
|
||||
color="#2D3748"
|
||||
/>
|
||||
|
||||
{/* MANUAL-CONTENT-START:intro */}
|
||||
|
||||
@@ -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` 字段。通过向输入格式添加自定义字段来增加结构化数据。
|
||||
|
||||
|
||||
49
apps/docs/content/docs/zh/triggers/rss.mdx
Normal file
49
apps/docs/content/docs/zh/triggers/rss.mdx
Normal 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>
|
||||
@@ -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
|
||||
|
||||
BIN
apps/docs/public/static/blocks/rss.png
Normal file
BIN
apps/docs/public/static/blocks/rss.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
@@ -364,7 +364,7 @@ describe('Chat Identifier API Route', () => {
|
||||
error: {
|
||||
message: 'Workflow is not deployed',
|
||||
statusCode: 403,
|
||||
logCreated: true,
|
||||
logCreated: false,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
188
apps/sim/app/api/tools/sftp/delete/route.ts
Normal file
188
apps/sim/app/api/tools/sftp/delete/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
149
apps/sim/app/api/tools/sftp/download/route.ts
Normal file
149
apps/sim/app/api/tools/sftp/download/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
156
apps/sim/app/api/tools/sftp/list/route.ts
Normal file
156
apps/sim/app/api/tools/sftp/list/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
168
apps/sim/app/api/tools/sftp/mkdir/route.ts
Normal file
168
apps/sim/app/api/tools/sftp/mkdir/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
242
apps/sim/app/api/tools/sftp/upload/route.ts
Normal file
242
apps/sim/app/api/tools/sftp/upload/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
275
apps/sim/app/api/tools/sftp/utils.ts
Normal file
275
apps/sim/app/api/tools/sftp/utils.ts
Normal 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')
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
})
|
||||
@@ -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: {
|
||||
|
||||
@@ -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()
|
||||
|
||||
66
apps/sim/app/api/webhooks/poll/rss/route.ts
Normal file
66
apps/sim/app/api/webhooks/poll/rss/route.ts
Normal 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(() => {})
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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...'
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -134,7 +134,7 @@ async function executeWebhookJobInternal(
|
||||
const loggingSession = new LoggingSession(
|
||||
payload.workflowId,
|
||||
executionId,
|
||||
payload.provider || 'webhook',
|
||||
payload.provider,
|
||||
requestId
|
||||
)
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
59
apps/sim/blocks/blocks/duckduckgo.ts
Normal file
59
apps/sim/blocks/blocks/duckduckgo.ts
Normal 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' },
|
||||
},
|
||||
}
|
||||
36
apps/sim/blocks/blocks/rss.ts
Normal file
36
apps/sim/blocks/blocks/rss.ts
Normal 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'],
|
||||
},
|
||||
}
|
||||
306
apps/sim/blocks/blocks/sftp.ts
Normal file
306
apps/sim/blocks/blocks/sftp.ts
Normal 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' },
|
||||
},
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 || {}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 []
|
||||
}
|
||||
/**
|
||||
|
||||
@@ -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 []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
387
apps/sim/lib/copilot/tools/client/workflow/manage-custom-tool.ts
Normal file
387
apps/sim/lib/copilot/tools/client/workflow/manage-custom-tool.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
7
apps/sim/lib/core/rate-limiter/index.ts
Normal file
7
apps/sim/lib/core/rate-limiter/index.ts
Normal 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'
|
||||
309
apps/sim/lib/core/rate-limiter/rate-limiter.test.ts
Normal file
309
apps/sim/lib/core/rate-limiter/rate-limiter.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
*/
|
||||
@@ -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)
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user