mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-10 23:48:09 -05:00
Compare commits
57 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aca4d2fc15 | ||
|
|
96958104c0 | ||
|
|
6f29e2413c | ||
|
|
c2ccd51b3e | ||
|
|
ec430abca2 | ||
|
|
4b4060f63f | ||
|
|
72a048f37d | ||
|
|
fa18bef65b | ||
|
|
a9eb91aed1 | ||
|
|
16a4f37dac | ||
|
|
6c56d48e73 | ||
|
|
1082e55871 | ||
|
|
948b6575dc | ||
|
|
3ba33791f7 | ||
|
|
1e915d5427 | ||
|
|
d86198ad5d | ||
|
|
785f847c48 | ||
|
|
dab70a8f1d | ||
|
|
d2c0147822 | ||
|
|
67d9343022 | ||
|
|
b3caef1f31 | ||
|
|
5457d4bc7b | ||
|
|
32a2e09a14 | ||
|
|
80eaeb00c2 | ||
|
|
b67b4ff8fb | ||
|
|
6f4f8cfad2 | ||
|
|
3a8f01f3e4 | ||
|
|
383b6f05a6 | ||
|
|
a70f2a6690 | ||
|
|
53150021e0 | ||
|
|
4e5b834433 | ||
|
|
304fb28baf | ||
|
|
07e803cfdd | ||
|
|
d50aefcc68 | ||
|
|
6513cbb7c1 | ||
|
|
f9e822f6c8 | ||
|
|
74202ce1fc | ||
|
|
66c8fa2a77 | ||
|
|
766c7fbfbb | ||
|
|
675c42188a | ||
|
|
f414ae1936 | ||
|
|
ead0db9d2a | ||
|
|
10288111a8 | ||
|
|
01183f1771 | ||
|
|
ff081714e4 | ||
|
|
36bcd75832 | ||
|
|
9db969b1e0 | ||
|
|
2fbe0de5d3 | ||
|
|
6315cc105b | ||
|
|
61404d48a3 | ||
|
|
dbf9097a5b | ||
|
|
79b318fd9c | ||
|
|
cb39e697e2 | ||
|
|
e1a46c90c6 | ||
|
|
c7560be282 | ||
|
|
63f18995da | ||
|
|
af501347bb |
19
README.md
19
README.md
@@ -13,8 +13,25 @@
|
||||
<a href="https://docs.sim.ai" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/badge/Docs-6F3DFA.svg" alt="Documentation"></a>
|
||||
</p>
|
||||
|
||||
### Build Workflows with Ease
|
||||
Design agent workflows visually on a canvas—connect agents, tools, and blocks, then run them instantly.
|
||||
|
||||
<p align="center">
|
||||
<img src="apps/sim/public/static/demo.gif" alt="Sim Demo" width="800"/>
|
||||
<img src="apps/sim/public/static/workflow.gif" alt="Workflow Builder Demo" width="800"/>
|
||||
</p>
|
||||
|
||||
### Supercharge with Copilot
|
||||
Leverage Copilot to generate nodes, fix errors, and iterate on flows directly from natural language.
|
||||
|
||||
<p align="center">
|
||||
<img src="apps/sim/public/static/copilot.gif" alt="Copilot Demo" width="800"/>
|
||||
</p>
|
||||
|
||||
### Integrate Vector Databases
|
||||
Upload documents to a vector store and let agents answer questions grounded in your specific content.
|
||||
|
||||
<p align="center">
|
||||
<img src="apps/sim/public/static/knowledge.gif" alt="Knowledge Uploads and Retrieval Demo" width="800"/>
|
||||
</p>
|
||||
|
||||
## Quickstart
|
||||
|
||||
@@ -13,7 +13,6 @@ import { Navbar } from '@/components/navbar/navbar'
|
||||
import { i18n } from '@/lib/i18n'
|
||||
import { source } from '@/lib/source'
|
||||
import '../global.css'
|
||||
import { Analytics } from '@vercel/analytics/next'
|
||||
|
||||
const inter = Inter({
|
||||
subsets: ['latin'],
|
||||
@@ -94,6 +93,8 @@ export default async function Layout({ children, params }: LayoutProps) {
|
||||
type='application/ld+json'
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
|
||||
/>
|
||||
{/* OneDollarStats Analytics - CDN script handles everything automatically */}
|
||||
<script defer src='https://assets.onedollarstats.com/stonks.js' />
|
||||
</head>
|
||||
<body className='flex min-h-screen flex-col font-sans'>
|
||||
<RootProvider i18n={provider(lang)}>
|
||||
@@ -132,7 +133,6 @@ export default async function Layout({ children, params }: LayoutProps) {
|
||||
>
|
||||
{children}
|
||||
</DocsLayout>
|
||||
<Analytics />
|
||||
</RootProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -4003,3 +4003,32 @@ export function SalesforceIcon(props: SVGProps<SVGSVGElement>) {
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function ApolloIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
{...props}
|
||||
height='26'
|
||||
viewBox='0 0 36 36'
|
||||
fill='currentColor'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<path
|
||||
d='M19.5993 0.0862365L19.605 13.2568C19.6058 15.3375 17.4222 16.6715 15.6079 15.6986L2.58376 8.7153C3.57706 7.05795 4.82616 5.57609 6.27427 4.32386L16.489 13.8945C17.0303 14.4015 17.8835 13.8518 17.6605 13.1398L13.6992 0.493553C15.0326 0.17147 16.4233 0 17.8536 0C18.4428 0 19.0248 0.0296814 19.5993 0.0862365Z'
|
||||
fill='#000000'
|
||||
/>
|
||||
<path
|
||||
d='M16.0635 36.1087L16.0578 23.0046C16.057 20.9239 18.2407 19.5898 20.0549 20.5627L33.0838 27.5486C32.0838 29.2016 30.8289 30.6786 29.3751 31.925L19.1738 22.3668C18.6326 21.8598 17.7793 22.4095 18.0023 23.1215L21.9486 35.72C20.6338 36.0329 19.263 36.1989 17.8539 36.1989C17.2497 36.1989 16.6523 36.1683 16.0635 36.1087Z'
|
||||
fill='#000000'
|
||||
/>
|
||||
<path
|
||||
d='M22.0105 16.77L31.4705 6.39392C30.2362 4.92008 28.7742 3.6486 27.1384 2.63702L20.2306 15.8767C19.2709 17.716 20.5871 19.9298 22.6396 19.9288L35.6183 19.923C35.6775 19.3234 35.7082 18.7151 35.7082 18.0996C35.7082 16.6683 35.5436 15.2761 35.2338 13.9406L22.7549 17.9576C22.0526 18.1837 21.5103 17.3187 22.0105 16.77Z'
|
||||
fill='#000000'
|
||||
/>
|
||||
<path
|
||||
d='M0.0842758 16.3383L13.0237 16.3325C15.0764 16.3317 16.3923 18.5454 15.4327 20.3846L8.56047 33.5561C6.93095 32.547 5.47394 31.2801 4.24344 29.8121L13.653 19.4914C14.1531 18.9427 13.6107 18.0777 12.9084 18.3037L0.485078 22.3029C0.168551 20.954 0 19.5467 0 18.0994C0 17.5051 0.0290814 16.9177 0.0842758 16.3383Z'
|
||||
fill='#000000'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import type { ComponentType, SVGProps } from 'react'
|
||||
import {
|
||||
AirtableIcon,
|
||||
ApolloIcon,
|
||||
ArxivIcon,
|
||||
AsanaIcon,
|
||||
BrainIcon,
|
||||
@@ -80,78 +81,79 @@ import {
|
||||
type IconComponent = ComponentType<SVGProps<SVGSVGElement>>
|
||||
|
||||
export const blockTypeToIconMap: Record<string, IconComponent> = {
|
||||
postgresql: PostgresIcon,
|
||||
twilio_voice: TwilioIcon,
|
||||
translate: TranslateIcon,
|
||||
tavily: TavilyIcon,
|
||||
stagehand_agent: StagehandIcon,
|
||||
youtube: YouTubeIcon,
|
||||
supabase: SupabaseIcon,
|
||||
vision: EyeIcon,
|
||||
confluence: ConfluenceIcon,
|
||||
arxiv: ArxivIcon,
|
||||
webflow: WebflowIcon,
|
||||
pinecone: PineconeIcon,
|
||||
whatsapp: WhatsAppIcon,
|
||||
typeform: TypeformIcon,
|
||||
qdrant: QdrantIcon,
|
||||
asana: AsanaIcon,
|
||||
memory: BrainIcon,
|
||||
serper: SerperIcon,
|
||||
linear: LinearIcon,
|
||||
exa: ExaAIIcon,
|
||||
telegram: TelegramIcon,
|
||||
salesforce: SalesforceIcon,
|
||||
hubspot: HubspotIcon,
|
||||
hunter: HunterIOIcon,
|
||||
linkup: LinkupIcon,
|
||||
mongodb: MongoDBIcon,
|
||||
airtable: AirtableIcon,
|
||||
discord: DiscordIcon,
|
||||
jina: JinaAIIcon,
|
||||
google_docs: GoogleDocsIcon,
|
||||
perplexity: PerplexityIcon,
|
||||
google_search: GoogleIcon,
|
||||
x: xIcon,
|
||||
google_calendar: GoogleCalendarIcon,
|
||||
zep: ZepIcon,
|
||||
microsoft_planner: MicrosoftPlannerIcon,
|
||||
thinking: BrainIcon,
|
||||
pipedrive: PipedriveIcon,
|
||||
stagehand: StagehandIcon,
|
||||
google_forms: GoogleFormsIcon,
|
||||
file: DocumentIcon,
|
||||
mistral_parse: MistralIcon,
|
||||
gmail: GmailIcon,
|
||||
openai: OpenAIIcon,
|
||||
outlook: OutlookIcon,
|
||||
onedrive: MicrosoftOneDriveIcon,
|
||||
resend: ResendIcon,
|
||||
google_vault: GoogleVaultIcon,
|
||||
sharepoint: MicrosoftSharepointIcon,
|
||||
huggingface: HuggingFaceIcon,
|
||||
clay: ClayIcon,
|
||||
jira: JiraIcon,
|
||||
wealthbox: WealthboxIcon,
|
||||
notion: NotionIcon,
|
||||
elevenlabs: ElevenLabsIcon,
|
||||
microsoft_teams: MicrosoftTeamsIcon,
|
||||
github: GithubIcon,
|
||||
google_drive: GoogleDriveIcon,
|
||||
reddit: RedditIcon,
|
||||
parallel_ai: ParallelIcon,
|
||||
stripe: StripeIcon,
|
||||
s3: S3Icon,
|
||||
trello: TrelloIcon,
|
||||
mem0: Mem0Icon,
|
||||
knowledge: PackageSearchIcon,
|
||||
twilio_sms: TwilioIcon,
|
||||
slack: SlackIcon,
|
||||
microsoft_excel: MicrosoftExcelIcon,
|
||||
image_generator: ImageIcon,
|
||||
google_sheets: GoogleSheetsIcon,
|
||||
youtube: YouTubeIcon,
|
||||
x: xIcon,
|
||||
wikipedia: WikipediaIcon,
|
||||
firecrawl: FirecrawlIcon,
|
||||
whatsapp: WhatsAppIcon,
|
||||
webflow: WebflowIcon,
|
||||
wealthbox: WealthboxIcon,
|
||||
vision: EyeIcon,
|
||||
typeform: TypeformIcon,
|
||||
twilio_voice: TwilioIcon,
|
||||
twilio_sms: TwilioIcon,
|
||||
trello: TrelloIcon,
|
||||
translate: TranslateIcon,
|
||||
thinking: BrainIcon,
|
||||
telegram: TelegramIcon,
|
||||
tavily: TavilyIcon,
|
||||
supabase: SupabaseIcon,
|
||||
stripe: StripeIcon,
|
||||
stagehand_agent: StagehandIcon,
|
||||
stagehand: StagehandIcon,
|
||||
slack: SlackIcon,
|
||||
sharepoint: MicrosoftSharepointIcon,
|
||||
serper: SerperIcon,
|
||||
salesforce: SalesforceIcon,
|
||||
s3: S3Icon,
|
||||
resend: ResendIcon,
|
||||
reddit: RedditIcon,
|
||||
qdrant: QdrantIcon,
|
||||
postgresql: PostgresIcon,
|
||||
pipedrive: PipedriveIcon,
|
||||
pinecone: PineconeIcon,
|
||||
perplexity: PerplexityIcon,
|
||||
parallel_ai: ParallelIcon,
|
||||
outlook: OutlookIcon,
|
||||
openai: OpenAIIcon,
|
||||
onedrive: MicrosoftOneDriveIcon,
|
||||
notion: NotionIcon,
|
||||
mysql: MySQLIcon,
|
||||
mongodb: MongoDBIcon,
|
||||
mistral_parse: MistralIcon,
|
||||
microsoft_teams: MicrosoftTeamsIcon,
|
||||
microsoft_planner: MicrosoftPlannerIcon,
|
||||
microsoft_excel: MicrosoftExcelIcon,
|
||||
memory: BrainIcon,
|
||||
mem0: Mem0Icon,
|
||||
linkup: LinkupIcon,
|
||||
linear: LinearIcon,
|
||||
knowledge: PackageSearchIcon,
|
||||
jira: JiraIcon,
|
||||
jina: JinaAIIcon,
|
||||
image_generator: ImageIcon,
|
||||
hunter: HunterIOIcon,
|
||||
huggingface: HuggingFaceIcon,
|
||||
hubspot: HubspotIcon,
|
||||
google_vault: GoogleVaultIcon,
|
||||
google_sheets: GoogleSheetsIcon,
|
||||
google_forms: GoogleFormsIcon,
|
||||
google_drive: GoogleDriveIcon,
|
||||
google_docs: GoogleDocsIcon,
|
||||
google_calendar: GoogleCalendarIcon,
|
||||
google_search: GoogleIcon,
|
||||
gmail: GmailIcon,
|
||||
github: GithubIcon,
|
||||
firecrawl: FirecrawlIcon,
|
||||
file: DocumentIcon,
|
||||
exa: ExaAIIcon,
|
||||
elevenlabs: ElevenLabsIcon,
|
||||
discord: DiscordIcon,
|
||||
confluence: ConfluenceIcon,
|
||||
clay: ClayIcon,
|
||||
browser_use: BrowserUseIcon,
|
||||
asana: AsanaIcon,
|
||||
arxiv: ArxivIcon,
|
||||
apollo: ApolloIcon,
|
||||
airtable: AirtableIcon,
|
||||
}
|
||||
|
||||
574
apps/docs/content/docs/de/tools/apollo.mdx
Normal file
574
apps/docs/content/docs/de/tools/apollo.mdx
Normal file
@@ -0,0 +1,574 @@
|
||||
---
|
||||
title: Apollo
|
||||
description: Suchen, anreichern und verwalten Sie Kontakte mit Apollo.io
|
||||
---
|
||||
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="apollo"
|
||||
color="#EBF212"
|
||||
/>
|
||||
|
||||
{/* MANUAL-CONTENT-START:intro */}
|
||||
[Apollo.io](https://apollo.io/) ist eine führende Plattform für Vertriebsintelligenz und -engagement, die Benutzern ermöglicht, Kontakte und Unternehmen im großen Maßstab zu finden, anzureichern und zu kontaktieren. Apollo.io kombiniert eine umfangreiche Kontaktdatenbank mit robusten Anreicherungs- und Workflow-Automatisierungstools und unterstützt Vertriebs-, Marketing- und Recruiting-Teams bei der Beschleunigung des Wachstums.
|
||||
|
||||
Mit Apollo.io können Sie:
|
||||
|
||||
- **Millionen von Kontakten und Unternehmen durchsuchen**: Finden Sie präzise Leads mit erweiterten Filtern
|
||||
- **Leads und Accounts anreichern**: Füllen Sie fehlende Details mit verifizierten Daten und aktuellen Informationen
|
||||
- **CRM-Datensätze verwalten und organisieren**: Halten Sie Ihre Personen- und Unternehmensdaten genau und handlungsfähig
|
||||
- **Outreach automatisieren**: Fügen Sie Kontakte zu Sequenzen hinzu und erstellen Sie Follow-up-Aufgaben direkt aus Apollo.io
|
||||
|
||||
In Sim ermöglicht die Apollo.io-Integration Ihren Agenten, zentrale Apollo-Operationen programmatisch durchzuführen:
|
||||
|
||||
- **Personen und Unternehmen suchen**: Verwenden Sie `apollo_people_search`, um neue Leads mit flexiblen Filtern zu entdecken.
|
||||
- **Personendaten anreichern**: Verwenden Sie `apollo_people_enrich`, um Kontakte mit verifizierten Informationen zu ergänzen.
|
||||
- **Personen in Masse anreichern**: Verwenden Sie `apollo_people_bulk_enrich` für die großflächige Anreicherung mehrerer Kontakte auf einmal.
|
||||
- **Unternehmen suchen und anreichern**: Verwenden Sie `apollo_company_search` und `apollo_company_enrich`, um wichtige Unternehmensinformationen zu entdecken und zu aktualisieren.
|
||||
|
||||
Dies ermöglicht Ihren Agenten, leistungsstarke Workflows für Prospecting, CRM-Anreicherung und Automatisierung zu erstellen, ohne manuelle Dateneingabe oder Tabwechsel. Integrieren Sie Apollo.io als dynamische Datenquelle und CRM-Engine – und befähigen Sie Ihre Agenten, Leads nahtlos als Teil ihrer täglichen Arbeit zu identifizieren, zu qualifizieren und zu kontaktieren.
|
||||
{/* MANUAL-CONTENT-END */}
|
||||
|
||||
## Gebrauchsanweisung
|
||||
|
||||
Integriert Apollo.io in den Workflow. Suche nach Personen und Unternehmen, reichere Kontaktdaten an, verwalte deine CRM-Kontakte und Konten, füge Kontakte zu Sequenzen hinzu und erstelle Aufgaben.
|
||||
|
||||
## Tools
|
||||
|
||||
### `apollo_people_search`
|
||||
|
||||
Apollo durchsuchen
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Typ | Erforderlich | Beschreibung |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Ja | Apollo API-Schlüssel |
|
||||
| `person_titles` | array | Nein | Zu suchende Berufsbezeichnungen (z.B. ["CEO", "VP of Sales"]) |
|
||||
| `person_locations` | array | Nein | Orte, in denen gesucht werden soll (z.B. ["San Francisco, CA", "New York, NY"]) |
|
||||
| `person_seniorities` | array | Nein | Hierarchieebenen (z.B. ["senior", "executive", "manager"]) |
|
||||
| `organization_names` | array | Nein | Unternehmensnamen, in denen gesucht werden soll |
|
||||
| `q_keywords` | string | Nein | Zu suchende Schlüsselwörter |
|
||||
| `page` | number | Nein | Seitennummer für Paginierung (Standard: 1) |
|
||||
| `per_page` | number | Nein | Ergebnisse pro Seite (Standard: 25, max: 100) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Typ | Beschreibung |
|
||||
| --------- | ---- | ----------- |
|
||||
| `people` | json | Array von Personen, die den Suchkriterien entsprechen |
|
||||
| `metadata` | json | Paginierungsinformationen einschließlich page, per_page und total_entries |
|
||||
|
||||
### `apollo_people_enrich`
|
||||
|
||||
Daten für eine einzelne Person mit Apollo anreichern
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Typ | Erforderlich | Beschreibung |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Ja | Apollo API-Schlüssel |
|
||||
| `first_name` | string | Nein | Vorname der Person |
|
||||
| `last_name` | string | Nein | Nachname der Person |
|
||||
| `email` | string | Nein | E-Mail-Adresse der Person |
|
||||
| `organization_name` | string | Nein | Name des Unternehmens, in dem die Person arbeitet |
|
||||
| `domain` | string | Nein | Unternehmensdomäne (z.B. apollo.io) |
|
||||
| `linkedin_url` | string | Nein | LinkedIn-Profil-URL |
|
||||
| `reveal_personal_emails` | boolean | Nein | Persönliche E-Mail-Adressen aufdecken (verbraucht Credits) |
|
||||
| `reveal_phone_number` | boolean | Nein | Telefonnummern aufdecken (verbraucht Credits) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Beschreibung |
|
||||
| --------- | ---- | ----------- |
|
||||
| `person` | json | Angereicherte Personendaten von Apollo |
|
||||
| `metadata` | json | Anreicherungsmetadaten einschließlich Anreicherungsstatus |
|
||||
|
||||
### `apollo_people_bulk_enrich`
|
||||
|
||||
Daten für bis zu 10 Personen gleichzeitig mit Apollo anreichern
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Erforderlich | Beschreibung |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Ja | Apollo API-Schlüssel |
|
||||
| `people` | array | Ja | Array von Personen zur Anreicherung (max. 10) |
|
||||
| `reveal_personal_emails` | boolean | Nein | Persönliche E-Mail-Adressen anzeigen (verbraucht Credits) |
|
||||
| `reveal_phone_number` | boolean | Nein | Telefonnummern anzeigen (verbraucht Credits) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Beschreibung |
|
||||
| --------- | ---- | ----------- |
|
||||
| `people` | json | Array von angereicherten Personendaten |
|
||||
| `metadata` | json | Metadaten zur Massenanreicherung einschließlich Gesamt- und angereicherter Anzahl |
|
||||
|
||||
### `apollo_organization_search`
|
||||
|
||||
Apollo durchsuchen
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Erforderlich | Beschreibung |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Ja | Apollo API-Schlüssel |
|
||||
| `organization_locations` | array | Nein | Zu durchsuchende Unternehmensstandorte |
|
||||
| `organization_num_employees_ranges` | array | Nein | Bereiche der Mitarbeiterzahl (z.B. ["1-10", "11-50"]) |
|
||||
| `q_organization_keyword_tags` | array | Nein | Branchen- oder Schlüsselwort-Tags |
|
||||
| `q_organization_name` | string | Nein | Zu suchender Organisationsname |
|
||||
| `page` | number | Nein | Seitennummer für Paginierung |
|
||||
| `per_page` | number | Nein | Ergebnisse pro Seite (max: 100) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Beschreibung |
|
||||
| --------- | ---- | ----------- |
|
||||
| `organizations` | json | Array von Organisationen, die den Suchkriterien entsprechen |
|
||||
| `metadata` | json | Paginierungsinformationen einschließlich page, per_page und total_entries |
|
||||
|
||||
### `apollo_organization_enrich`
|
||||
|
||||
Daten für eine einzelne Organisation mit Apollo anreichern
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Erforderlich | Beschreibung |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Ja | Apollo API-Schlüssel |
|
||||
| `organization_name` | string | Nein | Name der Organisation \(mindestens einer von organization_name oder domain ist erforderlich\) |
|
||||
| `domain` | string | Nein | Unternehmensdomäne \(z.B. apollo.io\) \(mindestens einer von domain oder organization_name ist erforderlich\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Beschreibung |
|
||||
| --------- | ---- | ----------- |
|
||||
| `organization` | json | Angereicherte Organisationsdaten von Apollo |
|
||||
| `metadata` | json | Anreicherungsmetadaten einschließlich des Anreicherungsstatus |
|
||||
|
||||
### `apollo_organization_bulk_enrich`
|
||||
|
||||
Daten für bis zu 10 Organisationen gleichzeitig mit Apollo anreichern
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Erforderlich | Beschreibung |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Ja | Apollo API-Schlüssel |
|
||||
| `organizations` | array | Ja | Array von zu anreichernden Organisationen \(max. 10\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Beschreibung |
|
||||
| --------- | ---- | ----------- |
|
||||
| `organizations` | json | Array von angereicherten Organisationsdaten |
|
||||
| `metadata` | json | Metadaten zur Massenanreicherung einschließlich Gesamt- und angereicherte Anzahl |
|
||||
|
||||
### `apollo_contact_create`
|
||||
|
||||
Einen neuen Kontakt in Ihrer Apollo-Datenbank erstellen
|
||||
|
||||
#### Eingabe
|
||||
|
||||
| Parameter | Typ | Erforderlich | Beschreibung |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Ja | Apollo API-Schlüssel |
|
||||
| `first_name` | string | Ja | Vorname des Kontakts |
|
||||
| `last_name` | string | Ja | Nachname des Kontakts |
|
||||
| `email` | string | Nein | E-Mail-Adresse des Kontakts |
|
||||
| `title` | string | Nein | Berufsbezeichnung |
|
||||
| `account_id` | string | Nein | Apollo-Konto-ID für die Zuordnung |
|
||||
| `owner_id` | string | Nein | Benutzer-ID des Kontaktinhabers |
|
||||
|
||||
#### Ausgabe
|
||||
|
||||
| Parameter | Typ | Beschreibung |
|
||||
| --------- | ---- | ----------- |
|
||||
| `contact` | json | Erstellte Kontaktdaten von Apollo |
|
||||
| `metadata` | json | Erstellungsmetadaten einschließlich Erstellungsstatus |
|
||||
|
||||
### `apollo_contact_update`
|
||||
|
||||
Einen bestehenden Kontakt in Ihrer Apollo-Datenbank aktualisieren
|
||||
|
||||
#### Eingabe
|
||||
|
||||
| Parameter | Typ | Erforderlich | Beschreibung |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Ja | Apollo API-Schlüssel |
|
||||
| `contact_id` | string | Ja | ID des zu aktualisierenden Kontakts |
|
||||
| `first_name` | string | Nein | Vorname des Kontakts |
|
||||
| `last_name` | string | Nein | Nachname des Kontakts |
|
||||
| `email` | string | Nein | E-Mail-Adresse |
|
||||
| `title` | string | Nein | Berufsbezeichnung |
|
||||
| `account_id` | string | Nein | Apollo-Konto-ID |
|
||||
| `owner_id` | string | Nein | Benutzer-ID des Kontaktinhabers |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Beschreibung |
|
||||
| --------- | ---- | ----------- |
|
||||
| `contact` | json | Aktualisierte Kontaktdaten von Apollo |
|
||||
| `metadata` | json | Aktualisierte Metadaten einschließlich des aktualisierten Status |
|
||||
|
||||
### `apollo_contact_search`
|
||||
|
||||
Dein Team durchsuchen
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Erforderlich | Beschreibung |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Ja | Apollo API-Schlüssel |
|
||||
| `q_keywords` | string | Nein | Suchbegriffe |
|
||||
| `contact_stage_ids` | array | Nein | Nach Kontaktphasen-IDs filtern |
|
||||
| `page` | number | Nein | Seitennummer für Paginierung |
|
||||
| `per_page` | number | Nein | Ergebnisse pro Seite \(max: 100\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Beschreibung |
|
||||
| --------- | ---- | ----------- |
|
||||
| `contacts` | json | Array von Kontakten, die den Suchkriterien entsprechen |
|
||||
| `metadata` | json | Paginierungsinformationen einschließlich page, per_page und total_entries |
|
||||
|
||||
### `apollo_contact_bulk_create`
|
||||
|
||||
Erstelle bis zu 100 Kontakte auf einmal in deiner Apollo-Datenbank. Unterstützt Deduplizierung, um das Erstellen von Duplikaten zu verhindern. Master-Schlüssel erforderlich.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Erforderlich | Beschreibung |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Ja | Apollo API-Schlüssel \(Master-Schlüssel erforderlich\) |
|
||||
| `contacts` | array | Ja | Array von zu erstellenden Kontakten \(max. 100\). Jeder Kontakt sollte first_name, last_name und optional email, title, account_id, owner_id enthalten |
|
||||
| `run_dedupe` | boolean | Nein | Aktiviere Deduplizierung, um das Erstellen von Duplikaten zu verhindern. Bei true werden bestehende Kontakte ohne Änderung zurückgegeben |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Typ | Beschreibung |
|
||||
| --------- | ---- | ----------- |
|
||||
| `created_contacts` | json | Array neu erstellter Kontakte |
|
||||
| `existing_contacts` | json | Array bestehender Kontakte \(wenn Deduplizierung aktiviert ist\) |
|
||||
| `metadata` | json | Metadaten zur Massenerstellung einschließlich Anzahl erstellter und bestehender Kontakte |
|
||||
|
||||
### `apollo_contact_bulk_update`
|
||||
|
||||
Aktualisieren Sie bis zu 100 bestehende Kontakte gleichzeitig in Ihrer Apollo-Datenbank. Jeder Kontakt muss ein ID-Feld enthalten. Master-Key erforderlich.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Typ | Erforderlich | Beschreibung |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Ja | Apollo API-Schlüssel \(Master-Key erforderlich\) |
|
||||
| `contacts` | array | Ja | Array zu aktualisierender Kontakte \(max. 100\). Jeder Kontakt muss ein ID-Feld enthalten und optional first_name, last_name, email, title, account_id, owner_id |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Typ | Beschreibung |
|
||||
| --------- | ---- | ----------- |
|
||||
| `updated_contacts` | json | Array erfolgreich aktualisierter Kontakte |
|
||||
| `failed_contacts` | json | Array von Kontakten, deren Aktualisierung fehlgeschlagen ist |
|
||||
| `metadata` | json | Metadaten zur Massenaktualisierung einschließlich Anzahl aktualisierter und fehlgeschlagener Kontakte |
|
||||
|
||||
### `apollo_account_create`
|
||||
|
||||
Erstellen Sie ein neues Konto (Unternehmen) in Ihrer Apollo-Datenbank
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Typ | Erforderlich | Beschreibung |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Ja | Apollo API-Schlüssel |
|
||||
| `name` | string | Ja | Unternehmensname |
|
||||
| `website_url` | string | Nein | Unternehmens-Website-URL |
|
||||
| `phone` | string | Nein | Telefonnummer des Unternehmens |
|
||||
| `owner_id` | string | Nein | Benutzer-ID des Kontoinhabers |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Beschreibung |
|
||||
| --------- | ---- | ----------- |
|
||||
| `account` | json | Erstellte Kontodaten von Apollo |
|
||||
| `metadata` | json | Erstellungsmetadaten einschließlich Erstellungsstatus |
|
||||
|
||||
### `apollo_account_update`
|
||||
|
||||
Aktualisieren eines vorhandenen Kontos in Ihrer Apollo-Datenbank
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Erforderlich | Beschreibung |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Ja | Apollo API-Schlüssel |
|
||||
| `account_id` | string | Ja | ID des zu aktualisierenden Kontos |
|
||||
| `name` | string | Nein | Firmenname |
|
||||
| `website_url` | string | Nein | Firmen-Website-URL |
|
||||
| `phone` | string | Nein | Telefonnummer des Unternehmens |
|
||||
| `owner_id` | string | Nein | Benutzer-ID des Kontoinhabers |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Beschreibung |
|
||||
| --------- | ---- | ----------- |
|
||||
| `account` | json | Aktualisierte Kontodaten von Apollo |
|
||||
| `metadata` | json | Aktualisierungsmetadaten einschließlich Aktualisierungsstatus |
|
||||
|
||||
### `apollo_account_search`
|
||||
|
||||
Durchsuchen Ihres Teams
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Erforderlich | Beschreibung |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Ja | Apollo API-Schlüssel \(Hauptschlüssel erforderlich\) |
|
||||
| `q_keywords` | string | Nein | Suchbegriffe für Kontodaten |
|
||||
| `owner_id` | string | Nein | Filtern nach Benutzer-ID des Kontoinhabers |
|
||||
| `account_stage_ids` | array | Nein | Filtern nach Kontophase-IDs |
|
||||
| `page` | number | Nein | Seitennummer für Paginierung |
|
||||
| `per_page` | number | Nein | Ergebnisse pro Seite \(max: 100\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Beschreibung |
|
||||
| --------- | ---- | ----------- |
|
||||
| `accounts` | json | Array von Konten, die den Suchkriterien entsprechen |
|
||||
| `metadata` | json | Paginierungsinformationen einschließlich page, per_page und total_entries |
|
||||
|
||||
### `apollo_account_bulk_create`
|
||||
|
||||
Erstellen Sie bis zu 100 Konten auf einmal in Ihrer Apollo-Datenbank. Hinweis: Apollo wendet keine Deduplizierung an - doppelte Konten können erstellt werden, wenn Einträge ähnliche Namen oder Domains haben. Master-Key erforderlich.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Erforderlich | Beschreibung |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Ja | Apollo API-Schlüssel \(Master-Key erforderlich\) |
|
||||
| `accounts` | array | Ja | Array von zu erstellenden Konten \(max. 100\). Jedes Konto sollte einen Namen \(erforderlich\) und optional website_url, phone, owner_id enthalten |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Beschreibung |
|
||||
| --------- | ---- | ----------- |
|
||||
| `created_accounts` | json | Array neu erstellter Konten |
|
||||
| `failed_accounts` | json | Array von Konten, deren Erstellung fehlgeschlagen ist |
|
||||
| `metadata` | json | Metadaten zur Massenerstellung einschließlich Anzahl erstellter und fehlgeschlagener Konten |
|
||||
|
||||
### `apollo_account_bulk_update`
|
||||
|
||||
Aktualisieren Sie bis zu 1000 bestehende Konten auf einmal in Ihrer Apollo-Datenbank (höheres Limit als bei Kontakten!). Jedes Konto muss ein id-Feld enthalten. Master-Key erforderlich.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Erforderlich | Beschreibung |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Ja | Apollo API-Schlüssel \(Master-Key erforderlich\) |
|
||||
| `accounts` | array | Ja | Array zu aktualisierender Konten \(max. 1000\). Jedes Konto muss ein id-Feld enthalten und optional name, website_url, phone, owner_id |
|
||||
|
||||
#### Ausgabe
|
||||
|
||||
| Parameter | Typ | Beschreibung |
|
||||
| --------- | ---- | ----------- |
|
||||
| `updated_accounts` | json | Array der erfolgreich aktualisierten Konten |
|
||||
| `failed_accounts` | json | Array der Konten, deren Aktualisierung fehlgeschlagen ist |
|
||||
| `metadata` | json | Metadaten zur Massenaktualisierung, einschließlich der Anzahl aktualisierter und fehlgeschlagener Konten |
|
||||
|
||||
### `apollo_opportunity_create`
|
||||
|
||||
Erstellen Sie einen neuen Deal für ein Konto in Ihrer Apollo-Datenbank (Master-Key erforderlich)
|
||||
|
||||
#### Eingabe
|
||||
|
||||
| Parameter | Typ | Erforderlich | Beschreibung |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Ja | Apollo API-Schlüssel \(Master-Key erforderlich\) |
|
||||
| `name` | string | Ja | Name der Opportunity/des Deals |
|
||||
| `account_id` | string | Ja | ID des Kontos, zu dem diese Opportunity gehört |
|
||||
| `amount` | number | Nein | Geldwert der Opportunity |
|
||||
| `stage_id` | string | Nein | ID der Deal-Phase |
|
||||
| `owner_id` | string | Nein | Benutzer-ID des Opportunity-Eigentümers |
|
||||
| `close_date` | string | Nein | Erwartetes Abschlussdatum \(ISO 8601-Format\) |
|
||||
| `description` | string | Nein | Beschreibung oder Notizen zur Opportunity |
|
||||
|
||||
#### Ausgabe
|
||||
|
||||
| Parameter | Typ | Beschreibung |
|
||||
| --------- | ---- | ----------- |
|
||||
| `opportunity` | json | Erstellte Opportunity-Daten von Apollo |
|
||||
| `metadata` | json | Erstellungsmetadaten einschließlich Erstellungsstatus |
|
||||
|
||||
### `apollo_opportunity_search`
|
||||
|
||||
Suchen und listen Sie alle Deals/Opportunities in Ihrem Team auf
|
||||
|
||||
#### Eingabe
|
||||
|
||||
| Parameter | Typ | Erforderlich | Beschreibung |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Ja | Apollo API-Schlüssel |
|
||||
| `q_keywords` | string | Nein | Suchbegriffe für die Suche in Opportunity-Namen |
|
||||
| `account_ids` | array | Nein | Nach bestimmten Account-IDs filtern |
|
||||
| `stage_ids` | array | Nein | Nach Deal-Phase-IDs filtern |
|
||||
| `owner_ids` | array | Nein | Nach Opportunity-Besitzer-IDs filtern |
|
||||
| `page` | number | Nein | Seitennummer für Paginierung |
|
||||
| `per_page` | number | Nein | Ergebnisse pro Seite \(max: 100\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Typ | Beschreibung |
|
||||
| --------- | ---- | ----------- |
|
||||
| `opportunities` | json | Array von Opportunities, die den Suchkriterien entsprechen |
|
||||
| `metadata` | json | Paginierungsinformationen einschließlich page, per_page und total_entries |
|
||||
|
||||
### `apollo_opportunity_get`
|
||||
|
||||
Vollständige Details eines bestimmten Deals/Opportunity anhand der ID abrufen
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Typ | Erforderlich | Beschreibung |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Ja | Apollo API-Schlüssel |
|
||||
| `opportunity_id` | string | Ja | ID der abzurufenden Opportunity |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Typ | Beschreibung |
|
||||
| --------- | ---- | ----------- |
|
||||
| `opportunity` | json | Vollständige Opportunity-Daten von Apollo |
|
||||
| `metadata` | json | Abruf-Metadaten einschließlich Gefunden-Status |
|
||||
|
||||
### `apollo_opportunity_update`
|
||||
|
||||
Einen bestehenden Deal/Opportunity in Ihrer Apollo-Datenbank aktualisieren
|
||||
|
||||
#### Eingabe
|
||||
|
||||
| Parameter | Typ | Erforderlich | Beschreibung |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Ja | Apollo API-Schlüssel |
|
||||
| `opportunity_id` | string | Ja | ID der zu aktualisierenden Opportunity |
|
||||
| `name` | string | Nein | Name der Opportunity/des Deals |
|
||||
| `amount` | number | Nein | Geldwert der Opportunity |
|
||||
| `stage_id` | string | Nein | ID der Deal-Phase |
|
||||
| `owner_id` | string | Nein | Benutzer-ID des Opportunity-Eigentümers |
|
||||
| `close_date` | string | Nein | Erwartetes Abschlussdatum (ISO 8601-Format) |
|
||||
| `description` | string | Nein | Beschreibung oder Notizen zur Opportunity |
|
||||
|
||||
#### Ausgabe
|
||||
|
||||
| Parameter | Typ | Beschreibung |
|
||||
| --------- | ---- | ----------- |
|
||||
| `opportunity` | json | Aktualisierte Opportunity-Daten von Apollo |
|
||||
| `metadata` | json | Aktualisierungsmetadaten einschließlich Aktualisierungsstatus |
|
||||
|
||||
### `apollo_sequence_search`
|
||||
|
||||
Suche nach Sequenzen/Kampagnen in deinem Team
|
||||
|
||||
#### Eingabe
|
||||
|
||||
| Parameter | Typ | Erforderlich | Beschreibung |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Ja | Apollo API-Schlüssel (Master-Schlüssel erforderlich) |
|
||||
| `q_name` | string | Nein | Sequenzen nach Namen durchsuchen |
|
||||
| `active` | boolean | Nein | Nach Aktivitätsstatus filtern (true für aktive Sequenzen, false für inaktive) |
|
||||
| `page` | number | Nein | Seitennummer für Paginierung |
|
||||
| `per_page` | number | Nein | Ergebnisse pro Seite (max: 100) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Beschreibung |
|
||||
| --------- | ---- | ----------- |
|
||||
| `sequences` | json | Array von Sequenzen/Kampagnen, die den Suchkriterien entsprechen |
|
||||
| `metadata` | json | Paginierungsinformationen einschließlich page, per_page und total_entries |
|
||||
|
||||
### `apollo_sequence_add_contacts`
|
||||
|
||||
Kontakte zu einer Apollo-Sequenz hinzufügen
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Erforderlich | Beschreibung |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Ja | Apollo API-Schlüssel (Master-Schlüssel erforderlich) |
|
||||
| `sequence_id` | string | Ja | ID der Sequenz, zu der Kontakte hinzugefügt werden sollen |
|
||||
| `contact_ids` | array | Ja | Array von Kontakt-IDs, die zur Sequenz hinzugefügt werden sollen |
|
||||
| `emailer_campaign_id` | string | Nein | Optionale E-Mail-Kampagnen-ID |
|
||||
| `send_email_from_user_id` | string | Nein | Benutzer-ID, von der E-Mails gesendet werden sollen |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Beschreibung |
|
||||
| --------- | ---- | ----------- |
|
||||
| `contacts_added` | json | Array von Kontakt-IDs, die zur Sequenz hinzugefügt wurden |
|
||||
| `metadata` | json | Sequenz-Metadaten einschließlich sequence_id und total_added Anzahl |
|
||||
|
||||
### `apollo_task_create`
|
||||
|
||||
Eine neue Aufgabe in Apollo erstellen
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Erforderlich | Beschreibung |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Ja | Apollo API-Schlüssel (Master-Schlüssel erforderlich) |
|
||||
| `note` | string | Ja | Aufgabennotiz/Beschreibung |
|
||||
| `contact_id` | string | Nein | Zu verknüpfende Kontakt-ID |
|
||||
| `account_id` | string | Nein | Zu verknüpfende Account-ID |
|
||||
| `due_at` | string | Nein | Fälligkeitsdatum im ISO-Format |
|
||||
| `priority` | string | Nein | Aufgabenpriorität |
|
||||
| `type` | string | Nein | Aufgabentyp |
|
||||
|
||||
#### Ausgabe
|
||||
|
||||
| Parameter | Typ | Beschreibung |
|
||||
| --------- | ---- | ----------- |
|
||||
| `task` | json | Erstellte Aufgabendaten von Apollo |
|
||||
| `metadata` | json | Erstellungsmetadaten einschließlich des Erstellungsstatus |
|
||||
|
||||
### `apollo_task_search`
|
||||
|
||||
Suche nach Aufgaben in Apollo
|
||||
|
||||
#### Eingabe
|
||||
|
||||
| Parameter | Typ | Erforderlich | Beschreibung |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Ja | Apollo API-Schlüssel (Master-Schlüssel erforderlich) |
|
||||
| `contact_id` | string | Nein | Nach Kontakt-ID filtern |
|
||||
| `account_id` | string | Nein | Nach Konto-ID filtern |
|
||||
| `completed` | boolean | Nein | Nach Abschlussstatus filtern |
|
||||
| `page` | number | Nein | Seitennummer für Paginierung |
|
||||
| `per_page` | number | Nein | Ergebnisse pro Seite (max: 100) |
|
||||
|
||||
#### Ausgabe
|
||||
|
||||
| Parameter | Typ | Beschreibung |
|
||||
| --------- | ---- | ----------- |
|
||||
| `tasks` | json | Array von Aufgaben, die den Suchkriterien entsprechen |
|
||||
| `metadata` | json | Paginierungsinformationen einschließlich Seite, pro_Seite und Gesamteinträge |
|
||||
|
||||
### `apollo_email_accounts`
|
||||
|
||||
Liste des Teams abrufen
|
||||
|
||||
#### Eingabe
|
||||
|
||||
| Parameter | Typ | Erforderlich | Beschreibung |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Ja | Apollo API-Schlüssel (Master-Schlüssel erforderlich) |
|
||||
|
||||
#### Ausgabe
|
||||
|
||||
| Parameter | Typ | Beschreibung |
|
||||
| --------- | ---- | ----------- |
|
||||
| `email_accounts` | json | Array von Team-E-Mail-Konten, die in Apollo verknüpft sind |
|
||||
| `metadata` | json | Metadaten einschließlich der Gesamtanzahl von E-Mail-Konten |
|
||||
|
||||
## Notizen
|
||||
|
||||
- Kategorie: `tools`
|
||||
- Typ: `apollo`
|
||||
@@ -71,8 +71,10 @@ Senden Sie Nachrichten an Slack-Kanäle oder Benutzer über die Slack-API. Unter
|
||||
|
||||
| Parameter | Typ | Beschreibung |
|
||||
| --------- | ---- | ----------- |
|
||||
| `ts` | string | Zeitstempel der Nachricht |
|
||||
| `channel` | string | Kanal-ID, wohin die Nachricht gesendet wurde |
|
||||
| `message` | object | Vollständiges Nachrichtenobjekt mit allen von Slack zurückgegebenen Eigenschaften |
|
||||
| `ts` | string | Nachrichtenzeitstempel |
|
||||
| `channel` | string | Kanal-ID, in dem die Nachricht gesendet wurde |
|
||||
| `fileCount` | number | Anzahl der hochgeladenen Dateien \(wenn Dateien angehängt sind\) |
|
||||
|
||||
### `slack_canvas`
|
||||
|
||||
@@ -155,6 +157,7 @@ Eine zuvor vom Bot in Slack gesendete Nachricht aktualisieren
|
||||
|
||||
| Parameter | Typ | Beschreibung |
|
||||
| --------- | ---- | ----------- |
|
||||
| `message` | object | Vollständiges aktualisiertes Nachrichtenobjekt mit allen von Slack zurückgegebenen Eigenschaften |
|
||||
| `content` | string | Erfolgsmeldung |
|
||||
| `metadata` | object | Metadaten der aktualisierten Nachricht |
|
||||
|
||||
|
||||
579
apps/docs/content/docs/en/tools/apollo.mdx
Normal file
579
apps/docs/content/docs/en/tools/apollo.mdx
Normal file
@@ -0,0 +1,579 @@
|
||||
---
|
||||
title: Apollo
|
||||
description: Search, enrich, and manage contacts with Apollo.io
|
||||
---
|
||||
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="apollo"
|
||||
color="#EBF212"
|
||||
/>
|
||||
|
||||
{/* MANUAL-CONTENT-START:intro */}
|
||||
[Apollo.io](https://apollo.io/) is a leading sales intelligence and engagement platform that empowers users to find, enrich, and engage contacts and companies at scale. Apollo.io combines an extensive contact database with robust enrichment and workflow automation tools, assisting sales, marketing, and recruiting teams to accelerate growth.
|
||||
|
||||
With Apollo.io, you can:
|
||||
|
||||
- **Search millions of contacts and companies**: Find precise leads using advanced filters
|
||||
- **Enrich leads and accounts**: Fill in missing details with verified data and up-to-date information
|
||||
- **Manage and organize CRM records**: Keep your people and company data accurate and actionable
|
||||
- **Automate outreach**: Add contacts to sequences and create follow-up tasks directly from Apollo.io
|
||||
|
||||
In Sim, the Apollo.io integration allows your agents to perform core Apollo operations programmatically:
|
||||
|
||||
- **Search people and companies**: Use `apollo_people_search` to discover new leads using flexible filters.
|
||||
- **Enrich people data**: Use `apollo_people_enrich` to augment contacts with verified information.
|
||||
- **Enrich people in bulk**: Use `apollo_people_bulk_enrich` for large-scale enrichment of multiple contacts at once.
|
||||
- **Search and enrich companies**: Use `apollo_company_search` and `apollo_company_enrich` to discover and update key company information.
|
||||
|
||||
This enables your agents to build powerful workflows for prospecting, CRM enrichment, and automation without manual data entry or switching tabs. Integrate Apollo.io as a dynamic data source and CRM engine — empowering your agents to identify, qualify, and reach out to leads seamlessly as part of their daily operations.
|
||||
{/* MANUAL-CONTENT-END */}
|
||||
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Integrates Apollo.io into the workflow. Search for people and companies, enrich contact data, manage your CRM contacts and accounts, add contacts to sequences, and create tasks.
|
||||
|
||||
|
||||
|
||||
## Tools
|
||||
|
||||
### `apollo_people_search`
|
||||
|
||||
Search Apollo
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Apollo API key |
|
||||
| `person_titles` | array | No | Job titles to search for \(e.g., \["CEO", "VP of Sales"\]\) |
|
||||
| `person_locations` | array | No | Locations to search in \(e.g., \["San Francisco, CA", "New York, NY"\]\) |
|
||||
| `person_seniorities` | array | No | Seniority levels \(e.g., \["senior", "executive", "manager"\]\) |
|
||||
| `organization_names` | array | No | Company names to search within |
|
||||
| `q_keywords` | string | No | Keywords to search for |
|
||||
| `page` | number | No | Page number for pagination \(default: 1\) |
|
||||
| `per_page` | number | No | Results per page \(default: 25, max: 100\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `people` | json | Array of people matching the search criteria |
|
||||
| `metadata` | json | Pagination information including page, per_page, and total_entries |
|
||||
|
||||
### `apollo_people_enrich`
|
||||
|
||||
Enrich data for a single person using Apollo
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Apollo API key |
|
||||
| `first_name` | string | No | First name of the person |
|
||||
| `last_name` | string | No | Last name of the person |
|
||||
| `email` | string | No | Email address of the person |
|
||||
| `organization_name` | string | No | Company name where the person works |
|
||||
| `domain` | string | No | Company domain \(e.g., apollo.io\) |
|
||||
| `linkedin_url` | string | No | LinkedIn profile URL |
|
||||
| `reveal_personal_emails` | boolean | No | Reveal personal email addresses \(uses credits\) |
|
||||
| `reveal_phone_number` | boolean | No | Reveal phone numbers \(uses credits\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `person` | json | Enriched person data from Apollo |
|
||||
| `metadata` | json | Enrichment metadata including enriched status |
|
||||
|
||||
### `apollo_people_bulk_enrich`
|
||||
|
||||
Enrich data for up to 10 people at once using Apollo
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Apollo API key |
|
||||
| `people` | array | Yes | Array of people to enrich \(max 10\) |
|
||||
| `reveal_personal_emails` | boolean | No | Reveal personal email addresses \(uses credits\) |
|
||||
| `reveal_phone_number` | boolean | No | Reveal phone numbers \(uses credits\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `people` | json | Array of enriched people data |
|
||||
| `metadata` | json | Bulk enrichment metadata including total and enriched counts |
|
||||
|
||||
### `apollo_organization_search`
|
||||
|
||||
Search Apollo
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Apollo API key |
|
||||
| `organization_locations` | array | No | Company locations to search |
|
||||
| `organization_num_employees_ranges` | array | No | Employee count ranges \(e.g., \["1-10", "11-50"\]\) |
|
||||
| `q_organization_keyword_tags` | array | No | Industry or keyword tags |
|
||||
| `q_organization_name` | string | No | Organization name to search for |
|
||||
| `page` | number | No | Page number for pagination |
|
||||
| `per_page` | number | No | Results per page \(max: 100\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `organizations` | json | Array of organizations matching the search criteria |
|
||||
| `metadata` | json | Pagination information including page, per_page, and total_entries |
|
||||
|
||||
### `apollo_organization_enrich`
|
||||
|
||||
Enrich data for a single organization using Apollo
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Apollo API key |
|
||||
| `organization_name` | string | No | Name of the organization \(at least one of organization_name or domain is required\) |
|
||||
| `domain` | string | No | Company domain \(e.g., apollo.io\) \(at least one of domain or organization_name is required\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `organization` | json | Enriched organization data from Apollo |
|
||||
| `metadata` | json | Enrichment metadata including enriched status |
|
||||
|
||||
### `apollo_organization_bulk_enrich`
|
||||
|
||||
Enrich data for up to 10 organizations at once using Apollo
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Apollo API key |
|
||||
| `organizations` | array | Yes | Array of organizations to enrich \(max 10\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `organizations` | json | Array of enriched organization data |
|
||||
| `metadata` | json | Bulk enrichment metadata including total and enriched counts |
|
||||
|
||||
### `apollo_contact_create`
|
||||
|
||||
Create a new contact in your Apollo database
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Apollo API key |
|
||||
| `first_name` | string | Yes | First name of the contact |
|
||||
| `last_name` | string | Yes | Last name of the contact |
|
||||
| `email` | string | No | Email address of the contact |
|
||||
| `title` | string | No | Job title |
|
||||
| `account_id` | string | No | Apollo account ID to associate with |
|
||||
| `owner_id` | string | No | User ID of the contact owner |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `contact` | json | Created contact data from Apollo |
|
||||
| `metadata` | json | Creation metadata including created status |
|
||||
|
||||
### `apollo_contact_update`
|
||||
|
||||
Update an existing contact in your Apollo database
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Apollo API key |
|
||||
| `contact_id` | string | Yes | ID of the contact to update |
|
||||
| `first_name` | string | No | First name of the contact |
|
||||
| `last_name` | string | No | Last name of the contact |
|
||||
| `email` | string | No | Email address |
|
||||
| `title` | string | No | Job title |
|
||||
| `account_id` | string | No | Apollo account ID |
|
||||
| `owner_id` | string | No | User ID of the contact owner |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `contact` | json | Updated contact data from Apollo |
|
||||
| `metadata` | json | Update metadata including updated status |
|
||||
|
||||
### `apollo_contact_search`
|
||||
|
||||
Search your team
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Apollo API key |
|
||||
| `q_keywords` | string | No | Keywords to search for |
|
||||
| `contact_stage_ids` | array | No | Filter by contact stage IDs |
|
||||
| `page` | number | No | Page number for pagination |
|
||||
| `per_page` | number | No | Results per page \(max: 100\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `contacts` | json | Array of contacts matching the search criteria |
|
||||
| `metadata` | json | Pagination information including page, per_page, and total_entries |
|
||||
|
||||
### `apollo_contact_bulk_create`
|
||||
|
||||
Create up to 100 contacts at once in your Apollo database. Supports deduplication to prevent creating duplicate contacts. Master key required.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Apollo API key \(master key required\) |
|
||||
| `contacts` | array | Yes | Array of contacts to create \(max 100\). Each contact should include first_name, last_name, and optionally email, title, account_id, owner_id |
|
||||
| `run_dedupe` | boolean | No | Enable deduplication to prevent creating duplicate contacts. When true, existing contacts are returned without modification |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `created_contacts` | json | Array of newly created contacts |
|
||||
| `existing_contacts` | json | Array of existing contacts \(when deduplication is enabled\) |
|
||||
| `metadata` | json | Bulk creation metadata including counts of created and existing contacts |
|
||||
|
||||
### `apollo_contact_bulk_update`
|
||||
|
||||
Update up to 100 existing contacts at once in your Apollo database. Each contact must include an id field. Master key required.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Apollo API key \(master key required\) |
|
||||
| `contacts` | array | Yes | Array of contacts to update \(max 100\). Each contact must include id field, and optionally first_name, last_name, email, title, account_id, owner_id |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `updated_contacts` | json | Array of successfully updated contacts |
|
||||
| `failed_contacts` | json | Array of contacts that failed to update |
|
||||
| `metadata` | json | Bulk update metadata including counts of updated and failed contacts |
|
||||
|
||||
### `apollo_account_create`
|
||||
|
||||
Create a new account (company) in your Apollo database
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Apollo API key |
|
||||
| `name` | string | Yes | Company name |
|
||||
| `website_url` | string | No | Company website URL |
|
||||
| `phone` | string | No | Company phone number |
|
||||
| `owner_id` | string | No | User ID of the account owner |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `account` | json | Created account data from Apollo |
|
||||
| `metadata` | json | Creation metadata including created status |
|
||||
|
||||
### `apollo_account_update`
|
||||
|
||||
Update an existing account in your Apollo database
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Apollo API key |
|
||||
| `account_id` | string | Yes | ID of the account to update |
|
||||
| `name` | string | No | Company name |
|
||||
| `website_url` | string | No | Company website URL |
|
||||
| `phone` | string | No | Company phone number |
|
||||
| `owner_id` | string | No | User ID of the account owner |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `account` | json | Updated account data from Apollo |
|
||||
| `metadata` | json | Update metadata including updated status |
|
||||
|
||||
### `apollo_account_search`
|
||||
|
||||
Search your team
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Apollo API key \(master key required\) |
|
||||
| `q_keywords` | string | No | Keywords to search for in account data |
|
||||
| `owner_id` | string | No | Filter by account owner user ID |
|
||||
| `account_stage_ids` | array | No | Filter by account stage IDs |
|
||||
| `page` | number | No | Page number for pagination |
|
||||
| `per_page` | number | No | Results per page \(max: 100\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `accounts` | json | Array of accounts matching the search criteria |
|
||||
| `metadata` | json | Pagination information including page, per_page, and total_entries |
|
||||
|
||||
### `apollo_account_bulk_create`
|
||||
|
||||
Create up to 100 accounts at once in your Apollo database. Note: Apollo does not apply deduplication - duplicate accounts may be created if entries share similar names or domains. Master key required.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Apollo API key \(master key required\) |
|
||||
| `accounts` | array | Yes | Array of accounts to create \(max 100\). Each account should include name \(required\), and optionally website_url, phone, owner_id |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `created_accounts` | json | Array of newly created accounts |
|
||||
| `failed_accounts` | json | Array of accounts that failed to create |
|
||||
| `metadata` | json | Bulk creation metadata including counts of created and failed accounts |
|
||||
|
||||
### `apollo_account_bulk_update`
|
||||
|
||||
Update up to 1000 existing accounts at once in your Apollo database (higher limit than contacts!). Each account must include an id field. Master key required.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Apollo API key \(master key required\) |
|
||||
| `accounts` | array | Yes | Array of accounts to update \(max 1000\). Each account must include id field, and optionally name, website_url, phone, owner_id |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `updated_accounts` | json | Array of successfully updated accounts |
|
||||
| `failed_accounts` | json | Array of accounts that failed to update |
|
||||
| `metadata` | json | Bulk update metadata including counts of updated and failed accounts |
|
||||
|
||||
### `apollo_opportunity_create`
|
||||
|
||||
Create a new deal for an account in your Apollo database (master key required)
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Apollo API key \(master key required\) |
|
||||
| `name` | string | Yes | Name of the opportunity/deal |
|
||||
| `account_id` | string | Yes | ID of the account this opportunity belongs to |
|
||||
| `amount` | number | No | Monetary value of the opportunity |
|
||||
| `stage_id` | string | No | ID of the deal stage |
|
||||
| `owner_id` | string | No | User ID of the opportunity owner |
|
||||
| `close_date` | string | No | Expected close date \(ISO 8601 format\) |
|
||||
| `description` | string | No | Description or notes about the opportunity |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `opportunity` | json | Created opportunity data from Apollo |
|
||||
| `metadata` | json | Creation metadata including created status |
|
||||
|
||||
### `apollo_opportunity_search`
|
||||
|
||||
Search and list all deals/opportunities in your team
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Apollo API key |
|
||||
| `q_keywords` | string | No | Keywords to search for in opportunity names |
|
||||
| `account_ids` | array | No | Filter by specific account IDs |
|
||||
| `stage_ids` | array | No | Filter by deal stage IDs |
|
||||
| `owner_ids` | array | No | Filter by opportunity owner IDs |
|
||||
| `page` | number | No | Page number for pagination |
|
||||
| `per_page` | number | No | Results per page \(max: 100\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `opportunities` | json | Array of opportunities matching the search criteria |
|
||||
| `metadata` | json | Pagination information including page, per_page, and total_entries |
|
||||
|
||||
### `apollo_opportunity_get`
|
||||
|
||||
Retrieve complete details of a specific deal/opportunity by ID
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Apollo API key |
|
||||
| `opportunity_id` | string | Yes | ID of the opportunity to retrieve |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `opportunity` | json | Complete opportunity data from Apollo |
|
||||
| `metadata` | json | Retrieval metadata including found status |
|
||||
|
||||
### `apollo_opportunity_update`
|
||||
|
||||
Update an existing deal/opportunity in your Apollo database
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Apollo API key |
|
||||
| `opportunity_id` | string | Yes | ID of the opportunity to update |
|
||||
| `name` | string | No | Name of the opportunity/deal |
|
||||
| `amount` | number | No | Monetary value of the opportunity |
|
||||
| `stage_id` | string | No | ID of the deal stage |
|
||||
| `owner_id` | string | No | User ID of the opportunity owner |
|
||||
| `close_date` | string | No | Expected close date \(ISO 8601 format\) |
|
||||
| `description` | string | No | Description or notes about the opportunity |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `opportunity` | json | Updated opportunity data from Apollo |
|
||||
| `metadata` | json | Update metadata including updated status |
|
||||
|
||||
### `apollo_sequence_search`
|
||||
|
||||
Search for sequences/campaigns in your team
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Apollo API key \(master key required\) |
|
||||
| `q_name` | string | No | Search sequences by name |
|
||||
| `active` | boolean | No | Filter by active status \(true for active sequences, false for inactive\) |
|
||||
| `page` | number | No | Page number for pagination |
|
||||
| `per_page` | number | No | Results per page \(max: 100\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `sequences` | json | Array of sequences/campaigns matching the search criteria |
|
||||
| `metadata` | json | Pagination information including page, per_page, and total_entries |
|
||||
|
||||
### `apollo_sequence_add_contacts`
|
||||
|
||||
Add contacts to an Apollo sequence
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Apollo API key \(master key required\) |
|
||||
| `sequence_id` | string | Yes | ID of the sequence to add contacts to |
|
||||
| `contact_ids` | array | Yes | Array of contact IDs to add to the sequence |
|
||||
| `emailer_campaign_id` | string | No | Optional emailer campaign ID |
|
||||
| `send_email_from_user_id` | string | No | User ID to send emails from |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `contacts_added` | json | Array of contact IDs added to the sequence |
|
||||
| `metadata` | json | Sequence metadata including sequence_id and total_added count |
|
||||
|
||||
### `apollo_task_create`
|
||||
|
||||
Create a new task in Apollo
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Apollo API key \(master key required\) |
|
||||
| `note` | string | Yes | Task note/description |
|
||||
| `contact_id` | string | No | Contact ID to associate with |
|
||||
| `account_id` | string | No | Account ID to associate with |
|
||||
| `due_at` | string | No | Due date in ISO format |
|
||||
| `priority` | string | No | Task priority |
|
||||
| `type` | string | No | Task type |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `task` | json | Created task data from Apollo |
|
||||
| `metadata` | json | Creation metadata including created status |
|
||||
|
||||
### `apollo_task_search`
|
||||
|
||||
Search for tasks in Apollo
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Apollo API key \(master key required\) |
|
||||
| `contact_id` | string | No | Filter by contact ID |
|
||||
| `account_id` | string | No | Filter by account ID |
|
||||
| `completed` | boolean | No | Filter by completion status |
|
||||
| `page` | number | No | Page number for pagination |
|
||||
| `per_page` | number | No | Results per page \(max: 100\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `tasks` | json | Array of tasks matching the search criteria |
|
||||
| `metadata` | json | Pagination information including page, per_page, and total_entries |
|
||||
|
||||
### `apollo_email_accounts`
|
||||
|
||||
Get list of team
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Apollo API key \(master key required\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `email_accounts` | json | Array of team email accounts linked in Apollo |
|
||||
| `metadata` | json | Metadata including total count of email accounts |
|
||||
|
||||
|
||||
|
||||
## Notes
|
||||
|
||||
- Category: `tools`
|
||||
- Type: `apollo`
|
||||
@@ -2,6 +2,7 @@
|
||||
"pages": [
|
||||
"index",
|
||||
"airtable",
|
||||
"apollo",
|
||||
"arxiv",
|
||||
"asana",
|
||||
"browser_use",
|
||||
|
||||
@@ -73,8 +73,10 @@ Send messages to Slack channels or users through the Slack API. Supports Slack m
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `message` | object | Complete message object with all properties returned by Slack |
|
||||
| `ts` | string | Message timestamp |
|
||||
| `channel` | string | Channel ID where message was sent |
|
||||
| `fileCount` | number | Number of files uploaded \(when files are attached\) |
|
||||
|
||||
### `slack_canvas`
|
||||
|
||||
@@ -157,6 +159,7 @@ Update a message previously sent by the bot in Slack
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `message` | object | Complete updated message object with all properties returned by Slack |
|
||||
| `content` | string | Success message |
|
||||
| `metadata` | object | Updated message metadata |
|
||||
|
||||
|
||||
574
apps/docs/content/docs/es/tools/apollo.mdx
Normal file
574
apps/docs/content/docs/es/tools/apollo.mdx
Normal file
@@ -0,0 +1,574 @@
|
||||
---
|
||||
title: Apollo
|
||||
description: Busca, enriquece y gestiona contactos con Apollo.io
|
||||
---
|
||||
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="apollo"
|
||||
color="#EBF212"
|
||||
/>
|
||||
|
||||
{/* MANUAL-CONTENT-START:intro */}
|
||||
[Apollo.io](https://apollo.io/) es una plataforma líder de inteligencia y participación de ventas que permite a los usuarios encontrar, enriquecer e interactuar con contactos y empresas a gran escala. Apollo.io combina una extensa base de datos de contactos con sólidas herramientas de enriquecimiento y automatización de flujos de trabajo, ayudando a los equipos de ventas, marketing y reclutamiento a acelerar el crecimiento.
|
||||
|
||||
Con Apollo.io, puedes:
|
||||
|
||||
- **Buscar millones de contactos y empresas**: Encuentra leads precisos utilizando filtros avanzados
|
||||
- **Enriquecer leads y cuentas**: Completa detalles faltantes con datos verificados e información actualizada
|
||||
- **Gestionar y organizar registros CRM**: Mantén tus datos de personas y empresas precisos y accionables
|
||||
- **Automatizar el alcance**: Añade contactos a secuencias y crea tareas de seguimiento directamente desde Apollo.io
|
||||
|
||||
En Sim, la integración de Apollo.io permite a tus agentes realizar operaciones básicas de Apollo de forma programática:
|
||||
|
||||
- **Buscar personas y empresas**: Usa `apollo_people_search` para descubrir nuevos leads utilizando filtros flexibles.
|
||||
- **Enriquecer datos de personas**: Usa `apollo_people_enrich` para aumentar contactos con información verificada.
|
||||
- **Enriquecer personas en masa**: Usa `apollo_people_bulk_enrich` para el enriquecimiento a gran escala de múltiples contactos a la vez.
|
||||
- **Buscar y enriquecer empresas**: Usa `apollo_company_search` y `apollo_company_enrich` para descubrir y actualizar información clave de empresas.
|
||||
|
||||
Esto permite a tus agentes construir flujos de trabajo potentes para prospección, enriquecimiento de CRM y automatización sin entrada manual de datos o cambio de pestañas. Integra Apollo.io como una fuente dinámica de datos y motor CRM — capacitando a tus agentes para identificar, calificar y contactar leads sin problemas como parte de sus operaciones diarias.
|
||||
{/* MANUAL-CONTENT-END */}
|
||||
|
||||
## Instrucciones de uso
|
||||
|
||||
Integra Apollo.io en el flujo de trabajo. Busca personas y empresas, enriquece datos de contacto, gestiona tus contactos y cuentas de CRM, añade contactos a secuencias y crea tareas.
|
||||
|
||||
## Herramientas
|
||||
|
||||
### `apollo_people_search`
|
||||
|
||||
Buscar en Apollo
|
||||
|
||||
#### Entrada
|
||||
|
||||
| Parámetro | Tipo | Obligatorio | Descripción |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Sí | Clave API de Apollo |
|
||||
| `person_titles` | array | No | Títulos de trabajo para buscar (p. ej., ["CEO", "VP of Sales"]) |
|
||||
| `person_locations` | array | No | Ubicaciones donde buscar (p. ej., ["San Francisco, CA", "New York, NY"]) |
|
||||
| `person_seniorities` | array | No | Niveles de antigüedad (p. ej., ["senior", "executive", "manager"]) |
|
||||
| `organization_names` | array | No | Nombres de empresas donde buscar |
|
||||
| `q_keywords` | string | No | Palabras clave para buscar |
|
||||
| `page` | number | No | Número de página para paginación (predeterminado: 1) |
|
||||
| `per_page` | number | No | Resultados por página (predeterminado: 25, máx: 100) |
|
||||
|
||||
#### Salida
|
||||
|
||||
| Parámetro | Tipo | Descripción |
|
||||
| --------- | ---- | ----------- |
|
||||
| `people` | json | Array de personas que coinciden con los criterios de búsqueda |
|
||||
| `metadata` | json | Información de paginación incluyendo página, por_página y total_entradas |
|
||||
|
||||
### `apollo_people_enrich`
|
||||
|
||||
Enriquecer datos de una persona usando Apollo
|
||||
|
||||
#### Entrada
|
||||
|
||||
| Parámetro | Tipo | Obligatorio | Descripción |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Sí | Clave API de Apollo |
|
||||
| `first_name` | string | No | Nombre de la persona |
|
||||
| `last_name` | string | No | Apellido de la persona |
|
||||
| `email` | string | No | Dirección de correo electrónico de la persona |
|
||||
| `organization_name` | string | No | Nombre de la empresa donde trabaja la persona |
|
||||
| `domain` | string | No | Dominio de la empresa (p. ej., apollo.io) |
|
||||
| `linkedin_url` | string | No | URL del perfil de LinkedIn |
|
||||
| `reveal_personal_emails` | boolean | No | Revelar direcciones de correo electrónico personales (usa créditos) |
|
||||
| `reveal_phone_number` | boolean | No | Revelar números de teléfono (usa créditos) |
|
||||
|
||||
#### Salida
|
||||
|
||||
| Parámetro | Tipo | Descripción |
|
||||
| --------- | ---- | ----------- |
|
||||
| `person` | json | Datos enriquecidos de la persona desde Apollo |
|
||||
| `metadata` | json | Metadatos de enriquecimiento incluyendo estado de enriquecimiento |
|
||||
|
||||
### `apollo_people_bulk_enrich`
|
||||
|
||||
Enriquece datos de hasta 10 personas a la vez usando Apollo
|
||||
|
||||
#### Entrada
|
||||
|
||||
| Parámetro | Tipo | Obligatorio | Descripción |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Sí | Clave API de Apollo |
|
||||
| `people` | array | Sí | Array de personas para enriquecer \(máximo 10\) |
|
||||
| `reveal_personal_emails` | boolean | No | Revelar direcciones de correo electrónico personales \(usa créditos\) |
|
||||
| `reveal_phone_number` | boolean | No | Revelar números de teléfono \(usa créditos\) |
|
||||
|
||||
#### Salida
|
||||
|
||||
| Parámetro | Tipo | Descripción |
|
||||
| --------- | ---- | ----------- |
|
||||
| `people` | json | Array de datos de personas enriquecidos |
|
||||
| `metadata` | json | Metadatos de enriquecimiento masivo incluyendo recuentos totales y enriquecidos |
|
||||
|
||||
### `apollo_organization_search`
|
||||
|
||||
Buscar en Apollo
|
||||
|
||||
#### Entrada
|
||||
|
||||
| Parámetro | Tipo | Obligatorio | Descripción |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Sí | Clave API de Apollo |
|
||||
| `organization_locations` | array | No | Ubicaciones de empresas para buscar |
|
||||
| `organization_num_employees_ranges` | array | No | Rangos de número de empleados \(p. ej., \["1-10", "11-50"\]\) |
|
||||
| `q_organization_keyword_tags` | array | No | Etiquetas de industria o palabras clave |
|
||||
| `q_organization_name` | string | No | Nombre de la organización a buscar |
|
||||
| `page` | number | No | Número de página para paginación |
|
||||
| `per_page` | number | No | Resultados por página \(máx: 100\) |
|
||||
|
||||
#### Salida
|
||||
|
||||
| Parámetro | Tipo | Descripción |
|
||||
| --------- | ---- | ----------- |
|
||||
| `organizations` | json | Array de organizaciones que coinciden con los criterios de búsqueda |
|
||||
| `metadata` | json | Información de paginación que incluye page, per_page y total_entries |
|
||||
|
||||
### `apollo_organization_enrich`
|
||||
|
||||
Enriquecer datos para una sola organización usando Apollo
|
||||
|
||||
#### Entrada
|
||||
|
||||
| Parámetro | Tipo | Obligatorio | Descripción |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Sí | Clave API de Apollo |
|
||||
| `organization_name` | string | No | Nombre de la organización \(se requiere al menos uno de organization_name o domain\) |
|
||||
| `domain` | string | No | Dominio de la empresa \(por ejemplo, apollo.io\) \(se requiere al menos uno de domain u organization_name\) |
|
||||
|
||||
#### Salida
|
||||
|
||||
| Parámetro | Tipo | Descripción |
|
||||
| --------- | ---- | ----------- |
|
||||
| `organization` | json | Datos enriquecidos de la organización desde Apollo |
|
||||
| `metadata` | json | Metadatos de enriquecimiento que incluyen el estado de enriquecimiento |
|
||||
|
||||
### `apollo_organization_bulk_enrich`
|
||||
|
||||
Enriquecer datos para hasta 10 organizaciones a la vez usando Apollo
|
||||
|
||||
#### Entrada
|
||||
|
||||
| Parámetro | Tipo | Obligatorio | Descripción |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Sí | Clave API de Apollo |
|
||||
| `organizations` | array | Sí | Array de organizaciones para enriquecer \(máximo 10\) |
|
||||
|
||||
#### Salida
|
||||
|
||||
| Parámetro | Tipo | Descripción |
|
||||
| --------- | ---- | ----------- |
|
||||
| `organizations` | json | Array de datos de organizaciones enriquecidos |
|
||||
| `metadata` | json | Metadatos de enriquecimiento masivo que incluyen recuentos totales y enriquecidos |
|
||||
|
||||
### `apollo_contact_create`
|
||||
|
||||
Crear un nuevo contacto en tu base de datos de Apollo
|
||||
|
||||
#### Entrada
|
||||
|
||||
| Parámetro | Tipo | Obligatorio | Descripción |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Sí | Clave API de Apollo |
|
||||
| `first_name` | string | Sí | Nombre del contacto |
|
||||
| `last_name` | string | Sí | Apellido del contacto |
|
||||
| `email` | string | No | Dirección de correo electrónico del contacto |
|
||||
| `title` | string | No | Cargo laboral |
|
||||
| `account_id` | string | No | ID de cuenta de Apollo para asociar |
|
||||
| `owner_id` | string | No | ID de usuario del propietario del contacto |
|
||||
|
||||
#### Salida
|
||||
|
||||
| Parámetro | Tipo | Descripción |
|
||||
| --------- | ---- | ----------- |
|
||||
| `contact` | json | Datos del contacto creado en Apollo |
|
||||
| `metadata` | json | Metadatos de creación incluyendo estado de creación |
|
||||
|
||||
### `apollo_contact_update`
|
||||
|
||||
Actualizar un contacto existente en tu base de datos de Apollo
|
||||
|
||||
#### Entrada
|
||||
|
||||
| Parámetro | Tipo | Obligatorio | Descripción |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Sí | Clave API de Apollo |
|
||||
| `contact_id` | string | Sí | ID del contacto a actualizar |
|
||||
| `first_name` | string | No | Nombre del contacto |
|
||||
| `last_name` | string | No | Apellido del contacto |
|
||||
| `email` | string | No | Dirección de correo electrónico |
|
||||
| `title` | string | No | Cargo laboral |
|
||||
| `account_id` | string | No | ID de cuenta de Apollo |
|
||||
| `owner_id` | string | No | ID de usuario del propietario del contacto |
|
||||
|
||||
#### Salida
|
||||
|
||||
| Parámetro | Tipo | Descripción |
|
||||
| --------- | ---- | ----------- |
|
||||
| `contact` | json | Datos de contacto actualizados de Apollo |
|
||||
| `metadata` | json | Metadatos de actualización incluyendo estado actualizado |
|
||||
|
||||
### `apollo_contact_search`
|
||||
|
||||
Buscar tu equipo
|
||||
|
||||
#### Entrada
|
||||
|
||||
| Parámetro | Tipo | Obligatorio | Descripción |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Sí | Clave API de Apollo |
|
||||
| `q_keywords` | string | No | Palabras clave para buscar |
|
||||
| `contact_stage_ids` | array | No | Filtrar por IDs de etapa de contacto |
|
||||
| `page` | number | No | Número de página para paginación |
|
||||
| `per_page` | number | No | Resultados por página \(máx: 100\) |
|
||||
|
||||
#### Salida
|
||||
|
||||
| Parámetro | Tipo | Descripción |
|
||||
| --------- | ---- | ----------- |
|
||||
| `contacts` | json | Array de contactos que coinciden con los criterios de búsqueda |
|
||||
| `metadata` | json | Información de paginación incluyendo page, per_page y total_entries |
|
||||
|
||||
### `apollo_contact_bulk_create`
|
||||
|
||||
Crea hasta 100 contactos a la vez en tu base de datos de Apollo. Admite deduplicación para evitar crear contactos duplicados. Se requiere clave maestra.
|
||||
|
||||
#### Entrada
|
||||
|
||||
| Parámetro | Tipo | Obligatorio | Descripción |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Sí | Clave API de Apollo \(se requiere clave maestra\) |
|
||||
| `contacts` | array | Sí | Array de contactos para crear \(máx 100\). Cada contacto debe incluir first_name, last_name y opcionalmente email, title, account_id, owner_id |
|
||||
| `run_dedupe` | boolean | No | Habilitar deduplicación para evitar crear contactos duplicados. Cuando es true, los contactos existentes se devuelven sin modificación |
|
||||
|
||||
#### Salida
|
||||
|
||||
| Parámetro | Tipo | Descripción |
|
||||
| --------- | ---- | ----------- |
|
||||
| `created_contacts` | json | Array de contactos recién creados |
|
||||
| `existing_contacts` | json | Array de contactos existentes \(cuando la deduplicación está habilitada\) |
|
||||
| `metadata` | json | Metadatos de creación masiva que incluyen recuentos de contactos creados y existentes |
|
||||
|
||||
### `apollo_contact_bulk_update`
|
||||
|
||||
Actualiza hasta 100 contactos existentes a la vez en tu base de datos de Apollo. Cada contacto debe incluir un campo id. Se requiere clave maestra.
|
||||
|
||||
#### Entrada
|
||||
|
||||
| Parámetro | Tipo | Obligatorio | Descripción |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Sí | Clave API de Apollo \(se requiere clave maestra\) |
|
||||
| `contacts` | array | Sí | Array de contactos para actualizar \(máximo 100\). Cada contacto debe incluir campo id, y opcionalmente first_name, last_name, email, title, account_id, owner_id |
|
||||
|
||||
#### Salida
|
||||
|
||||
| Parámetro | Tipo | Descripción |
|
||||
| --------- | ---- | ----------- |
|
||||
| `updated_contacts` | json | Array de contactos actualizados correctamente |
|
||||
| `failed_contacts` | json | Array de contactos cuya actualización falló |
|
||||
| `metadata` | json | Metadatos de actualización masiva que incluyen recuentos de contactos actualizados y fallidos |
|
||||
|
||||
### `apollo_account_create`
|
||||
|
||||
Crea una nueva cuenta (empresa) en tu base de datos de Apollo
|
||||
|
||||
#### Entrada
|
||||
|
||||
| Parámetro | Tipo | Obligatorio | Descripción |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Sí | Clave API de Apollo |
|
||||
| `name` | string | Sí | Nombre de la empresa |
|
||||
| `website_url` | string | No | URL del sitio web de la empresa |
|
||||
| `phone` | string | No | Número de teléfono de la empresa |
|
||||
| `owner_id` | string | No | ID de usuario del propietario de la cuenta |
|
||||
|
||||
#### Salida
|
||||
|
||||
| Parámetro | Tipo | Descripción |
|
||||
| --------- | ---- | ----------- |
|
||||
| `account` | json | Datos de la cuenta creada desde Apollo |
|
||||
| `metadata` | json | Metadatos de creación incluyendo el estado de creación |
|
||||
|
||||
### `apollo_account_update`
|
||||
|
||||
Actualizar una cuenta existente en tu base de datos de Apollo
|
||||
|
||||
#### Entrada
|
||||
|
||||
| Parámetro | Tipo | Obligatorio | Descripción |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Sí | Clave API de Apollo |
|
||||
| `account_id` | string | Sí | ID de la cuenta a actualizar |
|
||||
| `name` | string | No | Nombre de la empresa |
|
||||
| `website_url` | string | No | URL del sitio web de la empresa |
|
||||
| `phone` | string | No | Número de teléfono de la empresa |
|
||||
| `owner_id` | string | No | ID de usuario del propietario de la cuenta |
|
||||
|
||||
#### Salida
|
||||
|
||||
| Parámetro | Tipo | Descripción |
|
||||
| --------- | ---- | ----------- |
|
||||
| `account` | json | Datos de la cuenta actualizada desde Apollo |
|
||||
| `metadata` | json | Metadatos de actualización incluyendo el estado de actualización |
|
||||
|
||||
### `apollo_account_search`
|
||||
|
||||
Buscar en tu equipo
|
||||
|
||||
#### Entrada
|
||||
|
||||
| Parámetro | Tipo | Obligatorio | Descripción |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Sí | Clave API de Apollo \(se requiere clave maestra\) |
|
||||
| `q_keywords` | string | No | Palabras clave para buscar en los datos de la cuenta |
|
||||
| `owner_id` | string | No | Filtrar por ID de usuario propietario de la cuenta |
|
||||
| `account_stage_ids` | array | No | Filtrar por IDs de etapa de cuenta |
|
||||
| `page` | number | No | Número de página para paginación |
|
||||
| `per_page` | number | No | Resultados por página \(máx: 100\) |
|
||||
|
||||
#### Salida
|
||||
|
||||
| Parámetro | Tipo | Descripción |
|
||||
| --------- | ---- | ----------- |
|
||||
| `accounts` | json | Array de cuentas que coinciden con los criterios de búsqueda |
|
||||
| `metadata` | json | Información de paginación que incluye page, per_page y total_entries |
|
||||
|
||||
### `apollo_account_bulk_create`
|
||||
|
||||
Crea hasta 100 cuentas a la vez en tu base de datos de Apollo. Nota: Apollo no aplica deduplicación - se pueden crear cuentas duplicadas si las entradas comparten nombres o dominios similares. Se requiere clave maestra.
|
||||
|
||||
#### Entrada
|
||||
|
||||
| Parámetro | Tipo | Obligatorio | Descripción |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Sí | Clave API de Apollo \(se requiere clave maestra\) |
|
||||
| `accounts` | array | Sí | Array de cuentas para crear \(máximo 100\). Cada cuenta debe incluir name \(obligatorio\), y opcionalmente website_url, phone, owner_id |
|
||||
|
||||
#### Salida
|
||||
|
||||
| Parámetro | Tipo | Descripción |
|
||||
| --------- | ---- | ----------- |
|
||||
| `created_accounts` | json | Array de cuentas recién creadas |
|
||||
| `failed_accounts` | json | Array de cuentas que no se pudieron crear |
|
||||
| `metadata` | json | Metadatos de creación masiva que incluyen recuentos de cuentas creadas y fallidas |
|
||||
|
||||
### `apollo_account_bulk_update`
|
||||
|
||||
Actualiza hasta 1000 cuentas existentes a la vez en tu base de datos de Apollo (¡límite más alto que para contactos!). Cada cuenta debe incluir un campo id. Se requiere clave maestra.
|
||||
|
||||
#### Entrada
|
||||
|
||||
| Parámetro | Tipo | Obligatorio | Descripción |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Sí | Clave API de Apollo \(se requiere clave maestra\) |
|
||||
| `accounts` | array | Sí | Array de cuentas para actualizar \(máximo 1000\). Cada cuenta debe incluir el campo id, y opcionalmente name, website_url, phone, owner_id |
|
||||
|
||||
#### Salida
|
||||
|
||||
| Parámetro | Tipo | Descripción |
|
||||
| --------- | ---- | ----------- |
|
||||
| `updated_accounts` | json | Array de cuentas actualizadas con éxito |
|
||||
| `failed_accounts` | json | Array de cuentas que no se pudieron actualizar |
|
||||
| `metadata` | json | Metadatos de actualización masiva que incluyen recuentos de cuentas actualizadas y fallidas |
|
||||
|
||||
### `apollo_opportunity_create`
|
||||
|
||||
Crear una nueva oportunidad para una cuenta en tu base de datos de Apollo (se requiere clave maestra)
|
||||
|
||||
#### Entrada
|
||||
|
||||
| Parámetro | Tipo | Obligatorio | Descripción |
|
||||
| --------- | ---- | ----------- | ----------- |
|
||||
| `apiKey` | string | Sí | Clave API de Apollo \(se requiere clave maestra\) |
|
||||
| `name` | string | Sí | Nombre de la oportunidad/negocio |
|
||||
| `account_id` | string | Sí | ID de la cuenta a la que pertenece esta oportunidad |
|
||||
| `amount` | number | No | Valor monetario de la oportunidad |
|
||||
| `stage_id` | string | No | ID de la etapa del negocio |
|
||||
| `owner_id` | string | No | ID de usuario del propietario de la oportunidad |
|
||||
| `close_date` | string | No | Fecha de cierre prevista \(formato ISO 8601\) |
|
||||
| `description` | string | No | Descripción o notas sobre la oportunidad |
|
||||
|
||||
#### Salida
|
||||
|
||||
| Parámetro | Tipo | Descripción |
|
||||
| --------- | ---- | ----------- |
|
||||
| `opportunity` | json | Datos de la oportunidad creada desde Apollo |
|
||||
| `metadata` | json | Metadatos de creación que incluyen el estado de creación |
|
||||
|
||||
### `apollo_opportunity_search`
|
||||
|
||||
Buscar y listar todas las oportunidades/negocios en tu equipo
|
||||
|
||||
#### Entrada
|
||||
|
||||
| Parámetro | Tipo | Obligatorio | Descripción |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Sí | Clave API de Apollo |
|
||||
| `q_keywords` | string | No | Palabras clave para buscar en nombres de oportunidades |
|
||||
| `account_ids` | array | No | Filtrar por IDs de cuentas específicas |
|
||||
| `stage_ids` | array | No | Filtrar por IDs de etapas de negocio |
|
||||
| `owner_ids` | array | No | Filtrar por IDs de propietarios de oportunidades |
|
||||
| `page` | number | No | Número de página para paginación |
|
||||
| `per_page` | number | No | Resultados por página \(máx: 100\) |
|
||||
|
||||
#### Salida
|
||||
|
||||
| Parámetro | Tipo | Descripción |
|
||||
| --------- | ---- | ----------- |
|
||||
| `opportunities` | json | Array de oportunidades que coinciden con los criterios de búsqueda |
|
||||
| `metadata` | json | Información de paginación incluyendo página, por_página y total_entradas |
|
||||
|
||||
### `apollo_opportunity_get`
|
||||
|
||||
Recuperar detalles completos de un negocio/oportunidad específico por ID
|
||||
|
||||
#### Entrada
|
||||
|
||||
| Parámetro | Tipo | Obligatorio | Descripción |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Sí | Clave API de Apollo |
|
||||
| `opportunity_id` | string | Sí | ID de la oportunidad a recuperar |
|
||||
|
||||
#### Salida
|
||||
|
||||
| Parámetro | Tipo | Descripción |
|
||||
| --------- | ---- | ----------- |
|
||||
| `opportunity` | json | Datos completos de la oportunidad desde Apollo |
|
||||
| `metadata` | json | Metadatos de recuperación incluyendo estado de búsqueda |
|
||||
|
||||
### `apollo_opportunity_update`
|
||||
|
||||
Actualizar un negocio/oportunidad existente en tu base de datos de Apollo
|
||||
|
||||
#### Entrada
|
||||
|
||||
| Parámetro | Tipo | Obligatorio | Descripción |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Sí | Clave API de Apollo |
|
||||
| `opportunity_id` | string | Sí | ID de la oportunidad a actualizar |
|
||||
| `name` | string | No | Nombre de la oportunidad/acuerdo |
|
||||
| `amount` | number | No | Valor monetario de la oportunidad |
|
||||
| `stage_id` | string | No | ID de la etapa del acuerdo |
|
||||
| `owner_id` | string | No | ID de usuario del propietario de la oportunidad |
|
||||
| `close_date` | string | No | Fecha prevista de cierre (formato ISO 8601) |
|
||||
| `description` | string | No | Descripción o notas sobre la oportunidad |
|
||||
|
||||
#### Salida
|
||||
|
||||
| Parámetro | Tipo | Descripción |
|
||||
| --------- | ---- | ----------- |
|
||||
| `opportunity` | json | Datos actualizados de la oportunidad desde Apollo |
|
||||
| `metadata` | json | Metadatos de actualización incluyendo estado actualizado |
|
||||
|
||||
### `apollo_sequence_search`
|
||||
|
||||
Buscar secuencias/campañas en tu equipo
|
||||
|
||||
#### Entrada
|
||||
|
||||
| Parámetro | Tipo | Obligatorio | Descripción |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Sí | Clave API de Apollo (se requiere clave maestra) |
|
||||
| `q_name` | string | No | Buscar secuencias por nombre |
|
||||
| `active` | boolean | No | Filtrar por estado activo (true para secuencias activas, false para inactivas) |
|
||||
| `page` | number | No | Número de página para paginación |
|
||||
| `per_page` | number | No | Resultados por página (máx: 100) |
|
||||
|
||||
#### Salida
|
||||
|
||||
| Parámetro | Tipo | Descripción |
|
||||
| --------- | ---- | ----------- |
|
||||
| `sequences` | json | Array de secuencias/campañas que coinciden con los criterios de búsqueda |
|
||||
| `metadata` | json | Información de paginación que incluye página, por_página y total_entradas |
|
||||
|
||||
### `apollo_sequence_add_contacts`
|
||||
|
||||
Añadir contactos a una secuencia de Apollo
|
||||
|
||||
#### Entrada
|
||||
|
||||
| Parámetro | Tipo | Obligatorio | Descripción |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Sí | Clave API de Apollo \(se requiere clave maestra\) |
|
||||
| `sequence_id` | string | Sí | ID de la secuencia a la que añadir contactos |
|
||||
| `contact_ids` | array | Sí | Array de IDs de contactos para añadir a la secuencia |
|
||||
| `emailer_campaign_id` | string | No | ID de campaña de correo electrónico opcional |
|
||||
| `send_email_from_user_id` | string | No | ID de usuario desde el que enviar correos electrónicos |
|
||||
|
||||
#### Salida
|
||||
|
||||
| Parámetro | Tipo | Descripción |
|
||||
| --------- | ---- | ----------- |
|
||||
| `contacts_added` | json | Array de IDs de contactos añadidos a la secuencia |
|
||||
| `metadata` | json | Metadatos de la secuencia que incluyen sequence_id y recuento total_añadidos |
|
||||
|
||||
### `apollo_task_create`
|
||||
|
||||
Crear una nueva tarea en Apollo
|
||||
|
||||
#### Entrada
|
||||
|
||||
| Parámetro | Tipo | Obligatorio | Descripción |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Sí | Clave API de Apollo \(se requiere clave maestra\) |
|
||||
| `note` | string | Sí | Nota/descripción de la tarea |
|
||||
| `contact_id` | string | No | ID de contacto para asociar |
|
||||
| `account_id` | string | No | ID de cuenta para asociar |
|
||||
| `due_at` | string | No | Fecha de vencimiento en formato ISO |
|
||||
| `priority` | string | No | Prioridad de la tarea |
|
||||
| `type` | string | No | Tipo de tarea |
|
||||
|
||||
#### Salida
|
||||
|
||||
| Parámetro | Tipo | Descripción |
|
||||
| --------- | ---- | ----------- |
|
||||
| `task` | json | Datos de la tarea creada desde Apollo |
|
||||
| `metadata` | json | Metadatos de creación incluyendo el estado de creación |
|
||||
|
||||
### `apollo_task_search`
|
||||
|
||||
Buscar tareas en Apollo
|
||||
|
||||
#### Entrada
|
||||
|
||||
| Parámetro | Tipo | Obligatorio | Descripción |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Sí | Clave API de Apollo \(se requiere clave maestra\) |
|
||||
| `contact_id` | string | No | Filtrar por ID de contacto |
|
||||
| `account_id` | string | No | Filtrar por ID de cuenta |
|
||||
| `completed` | boolean | No | Filtrar por estado de finalización |
|
||||
| `page` | number | No | Número de página para paginación |
|
||||
| `per_page` | number | No | Resultados por página \(máx: 100\) |
|
||||
|
||||
#### Salida
|
||||
|
||||
| Parámetro | Tipo | Descripción |
|
||||
| --------- | ---- | ----------- |
|
||||
| `tasks` | json | Array de tareas que coinciden con los criterios de búsqueda |
|
||||
| `metadata` | json | Información de paginación incluyendo página, por_página y total_entradas |
|
||||
|
||||
### `apollo_email_accounts`
|
||||
|
||||
Obtener lista de equipo
|
||||
|
||||
#### Entrada
|
||||
|
||||
| Parámetro | Tipo | Obligatorio | Descripción |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Sí | Clave API de Apollo \(se requiere clave maestra\) |
|
||||
|
||||
#### Salida
|
||||
|
||||
| Parámetro | Tipo | Descripción |
|
||||
| --------- | ---- | ----------- |
|
||||
| `email_accounts` | json | Array de cuentas de correo electrónico del equipo vinculadas en Apollo |
|
||||
| `metadata` | json | Metadatos incluyendo el recuento total de cuentas de correo electrónico |
|
||||
|
||||
## Notas
|
||||
|
||||
- Categoría: `tools`
|
||||
- Tipo: `apollo`
|
||||
@@ -71,8 +71,10 @@ Envía mensajes a canales o usuarios de Slack a través de la API de Slack. Comp
|
||||
|
||||
| Parámetro | Tipo | Descripción |
|
||||
| --------- | ---- | ----------- |
|
||||
| `message` | object | Objeto de mensaje completo con todas las propiedades devueltas por Slack |
|
||||
| `ts` | string | Marca de tiempo del mensaje |
|
||||
| `channel` | string | ID del canal donde se envió el mensaje |
|
||||
| `fileCount` | number | Número de archivos subidos (cuando se adjuntan archivos) |
|
||||
|
||||
### `slack_canvas`
|
||||
|
||||
@@ -155,6 +157,7 @@ Actualizar un mensaje enviado previamente por el bot en Slack
|
||||
|
||||
| Parámetro | Tipo | Descripción |
|
||||
| --------- | ---- | ----------- |
|
||||
| `message` | object | Objeto de mensaje actualizado completo con todas las propiedades devueltas por Slack |
|
||||
| `content` | string | Mensaje de éxito |
|
||||
| `metadata` | object | Metadatos del mensaje actualizado |
|
||||
|
||||
|
||||
574
apps/docs/content/docs/fr/tools/apollo.mdx
Normal file
574
apps/docs/content/docs/fr/tools/apollo.mdx
Normal file
@@ -0,0 +1,574 @@
|
||||
---
|
||||
title: Apollo
|
||||
description: Recherchez, enrichissez et gérez des contacts avec Apollo.io
|
||||
---
|
||||
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="apollo"
|
||||
color="#EBF212"
|
||||
/>
|
||||
|
||||
{/* MANUAL-CONTENT-START:intro */}
|
||||
[Apollo.io](https://apollo.io/) est une plateforme de premier plan en matière d'intelligence commerciale et d'engagement qui permet aux utilisateurs de trouver, d'enrichir et d'interagir avec des contacts et des entreprises à grande échelle. Apollo.io combine une vaste base de données de contacts avec des outils robustes d'enrichissement et d'automatisation des flux de travail, aidant les équipes de vente, de marketing et de recrutement à accélérer leur croissance.
|
||||
|
||||
Avec Apollo.io, vous pouvez :
|
||||
|
||||
- **Rechercher des millions de contacts et d'entreprises** : Trouvez des prospects précis à l'aide de filtres avancés
|
||||
- **Enrichir les prospects et les comptes** : Complétez les informations manquantes avec des données vérifiées et des informations à jour
|
||||
- **Gérer et organiser les enregistrements CRM** : Gardez vos données sur les personnes et les entreprises précises et exploitables
|
||||
- **Automatiser la prospection** : Ajoutez des contacts à des séquences et créez des tâches de suivi directement depuis Apollo.io
|
||||
|
||||
Dans Sim, l'intégration Apollo.io permet à vos agents d'effectuer les opérations essentielles d'Apollo de manière programmatique :
|
||||
|
||||
- **Rechercher des personnes et des entreprises** : Utilisez `apollo_people_search` pour découvrir de nouveaux prospects à l'aide de filtres flexibles.
|
||||
- **Enrichir les données des personnes** : Utilisez `apollo_people_enrich` pour augmenter les contacts avec des informations vérifiées.
|
||||
- **Enrichir des personnes en masse** : Utilisez `apollo_people_bulk_enrich` pour l'enrichissement à grande échelle de plusieurs contacts à la fois.
|
||||
- **Rechercher et enrichir des entreprises** : Utilisez `apollo_company_search` et `apollo_company_enrich` pour découvrir et mettre à jour des informations clés sur les entreprises.
|
||||
|
||||
Cela permet à vos agents de créer des flux de travail puissants pour la prospection, l'enrichissement CRM et l'automatisation sans saisie manuelle de données ou changement d'onglets. Intégrez Apollo.io comme source dynamique de données et moteur CRM — permettant à vos agents d'identifier, de qualifier et de contacter des prospects de manière transparente dans le cadre de leurs opérations quotidiennes.
|
||||
{/* MANUAL-CONTENT-END */}
|
||||
|
||||
## Instructions d'utilisation
|
||||
|
||||
Intègre Apollo.io dans le flux de travail. Recherchez des personnes et des entreprises, enrichissez les données de contact, gérez vos contacts et comptes CRM, ajoutez des contacts aux séquences et créez des tâches.
|
||||
|
||||
## Outils
|
||||
|
||||
### `apollo_people_search`
|
||||
|
||||
Rechercher dans Apollo
|
||||
|
||||
#### Entrée
|
||||
|
||||
| Paramètre | Type | Obligatoire | Description |
|
||||
| --------- | ---- | ----------- | ----------- |
|
||||
| `apiKey` | string | Oui | Clé API Apollo |
|
||||
| `person_titles` | array | Non | Titres de poste à rechercher \(ex., \["CEO", "VP of Sales"\]\) |
|
||||
| `person_locations` | array | Non | Lieux à rechercher \(ex., \["San Francisco, CA", "New York, NY"\]\) |
|
||||
| `person_seniorities` | array | Non | Niveaux d'ancienneté \(ex., \["senior", "executive", "manager"\]\) |
|
||||
| `organization_names` | array | Non | Noms d'entreprises à rechercher |
|
||||
| `q_keywords` | string | Non | Mots-clés à rechercher |
|
||||
| `page` | number | Non | Numéro de page pour la pagination \(par défaut : 1\) |
|
||||
| `per_page` | number | Non | Résultats par page \(par défaut : 25, max : 100\) |
|
||||
|
||||
#### Sortie
|
||||
|
||||
| Paramètre | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `people` | json | Tableau de personnes correspondant aux critères de recherche |
|
||||
| `metadata` | json | Informations de pagination incluant page, par_page et total_entrées |
|
||||
|
||||
### `apollo_people_enrich`
|
||||
|
||||
Enrichir les données d'une personne en utilisant Apollo
|
||||
|
||||
#### Entrée
|
||||
|
||||
| Paramètre | Type | Obligatoire | Description |
|
||||
| --------- | ---- | ----------- | ----------- |
|
||||
| `apiKey` | string | Oui | Clé API Apollo |
|
||||
| `first_name` | string | Non | Prénom de la personne |
|
||||
| `last_name` | string | Non | Nom de famille de la personne |
|
||||
| `email` | string | Non | Adresse e-mail de la personne |
|
||||
| `organization_name` | string | Non | Nom de l'entreprise où travaille la personne |
|
||||
| `domain` | string | Non | Domaine de l'entreprise \(ex., apollo.io\) |
|
||||
| `linkedin_url` | string | Non | URL du profil LinkedIn |
|
||||
| `reveal_personal_emails` | boolean | Non | Révéler les adresses e-mail personnelles \(utilise des crédits\) |
|
||||
| `reveal_phone_number` | boolean | Non | Révéler les numéros de téléphone \(utilise des crédits\) |
|
||||
|
||||
#### Sortie
|
||||
|
||||
| Paramètre | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `person` | json | Données de personne enrichies depuis Apollo |
|
||||
| `metadata` | json | Métadonnées d'enrichissement incluant le statut d'enrichissement |
|
||||
|
||||
### `apollo_people_bulk_enrich`
|
||||
|
||||
Enrichir les données pour jusqu'à 10 personnes à la fois en utilisant Apollo
|
||||
|
||||
#### Entrée
|
||||
|
||||
| Paramètre | Type | Obligatoire | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Oui | Clé API Apollo |
|
||||
| `people` | array | Oui | Tableau de personnes à enrichir \(max 10\) |
|
||||
| `reveal_personal_emails` | boolean | Non | Révéler les adresses e-mail personnelles \(utilise des crédits\) |
|
||||
| `reveal_phone_number` | boolean | Non | Révéler les numéros de téléphone \(utilise des crédits\) |
|
||||
|
||||
#### Sortie
|
||||
|
||||
| Paramètre | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `people` | json | Tableau de données de personnes enrichies |
|
||||
| `metadata` | json | Métadonnées d'enrichissement en masse incluant les comptages totaux et enrichis |
|
||||
|
||||
### `apollo_organization_search`
|
||||
|
||||
Rechercher dans Apollo
|
||||
|
||||
#### Entrée
|
||||
|
||||
| Paramètre | Type | Obligatoire | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Oui | Clé API Apollo |
|
||||
| `organization_locations` | array | Non | Emplacements d'entreprise à rechercher |
|
||||
| `organization_num_employees_ranges` | array | Non | Fourchettes de nombre d'employés \(ex. \["1-10", "11-50"\]\) |
|
||||
| `q_organization_keyword_tags` | array | Non | Tags d'industrie ou mots-clés |
|
||||
| `q_organization_name` | string | Non | Nom de l'organisation à rechercher |
|
||||
| `page` | number | Non | Numéro de page pour la pagination |
|
||||
| `per_page` | number | Non | Résultats par page \(max : 100\) |
|
||||
|
||||
#### Sortie
|
||||
|
||||
| Paramètre | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `organizations` | json | Tableau des organisations correspondant aux critères de recherche |
|
||||
| `metadata` | json | Informations de pagination incluant page, par_page et total_entrées |
|
||||
|
||||
### `apollo_organization_enrich`
|
||||
|
||||
Enrichir les données pour une seule organisation en utilisant Apollo
|
||||
|
||||
#### Entrée
|
||||
|
||||
| Paramètre | Type | Obligatoire | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Oui | Clé API Apollo |
|
||||
| `organization_name` | string | Non | Nom de l'organisation \(au moins un des paramètres nom_organisation ou domaine est requis\) |
|
||||
| `domain` | string | Non | Domaine de l'entreprise \(par exemple, apollo.io\) \(au moins un des paramètres domaine ou nom_organisation est requis\) |
|
||||
|
||||
#### Sortie
|
||||
|
||||
| Paramètre | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `organization` | json | Données d'organisation enrichies depuis Apollo |
|
||||
| `metadata` | json | Métadonnées d'enrichissement incluant le statut d'enrichissement |
|
||||
|
||||
### `apollo_organization_bulk_enrich`
|
||||
|
||||
Enrichir les données pour jusqu'à 10 organisations à la fois en utilisant Apollo
|
||||
|
||||
#### Entrée
|
||||
|
||||
| Paramètre | Type | Obligatoire | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Oui | Clé API Apollo |
|
||||
| `organizations` | array | Oui | Tableau des organisations à enrichir \(max 10\) |
|
||||
|
||||
#### Sortie
|
||||
|
||||
| Paramètre | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `organizations` | json | Tableau des données d'organisation enrichies |
|
||||
| `metadata` | json | Métadonnées d'enrichissement en masse incluant les comptages totaux et enrichis |
|
||||
|
||||
### `apollo_contact_create`
|
||||
|
||||
Créer un nouveau contact dans votre base de données Apollo
|
||||
|
||||
#### Entrée
|
||||
|
||||
| Paramètre | Type | Obligatoire | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Oui | Clé API Apollo |
|
||||
| `first_name` | string | Oui | Prénom du contact |
|
||||
| `last_name` | string | Oui | Nom de famille du contact |
|
||||
| `email` | string | Non | Adresse e-mail du contact |
|
||||
| `title` | string | Non | Titre du poste |
|
||||
| `account_id` | string | Non | ID de compte Apollo à associer |
|
||||
| `owner_id` | string | Non | ID utilisateur du propriétaire du contact |
|
||||
|
||||
#### Sortie
|
||||
|
||||
| Paramètre | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `contact` | json | Données du contact créé dans Apollo |
|
||||
| `metadata` | json | Métadonnées de création incluant le statut de création |
|
||||
|
||||
### `apollo_contact_update`
|
||||
|
||||
Mettre à jour un contact existant dans votre base de données Apollo
|
||||
|
||||
#### Entrée
|
||||
|
||||
| Paramètre | Type | Obligatoire | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Oui | Clé API Apollo |
|
||||
| `contact_id` | string | Oui | ID du contact à mettre à jour |
|
||||
| `first_name` | string | Non | Prénom du contact |
|
||||
| `last_name` | string | Non | Nom de famille du contact |
|
||||
| `email` | string | Non | Adresse e-mail |
|
||||
| `title` | string | Non | Titre du poste |
|
||||
| `account_id` | string | Non | ID de compte Apollo |
|
||||
| `owner_id` | string | Non | ID utilisateur du propriétaire du contact |
|
||||
|
||||
#### Sortie
|
||||
|
||||
| Paramètre | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `contact` | json | Données de contact mises à jour depuis Apollo |
|
||||
| `metadata` | json | Métadonnées de mise à jour incluant le statut mis à jour |
|
||||
|
||||
### `apollo_contact_search`
|
||||
|
||||
Rechercher votre équipe
|
||||
|
||||
#### Entrée
|
||||
|
||||
| Paramètre | Type | Obligatoire | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Oui | Clé API Apollo |
|
||||
| `q_keywords` | string | Non | Mots-clés à rechercher |
|
||||
| `contact_stage_ids` | array | Non | Filtrer par IDs d'étape de contact |
|
||||
| `page` | number | Non | Numéro de page pour la pagination |
|
||||
| `per_page` | number | Non | Résultats par page \(max : 100\) |
|
||||
|
||||
#### Sortie
|
||||
|
||||
| Paramètre | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `contacts` | json | Tableau de contacts correspondant aux critères de recherche |
|
||||
| `metadata` | json | Informations de pagination incluant page, per_page et total_entries |
|
||||
|
||||
### `apollo_contact_bulk_create`
|
||||
|
||||
Créez jusqu'à 100 contacts à la fois dans votre base de données Apollo. Prend en charge la déduplication pour éviter de créer des contacts en double. Clé principale requise.
|
||||
|
||||
#### Entrée
|
||||
|
||||
| Paramètre | Type | Obligatoire | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Oui | Clé API Apollo \(clé principale requise\) |
|
||||
| `contacts` | array | Oui | Tableau de contacts à créer \(max 100\). Chaque contact doit inclure first_name, last_name, et optionnellement email, title, account_id, owner_id |
|
||||
| `run_dedupe` | boolean | Non | Activer la déduplication pour éviter de créer des contacts en double. Lorsque défini sur true, les contacts existants sont renvoyés sans modification |
|
||||
|
||||
#### Sortie
|
||||
|
||||
| Paramètre | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `created_contacts` | json | Tableau des contacts nouvellement créés |
|
||||
| `existing_contacts` | json | Tableau des contacts existants \(lorsque la déduplication est activée\) |
|
||||
| `metadata` | json | Métadonnées de création en masse incluant le nombre de contacts créés et existants |
|
||||
|
||||
### `apollo_contact_bulk_update`
|
||||
|
||||
Mettez à jour jusqu'à 100 contacts existants à la fois dans votre base de données Apollo. Chaque contact doit inclure un champ id. Clé principale requise.
|
||||
|
||||
#### Entrée
|
||||
|
||||
| Paramètre | Type | Obligatoire | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Oui | Clé API Apollo \(clé principale requise\) |
|
||||
| `contacts` | array | Oui | Tableau de contacts à mettre à jour \(max 100\). Chaque contact doit inclure le champ id, et optionnellement first_name, last_name, email, title, account_id, owner_id |
|
||||
|
||||
#### Sortie
|
||||
|
||||
| Paramètre | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `updated_contacts` | json | Tableau des contacts mis à jour avec succès |
|
||||
| `failed_contacts` | json | Tableau des contacts dont la mise à jour a échoué |
|
||||
| `metadata` | json | Métadonnées de mise à jour en masse incluant le nombre de contacts mis à jour et en échec |
|
||||
|
||||
### `apollo_account_create`
|
||||
|
||||
Créer un nouveau compte (entreprise) dans votre base de données Apollo
|
||||
|
||||
#### Entrée
|
||||
|
||||
| Paramètre | Type | Obligatoire | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Oui | Clé API Apollo |
|
||||
| `name` | string | Oui | Nom de l'entreprise |
|
||||
| `website_url` | string | Non | URL du site web de l'entreprise |
|
||||
| `phone` | string | Non | Numéro de téléphone de l'entreprise |
|
||||
| `owner_id` | string | Non | ID utilisateur du propriétaire du compte |
|
||||
|
||||
#### Sortie
|
||||
|
||||
| Paramètre | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `account` | json | Données du compte créé depuis Apollo |
|
||||
| `metadata` | json | Métadonnées de création incluant le statut de création |
|
||||
|
||||
### `apollo_account_update`
|
||||
|
||||
Mettre à jour un compte existant dans votre base de données Apollo
|
||||
|
||||
#### Entrée
|
||||
|
||||
| Paramètre | Type | Obligatoire | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Oui | Clé API Apollo |
|
||||
| `account_id` | string | Oui | ID du compte à mettre à jour |
|
||||
| `name` | string | Non | Nom de l'entreprise |
|
||||
| `website_url` | string | Non | URL du site web de l'entreprise |
|
||||
| `phone` | string | Non | Numéro de téléphone de l'entreprise |
|
||||
| `owner_id` | string | Non | ID utilisateur du propriétaire du compte |
|
||||
|
||||
#### Sortie
|
||||
|
||||
| Paramètre | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `account` | json | Données du compte mis à jour depuis Apollo |
|
||||
| `metadata` | json | Métadonnées de mise à jour incluant le statut de mise à jour |
|
||||
|
||||
### `apollo_account_search`
|
||||
|
||||
Rechercher dans votre équipe
|
||||
|
||||
#### Entrée
|
||||
|
||||
| Paramètre | Type | Obligatoire | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Oui | Clé API Apollo \(clé principale requise\) |
|
||||
| `q_keywords` | string | Non | Mots-clés à rechercher dans les données du compte |
|
||||
| `owner_id` | string | Non | Filtrer par ID utilisateur du propriétaire du compte |
|
||||
| `account_stage_ids` | array | Non | Filtrer par IDs d'étape de compte |
|
||||
| `page` | number | Non | Numéro de page pour la pagination |
|
||||
| `per_page` | number | Non | Résultats par page \(max : 100\) |
|
||||
|
||||
#### Sortie
|
||||
|
||||
| Paramètre | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `accounts` | json | Tableau des comptes correspondant aux critères de recherche |
|
||||
| `metadata` | json | Informations de pagination incluant page, par_page et total_entrées |
|
||||
|
||||
### `apollo_account_bulk_create`
|
||||
|
||||
Créez jusqu'à 100 comptes à la fois dans votre base de données Apollo. Remarque : Apollo n'applique pas de déduplication - des comptes en double peuvent être créés si les entrées partagent des noms ou domaines similaires. Clé principale requise.
|
||||
|
||||
#### Entrée
|
||||
|
||||
| Paramètre | Type | Obligatoire | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Oui | Clé API Apollo \(clé principale requise\) |
|
||||
| `accounts` | array | Oui | Tableau des comptes à créer \(max 100\). Chaque compte doit inclure un nom \(obligatoire\), et éventuellement website_url, téléphone, owner_id |
|
||||
|
||||
#### Sortie
|
||||
|
||||
| Paramètre | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `created_accounts` | json | Tableau des comptes nouvellement créés |
|
||||
| `failed_accounts` | json | Tableau des comptes dont la création a échoué |
|
||||
| `metadata` | json | Métadonnées de création en masse incluant le nombre de comptes créés et échoués |
|
||||
|
||||
### `apollo_account_bulk_update`
|
||||
|
||||
Mettez à jour jusqu'à 1000 comptes existants à la fois dans votre base de données Apollo (limite plus élevée que pour les contacts !). Chaque compte doit inclure un champ id. Clé principale requise.
|
||||
|
||||
#### Entrée
|
||||
|
||||
| Paramètre | Type | Obligatoire | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Oui | Clé API Apollo \(clé principale requise\) |
|
||||
| `accounts` | array | Oui | Tableau des comptes à mettre à jour \(max 1000\). Chaque compte doit inclure un champ id, et éventuellement name, website_url, phone, owner_id |
|
||||
|
||||
#### Sortie
|
||||
|
||||
| Paramètre | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `updated_accounts` | json | Tableau des comptes mis à jour avec succès |
|
||||
| `failed_accounts` | json | Tableau des comptes dont la mise à jour a échoué |
|
||||
| `metadata` | json | Métadonnées de mise à jour en masse incluant le nombre de comptes mis à jour et échoués |
|
||||
|
||||
### `apollo_opportunity_create`
|
||||
|
||||
Créer une nouvelle opportunité pour un compte dans votre base de données Apollo (clé principale requise)
|
||||
|
||||
#### Entrée
|
||||
|
||||
| Paramètre | Type | Obligatoire | Description |
|
||||
| --------- | ---- | ---------- | ----------- |
|
||||
| `apiKey` | string | Oui | Clé API Apollo \(clé principale requise\) |
|
||||
| `name` | string | Oui | Nom de l'opportunité/affaire |
|
||||
| `account_id` | string | Oui | ID du compte auquel cette opportunité appartient |
|
||||
| `amount` | number | Non | Valeur monétaire de l'opportunité |
|
||||
| `stage_id` | string | Non | ID de l'étape de l'affaire |
|
||||
| `owner_id` | string | Non | ID utilisateur du propriétaire de l'opportunité |
|
||||
| `close_date` | string | Non | Date de clôture prévue \(format ISO 8601\) |
|
||||
| `description` | string | Non | Description ou notes concernant l'opportunité |
|
||||
|
||||
#### Sortie
|
||||
|
||||
| Paramètre | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `opportunity` | json | Données de l'opportunité créée depuis Apollo |
|
||||
| `metadata` | json | Métadonnées de création incluant le statut de création |
|
||||
|
||||
### `apollo_opportunity_search`
|
||||
|
||||
Rechercher et lister toutes les affaires/opportunités dans votre équipe
|
||||
|
||||
#### Entrée
|
||||
|
||||
| Paramètre | Type | Obligatoire | Description |
|
||||
| --------- | ---- | ---------- | ----------- |
|
||||
| `apiKey` | string | Oui | Clé API Apollo |
|
||||
| `q_keywords` | string | Non | Mots-clés à rechercher dans les noms d'opportunités |
|
||||
| `account_ids` | array | Non | Filtrer par identifiants de compte spécifiques |
|
||||
| `stage_ids` | array | Non | Filtrer par identifiants d'étape de transaction |
|
||||
| `owner_ids` | array | Non | Filtrer par identifiants de propriétaires d'opportunités |
|
||||
| `page` | number | Non | Numéro de page pour la pagination |
|
||||
| `per_page` | number | Non | Résultats par page \(max : 100\) |
|
||||
|
||||
#### Sortie
|
||||
|
||||
| Paramètre | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `opportunities` | json | Tableau d'opportunités correspondant aux critères de recherche |
|
||||
| `metadata` | json | Informations de pagination incluant page, par_page et total_entrées |
|
||||
|
||||
### `apollo_opportunity_get`
|
||||
|
||||
Récupérer les détails complets d'une transaction/opportunité spécifique par ID
|
||||
|
||||
#### Entrée
|
||||
|
||||
| Paramètre | Type | Obligatoire | Description |
|
||||
| --------- | ---- | ---------- | ----------- |
|
||||
| `apiKey` | string | Oui | Clé API Apollo |
|
||||
| `opportunity_id` | string | Oui | ID de l'opportunité à récupérer |
|
||||
|
||||
#### Sortie
|
||||
|
||||
| Paramètre | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `opportunity` | json | Données complètes de l'opportunité depuis Apollo |
|
||||
| `metadata` | json | Métadonnées de récupération incluant le statut trouvé |
|
||||
|
||||
### `apollo_opportunity_update`
|
||||
|
||||
Mettre à jour une transaction/opportunité existante dans votre base de données Apollo
|
||||
|
||||
#### Entrée
|
||||
|
||||
| Paramètre | Type | Obligatoire | Description |
|
||||
| --------- | ---- | ---------- | ----------- |
|
||||
| `apiKey` | string | Oui | Clé API Apollo |
|
||||
| `opportunity_id` | string | Oui | ID de l'opportunité à mettre à jour |
|
||||
| `name` | string | Non | Nom de l'opportunité/affaire |
|
||||
| `amount` | number | Non | Valeur monétaire de l'opportunité |
|
||||
| `stage_id` | string | Non | ID de l'étape de l'affaire |
|
||||
| `owner_id` | string | Non | ID utilisateur du propriétaire de l'opportunité |
|
||||
| `close_date` | string | Non | Date de clôture prévue (format ISO 8601) |
|
||||
| `description` | string | Non | Description ou notes concernant l'opportunité |
|
||||
|
||||
#### Sortie
|
||||
|
||||
| Paramètre | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `opportunity` | json | Données d'opportunité mises à jour depuis Apollo |
|
||||
| `metadata` | json | Métadonnées de mise à jour incluant le statut de mise à jour |
|
||||
|
||||
### `apollo_sequence_search`
|
||||
|
||||
Rechercher des séquences/campagnes dans votre équipe
|
||||
|
||||
#### Entrée
|
||||
|
||||
| Paramètre | Type | Obligatoire | Description |
|
||||
| --------- | ---- | ---------- | ----------- |
|
||||
| `apiKey` | string | Oui | Clé API Apollo (clé principale requise) |
|
||||
| `q_name` | string | Non | Rechercher des séquences par nom |
|
||||
| `active` | boolean | Non | Filtrer par statut actif (true pour les séquences actives, false pour les inactives) |
|
||||
| `page` | number | Non | Numéro de page pour la pagination |
|
||||
| `per_page` | number | Non | Résultats par page (max : 100) |
|
||||
|
||||
#### Sortie
|
||||
|
||||
| Paramètre | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `sequences` | json | Tableau de séquences/campagnes correspondant aux critères de recherche |
|
||||
| `metadata` | json | Informations de pagination incluant page, par_page et total_entrées |
|
||||
|
||||
### `apollo_sequence_add_contacts`
|
||||
|
||||
Ajouter des contacts à une séquence Apollo
|
||||
|
||||
#### Entrée
|
||||
|
||||
| Paramètre | Type | Obligatoire | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Oui | Clé API Apollo (clé principale requise) |
|
||||
| `sequence_id` | string | Oui | ID de la séquence à laquelle ajouter des contacts |
|
||||
| `contact_ids` | array | Oui | Tableau d'ID de contacts à ajouter à la séquence |
|
||||
| `emailer_campaign_id` | string | Non | ID de campagne d'e-mailing optionnel |
|
||||
| `send_email_from_user_id` | string | Non | ID utilisateur pour l'envoi des e-mails |
|
||||
|
||||
#### Sortie
|
||||
|
||||
| Paramètre | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `contacts_added` | json | Tableau d'ID de contacts ajoutés à la séquence |
|
||||
| `metadata` | json | Métadonnées de séquence incluant sequence_id et nombre total_added |
|
||||
|
||||
### `apollo_task_create`
|
||||
|
||||
Créer une nouvelle tâche dans Apollo
|
||||
|
||||
#### Entrée
|
||||
|
||||
| Paramètre | Type | Obligatoire | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Oui | Clé API Apollo (clé principale requise) |
|
||||
| `note` | string | Oui | Note/description de la tâche |
|
||||
| `contact_id` | string | Non | ID du contact à associer |
|
||||
| `account_id` | string | Non | ID du compte à associer |
|
||||
| `due_at` | string | Non | Date d'échéance au format ISO |
|
||||
| `priority` | string | Non | Priorité de la tâche |
|
||||
| `type` | string | Non | Type de tâche |
|
||||
|
||||
#### Sortie
|
||||
|
||||
| Paramètre | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `task` | json | Données de tâche créées depuis Apollo |
|
||||
| `metadata` | json | Métadonnées de création incluant le statut de création |
|
||||
|
||||
### `apollo_task_search`
|
||||
|
||||
Rechercher des tâches dans Apollo
|
||||
|
||||
#### Entrée
|
||||
|
||||
| Paramètre | Type | Obligatoire | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Oui | Clé API Apollo (clé principale requise) |
|
||||
| `contact_id` | string | Non | Filtrer par ID de contact |
|
||||
| `account_id` | string | Non | Filtrer par ID de compte |
|
||||
| `completed` | boolean | Non | Filtrer par statut d'achèvement |
|
||||
| `page` | number | Non | Numéro de page pour la pagination |
|
||||
| `per_page` | number | Non | Résultats par page (max : 100) |
|
||||
|
||||
#### Sortie
|
||||
|
||||
| Paramètre | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `tasks` | json | Tableau des tâches correspondant aux critères de recherche |
|
||||
| `metadata` | json | Informations de pagination incluant page, par_page et total_entrées |
|
||||
|
||||
### `apollo_email_accounts`
|
||||
|
||||
Obtenir la liste de l'équipe
|
||||
|
||||
#### Entrée
|
||||
|
||||
| Paramètre | Type | Obligatoire | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Oui | Clé API Apollo (clé principale requise) |
|
||||
|
||||
#### Sortie
|
||||
|
||||
| Paramètre | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `email_accounts` | json | Tableau des comptes email d'équipe liés dans Apollo |
|
||||
| `metadata` | json | Métadonnées incluant le nombre total de comptes email |
|
||||
|
||||
## Notes
|
||||
|
||||
- Catégorie : `tools`
|
||||
- Type : `apollo`
|
||||
@@ -71,8 +71,10 @@ Envoyez des messages aux canaux ou utilisateurs Slack via l'API Slack. Prend en
|
||||
|
||||
| Paramètre | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `message` | objet | Objet de message complet avec toutes les propriétés renvoyées par Slack |
|
||||
| `ts` | chaîne | Horodatage du message |
|
||||
| `channel` | chaîne | ID du canal où le message a été envoyé |
|
||||
| `fileCount` | nombre | Nombre de fichiers téléchargés \(lorsque des fichiers sont joints\) |
|
||||
|
||||
### `slack_canvas`
|
||||
|
||||
@@ -155,6 +157,7 @@ Mettre à jour un message précédemment envoyé par le bot dans Slack
|
||||
|
||||
| Paramètre | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `message` | objet | Objet de message mis à jour complet avec toutes les propriétés renvoyées par Slack |
|
||||
| `content` | chaîne | Message de succès |
|
||||
| `metadata` | objet | Métadonnées du message mis à jour |
|
||||
|
||||
|
||||
@@ -1017,3 +1017,22 @@ Lister tous les événements
|
||||
|
||||
- Catégorie : `tools`
|
||||
- Type : `stripe`
|
||||
|
||||
| Paramètre | Type | Obligatoire | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Oui | Clé API Stripe (clé secrète) |
|
||||
| `limit` | number | Non | Nombre de résultats à retourner (par défaut 10, max 100) |
|
||||
| `type` | string | Non | Filtrer par type d'événement (par ex., payment_intent.created) |
|
||||
| `created` | json | Non | Filtrer par date de création (par ex., \{"gt": 1633024800\}) |
|
||||
|
||||
#### Sortie
|
||||
|
||||
| Paramètre | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `events` | json | Tableau d'objets événement |
|
||||
| `metadata` | json | Métadonnées de liste incluant le nombre et has_more |
|
||||
|
||||
## Notes
|
||||
|
||||
- Catégorie : `tools`
|
||||
- Type : `stripe`
|
||||
|
||||
574
apps/docs/content/docs/ja/tools/apollo.mdx
Normal file
574
apps/docs/content/docs/ja/tools/apollo.mdx
Normal file
@@ -0,0 +1,574 @@
|
||||
---
|
||||
title: Apollo
|
||||
description: Apollo.ioで連絡先の検索、情報強化、管理を行う
|
||||
---
|
||||
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="apollo"
|
||||
color="#EBF212"
|
||||
/>
|
||||
|
||||
{/* MANUAL-CONTENT-START:intro */}
|
||||
[Apollo.io](https://apollo.io/)は、ユーザーが大規模に連絡先や企業を見つけ、情報を強化し、エンゲージメントを行うことができる、先進的な営業インテリジェンスおよびエンゲージメントプラットフォームです。Apollo.ioは、広範な連絡先データベースと堅牢な情報強化およびワークフロー自動化ツールを組み合わせ、営業、マーケティング、採用チームの成長を加速させるのに役立ちます。
|
||||
|
||||
Apollo.ioでは、以下のことが可能です:
|
||||
|
||||
- **数百万の連絡先と企業を検索**: 高度なフィルターを使用して正確なリードを見つける
|
||||
- **リードとアカウントの情報を強化**: 検証済みデータと最新情報で不足している詳細を補完
|
||||
- **CRMレコードの管理と整理**: 人物と企業のデータを正確かつ実用的に保つ
|
||||
- **アウトリーチの自動化**: Apollo.ioから直接連絡先をシーケンスに追加し、フォローアップタスクを作成
|
||||
|
||||
Simでは、Apollo.io統合により、エージェントがプログラムによって主要なApollo操作を実行できます:
|
||||
|
||||
- **人物と企業の検索**: `apollo_people_search`を使用して、柔軟なフィルターで新しいリードを発見します。
|
||||
- **人物データの強化**: `apollo_people_enrich`を使用して、連絡先を検証済み情報で補強します。
|
||||
- **人物の一括強化**: `apollo_people_bulk_enrich`を使用して、複数の連絡先を一度に大規模に強化します。
|
||||
- **企業の検索と強化**: `apollo_company_search`と`apollo_company_enrich`を使用して、主要な企業情報を発見し更新します。
|
||||
|
||||
これにより、エージェントは手動でのデータ入力やタブの切り替えなしに、見込み客の発掘、CRM情報強化、自動化のための強力なワークフローを構築できます。Apollo.ioを動的なデータソースとCRMエンジンとして統合することで、エージェントが日常業務の一部として、リードを特定し、資格を評価し、シームレスにアプローチすることができます。
|
||||
{/* MANUAL-CONTENT-END */}
|
||||
|
||||
## 使用手順
|
||||
|
||||
Apollo.ioをワークフローに統合します。人物や企業を検索し、連絡先データを充実させ、CRMの連絡先やアカウントを管理し、連絡先をシーケンスに追加し、タスクを作成できます。
|
||||
|
||||
## ツール
|
||||
|
||||
### `apollo_people_search`
|
||||
|
||||
Apolloを検索
|
||||
|
||||
#### 入力
|
||||
|
||||
| パラメータ | 型 | 必須 | 説明 |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | はい | Apollo APIキー |
|
||||
| `person_titles` | array | いいえ | 検索する役職(例:["CEO", "VP of Sales"]) |
|
||||
| `person_locations` | array | いいえ | 検索する場所(例:["San Francisco, CA", "New York, NY"]) |
|
||||
| `person_seniorities` | array | いいえ | 役職レベル(例:["senior", "executive", "manager"]) |
|
||||
| `organization_names` | array | いいえ | 検索対象の企業名 |
|
||||
| `q_keywords` | string | いいえ | 検索するキーワード |
|
||||
| `page` | number | いいえ | ページネーションのページ番号(デフォルト:1) |
|
||||
| `per_page` | number | いいえ | 1ページあたりの結果数(デフォルト:25、最大:100) |
|
||||
|
||||
#### 出力
|
||||
|
||||
| パラメータ | 型 | 説明 |
|
||||
| --------- | ---- | ----------- |
|
||||
| `people` | json | 検索条件に一致する人物の配列 |
|
||||
| `metadata` | json | ページ、per_page、total_entriesを含むページネーション情報 |
|
||||
|
||||
### `apollo_people_enrich`
|
||||
|
||||
Apolloを使用して1人のデータを充実させる
|
||||
|
||||
#### 入力
|
||||
|
||||
| パラメータ | 型 | 必須 | 説明 |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | はい | Apollo APIキー |
|
||||
| `first_name` | string | いいえ | 人物の名 |
|
||||
| `last_name` | string | いいえ | 人物の姓 |
|
||||
| `email` | string | いいえ | 人物のメールアドレス |
|
||||
| `organization_name` | string | いいえ | 人物が勤務する企業名 |
|
||||
| `domain` | string | いいえ | 企業ドメイン(例:apollo.io) |
|
||||
| `linkedin_url` | string | いいえ | LinkedInプロフィールURL |
|
||||
| `reveal_personal_emails` | boolean | いいえ | 個人メールアドレスを表示(クレジットを使用) |
|
||||
| `reveal_phone_number` | boolean | いいえ | 電話番号を表示(クレジットを使用) |
|
||||
|
||||
#### 出力
|
||||
|
||||
| パラメータ | 型 | 説明 |
|
||||
| --------- | ---- | ----------- |
|
||||
| `person` | json | Apolloからの充実した人物データ |
|
||||
| `metadata` | json | 充実ステータスを含むエンリッチメントメタデータ |
|
||||
|
||||
### `apollo_people_bulk_enrich`
|
||||
|
||||
Apolloを使用して一度に最大10人のデータを充実させる
|
||||
|
||||
#### 入力
|
||||
|
||||
| パラメータ | 型 | 必須 | 説明 |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | はい | Apollo APIキー |
|
||||
| `people` | array | はい | 充実させる人物の配列(最大10人) |
|
||||
| `reveal_personal_emails` | boolean | いいえ | 個人メールアドレスを表示(クレジットを使用) |
|
||||
| `reveal_phone_number` | boolean | いいえ | 電話番号を表示(クレジットを使用) |
|
||||
|
||||
#### 出力
|
||||
|
||||
| パラメータ | 型 | 説明 |
|
||||
| --------- | ---- | ----------- |
|
||||
| `people` | json | 充実した人物データの配列 |
|
||||
| `metadata` | json | 合計数と充実数を含む一括充実メタデータ |
|
||||
|
||||
### `apollo_organization_search`
|
||||
|
||||
Apolloを検索
|
||||
|
||||
#### 入力
|
||||
|
||||
| パラメータ | 型 | 必須 | 説明 |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | はい | Apollo APIキー |
|
||||
| `organization_locations` | array | いいえ | 検索する会社の所在地 |
|
||||
| `organization_num_employees_ranges` | array | いいえ | 従業員数の範囲(例:["1-10", "11-50"]) |
|
||||
| `q_organization_keyword_tags` | array | いいえ | 業界またはキーワードタグ |
|
||||
| `q_organization_name` | string | いいえ | 検索する組織名 |
|
||||
| `page` | number | いいえ | ページネーションのページ番号 |
|
||||
| `per_page` | number | いいえ | ページあたりの結果数(最大:100) |
|
||||
|
||||
#### 出力
|
||||
|
||||
| パラメータ | 型 | 説明 |
|
||||
| --------- | ---- | ----------- |
|
||||
| `organizations` | json | 検索条件に一致する組織の配列 |
|
||||
| `metadata` | json | ページ、per_page、total_entriesを含むページネーション情報 |
|
||||
|
||||
### `apollo_organization_enrich`
|
||||
|
||||
Apolloを使用して単一の組織のデータを充実させる
|
||||
|
||||
#### 入力
|
||||
|
||||
| パラメータ | 型 | 必須 | 説明 |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | はい | Apollo APIキー |
|
||||
| `organization_name` | string | いいえ | 組織名(organization_nameまたはdomainの少なくとも1つが必要) |
|
||||
| `domain` | string | いいえ | 会社ドメイン(例:apollo.io)(domainまたはorganization_nameの少なくとも1つが必要) |
|
||||
|
||||
#### 出力
|
||||
|
||||
| パラメータ | 型 | 説明 |
|
||||
| --------- | ---- | ----------- |
|
||||
| `organization` | json | Apolloからの充実した組織データ |
|
||||
| `metadata` | json | 充実ステータスを含むエンリッチメントメタデータ |
|
||||
|
||||
### `apollo_organization_bulk_enrich`
|
||||
|
||||
Apolloを使用して最大10の組織のデータを一度に充実させる
|
||||
|
||||
#### 入力
|
||||
|
||||
| パラメータ | 型 | 必須 | 説明 |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | はい | Apollo APIキー |
|
||||
| `organizations` | array | はい | 充実させる組織の配列(最大10) |
|
||||
|
||||
#### 出力
|
||||
|
||||
| パラメータ | 型 | 説明 |
|
||||
| --------- | ---- | ----------- |
|
||||
| `organizations` | json | 充実した組織データの配列 |
|
||||
| `metadata` | json | 合計数と充実数を含む一括エンリッチメントメタデータ |
|
||||
|
||||
### `apollo_contact_create`
|
||||
|
||||
Apolloデータベースに新しい連絡先を作成する
|
||||
|
||||
#### 入力
|
||||
|
||||
| パラメータ | 型 | 必須 | 説明 |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | はい | Apollo APIキー |
|
||||
| `first_name` | string | はい | 連絡先の名 |
|
||||
| `last_name` | string | はい | 連絡先の姓 |
|
||||
| `email` | string | いいえ | 連絡先のメールアドレス |
|
||||
| `title` | string | いいえ | 役職 |
|
||||
| `account_id` | string | いいえ | 関連付けるApolloアカウントID |
|
||||
| `owner_id` | string | いいえ | 連絡先所有者のユーザーID |
|
||||
|
||||
#### 出力
|
||||
|
||||
| パラメータ | 型 | 説明 |
|
||||
| --------- | ---- | ----------- |
|
||||
| `contact` | json | Apolloから作成された連絡先データ |
|
||||
| `metadata` | json | 作成ステータスを含む作成メタデータ |
|
||||
|
||||
### `apollo_contact_update`
|
||||
|
||||
Apolloデータベースの既存の連絡先を更新する
|
||||
|
||||
#### 入力
|
||||
|
||||
| パラメータ | 型 | 必須 | 説明 |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | はい | Apollo APIキー |
|
||||
| `contact_id` | string | はい | 更新する連絡先のID |
|
||||
| `first_name` | string | いいえ | 連絡先の名 |
|
||||
| `last_name` | string | いいえ | 連絡先の姓 |
|
||||
| `email` | string | いいえ | メールアドレス |
|
||||
| `title` | string | いいえ | 役職 |
|
||||
| `account_id` | string | いいえ | Apolloアカウント ID |
|
||||
| `owner_id` | string | いいえ | 連絡先所有者のユーザーID |
|
||||
|
||||
#### 出力
|
||||
|
||||
| パラメータ | 型 | 説明 |
|
||||
| --------- | ---- | ----------- |
|
||||
| `contact` | json | Apolloから更新された連絡先データ |
|
||||
| `metadata` | json | 更新ステータスを含む更新メタデータ |
|
||||
|
||||
### `apollo_contact_search`
|
||||
|
||||
チームを検索
|
||||
|
||||
#### 入力
|
||||
|
||||
| パラメータ | 型 | 必須 | 説明 |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | はい | Apollo APIキー |
|
||||
| `q_keywords` | string | いいえ | 検索するキーワード |
|
||||
| `contact_stage_ids` | array | いいえ | 連絡先ステージIDでフィルタリング |
|
||||
| `page` | number | いいえ | ページネーションのページ番号 |
|
||||
| `per_page` | number | いいえ | 1ページあたりの結果数(最大:100) |
|
||||
|
||||
#### 出力
|
||||
|
||||
| パラメータ | 型 | 説明 |
|
||||
| --------- | ---- | ----------- |
|
||||
| `contacts` | json | 検索条件に一致する連絡先の配列 |
|
||||
| `metadata` | json | ページ、per_page、total_entriesを含むページネーション情報 |
|
||||
|
||||
### `apollo_contact_bulk_create`
|
||||
|
||||
Apolloデータベースに一度に最大100件の連絡先を作成します。重複を防ぐための重複排除をサポートしています。マスターキーが必要です。
|
||||
|
||||
#### 入力
|
||||
|
||||
| パラメータ | 型 | 必須 | 説明 |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | はい | Apollo APIキー(マスターキーが必要) |
|
||||
| `contacts` | array | はい | 作成する連絡先の配列(最大100件)。各連絡先にはfirst_name、last_nameを含め、オプションでemail、title、account_id、owner_idを含めることができます |
|
||||
| `run_dedupe` | boolean | いいえ | 重複する連絡先の作成を防ぐための重複排除を有効にします。trueの場合、既存の連絡先は変更せずに返されます |
|
||||
|
||||
#### 出力
|
||||
|
||||
| パラメータ | 型 | 説明 |
|
||||
| --------- | ---- | ----------- |
|
||||
| `created_contacts` | json | 新しく作成された連絡先の配列 |
|
||||
| `existing_contacts` | json | 既存の連絡先の配列(重複排除が有効な場合) |
|
||||
| `metadata` | json | 作成された連絡先と既存の連絡先の数を含む一括作成メタデータ |
|
||||
|
||||
### `apollo_contact_bulk_update`
|
||||
|
||||
Apolloデータベース内の最大100件の既存の連絡先を一度に更新します。各連絡先にはidフィールドが含まれている必要があります。マスターキーが必要です。
|
||||
|
||||
#### 入力
|
||||
|
||||
| パラメータ | 型 | 必須 | 説明 |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | はい | Apollo APIキー(マスターキーが必要) |
|
||||
| `contacts` | array | はい | 更新する連絡先の配列(最大100件)。各連絡先にはidフィールドが必須で、オプションでfirst_name、last_name、email、title、account_id、owner_idを含めることができます |
|
||||
|
||||
#### 出力
|
||||
|
||||
| パラメータ | 型 | 説明 |
|
||||
| --------- | ---- | ----------- |
|
||||
| `updated_contacts` | json | 正常に更新された連絡先の配列 |
|
||||
| `failed_contacts` | json | 更新に失敗した連絡先の配列 |
|
||||
| `metadata` | json | 更新された連絡先と失敗した連絡先の数を含む一括更新メタデータ |
|
||||
|
||||
### `apollo_account_create`
|
||||
|
||||
Apolloデータベースに新しいアカウント(会社)を作成します
|
||||
|
||||
#### 入力
|
||||
|
||||
| パラメータ | 型 | 必須 | 説明 |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | はい | Apollo APIキー |
|
||||
| `name` | string | はい | 会社名 |
|
||||
| `website_url` | string | いいえ | 会社のウェブサイトURL |
|
||||
| `phone` | string | いいえ | 会社の電話番号 |
|
||||
| `owner_id` | string | いいえ | アカウント所有者のユーザーID |
|
||||
|
||||
#### 出力
|
||||
|
||||
| パラメータ | 型 | 説明 |
|
||||
| --------- | ---- | ----------- |
|
||||
| `account` | json | Apolloから作成されたアカウントデータ |
|
||||
| `metadata` | json | 作成ステータスを含む作成メタデータ |
|
||||
|
||||
### `apollo_account_update`
|
||||
|
||||
Apolloデータベース内の既存のアカウントを更新する
|
||||
|
||||
#### 入力
|
||||
|
||||
| パラメータ | 型 | 必須 | 説明 |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | はい | Apollo APIキー |
|
||||
| `account_id` | string | はい | 更新するアカウントのID |
|
||||
| `name` | string | いいえ | 会社名 |
|
||||
| `website_url` | string | いいえ | 会社のウェブサイトURL |
|
||||
| `phone` | string | いいえ | 会社の電話番号 |
|
||||
| `owner_id` | string | いいえ | アカウント所有者のユーザーID |
|
||||
|
||||
#### 出力
|
||||
|
||||
| パラメータ | 型 | 説明 |
|
||||
| --------- | ---- | ----------- |
|
||||
| `account` | json | Apolloから更新されたアカウントデータ |
|
||||
| `metadata` | json | 更新ステータスを含む更新メタデータ |
|
||||
|
||||
### `apollo_account_search`
|
||||
|
||||
チームを検索する
|
||||
|
||||
#### 入力
|
||||
|
||||
| パラメータ | 型 | 必須 | 説明 |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | はい | Apollo APIキー(マスターキーが必要) |
|
||||
| `q_keywords` | string | いいえ | アカウントデータで検索するキーワード |
|
||||
| `owner_id` | string | いいえ | アカウント所有者のユーザーIDでフィルタリング |
|
||||
| `account_stage_ids` | array | いいえ | アカウントステージIDでフィルタリング |
|
||||
| `page` | number | いいえ | ページネーションのページ番号 |
|
||||
| `per_page` | number | いいえ | 1ページあたりの結果数(最大:100) |
|
||||
|
||||
#### 出力
|
||||
|
||||
| パラメータ | 型 | 説明 |
|
||||
| --------- | ---- | ----------- |
|
||||
| `accounts` | json | 検索条件に一致するアカウントの配列 |
|
||||
| `metadata` | json | ページ、per_page、total_entriesを含むページネーション情報 |
|
||||
|
||||
### `apollo_account_bulk_create`
|
||||
|
||||
Apolloデータベースに一度に最大100のアカウントを作成できます。注意:Apolloは重複排除を適用しないため、エントリが類似した名前やドメインを共有している場合、重複アカウントが作成される可能性があります。マスターキーが必要です。
|
||||
|
||||
#### 入力
|
||||
|
||||
| パラメータ | 型 | 必須 | 説明 |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | はい | Apollo APIキー(マスターキーが必要) |
|
||||
| `accounts` | array | はい | 作成するアカウントの配列(最大100)。各アカウントには名前(必須)、およびオプションでwebsite_url、phone、owner_idを含める必要があります |
|
||||
|
||||
#### 出力
|
||||
|
||||
| パラメータ | 型 | 説明 |
|
||||
| --------- | ---- | ----------- |
|
||||
| `created_accounts` | json | 新しく作成されたアカウントの配列 |
|
||||
| `failed_accounts` | json | 作成に失敗したアカウントの配列 |
|
||||
| `metadata` | json | 作成されたアカウントと失敗したアカウントの数を含む一括作成メタデータ |
|
||||
|
||||
### `apollo_account_bulk_update`
|
||||
|
||||
Apolloデータベースで一度に最大1000の既存アカウントを更新します(連絡先よりも高い制限!)。各アカウントにはidフィールドを含める必要があります。マスターキーが必要です。
|
||||
|
||||
#### 入力
|
||||
|
||||
| パラメータ | 型 | 必須 | 説明 |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | はい | Apollo APIキー(マスターキーが必要) |
|
||||
| `accounts` | array | はい | 更新するアカウントの配列(最大1000)。各アカウントにはidフィールドを含め、オプションでname、website_url、phone、owner_idを含める必要があります |
|
||||
|
||||
#### 出力
|
||||
|
||||
| パラメータ | 型 | 説明 |
|
||||
| --------- | ---- | ----------- |
|
||||
| `updated_accounts` | json | 正常に更新されたアカウントの配列 |
|
||||
| `failed_accounts` | json | 更新に失敗したアカウントの配列 |
|
||||
| `metadata` | json | 更新および失敗したアカウント数を含む一括更新メタデータ |
|
||||
|
||||
### `apollo_opportunity_create`
|
||||
|
||||
Apolloデータベースのアカウントに新しい取引を作成します(マスターキーが必要)
|
||||
|
||||
#### 入力
|
||||
|
||||
| パラメータ | 型 | 必須 | 説明 |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | はい | Apollo APIキー(マスターキーが必要) |
|
||||
| `name` | string | はい | 商談/取引の名前 |
|
||||
| `account_id` | string | はい | この商談が属するアカウントのID |
|
||||
| `amount` | number | いいえ | 商談の金銭的価値 |
|
||||
| `stage_id` | string | いいえ | 取引ステージのID |
|
||||
| `owner_id` | string | いいえ | 商談所有者のユーザーID |
|
||||
| `close_date` | string | いいえ | 予想クローズ日(ISO 8601形式) |
|
||||
| `description` | string | いいえ | 商談に関する説明またはメモ |
|
||||
|
||||
#### 出力
|
||||
|
||||
| パラメータ | 型 | 説明 |
|
||||
| --------- | ---- | ----------- |
|
||||
| `opportunity` | json | Apolloから作成された商談データ |
|
||||
| `metadata` | json | 作成ステータスを含む作成メタデータ |
|
||||
|
||||
### `apollo_opportunity_search`
|
||||
|
||||
チーム内のすべての取引/商談を検索してリスト表示する
|
||||
|
||||
#### 入力
|
||||
|
||||
| パラメータ | 型 | 必須 | 説明 |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | はい | Apollo APIキー |
|
||||
| `q_keywords` | string | いいえ | 案件名で検索するキーワード |
|
||||
| `account_ids` | array | いいえ | 特定のアカウントIDでフィルタリング |
|
||||
| `stage_ids` | array | いいえ | 商談ステージIDでフィルタリング |
|
||||
| `owner_ids` | array | いいえ | 案件担当者IDでフィルタリング |
|
||||
| `page` | number | いいえ | ページネーション用のページ番号 |
|
||||
| `per_page` | number | いいえ | 1ページあたりの結果数(最大:100) |
|
||||
|
||||
#### 出力
|
||||
|
||||
| パラメータ | 型 | 説明 |
|
||||
| --------- | ---- | ----------- |
|
||||
| `opportunities` | json | 検索条件に一致する案件の配列 |
|
||||
| `metadata` | json | ページ、per_page、total_entriesを含むページネーション情報 |
|
||||
|
||||
### `apollo_opportunity_get`
|
||||
|
||||
IDで特定の商談/案件の詳細情報を取得する
|
||||
|
||||
#### 入力
|
||||
|
||||
| パラメータ | 型 | 必須 | 説明 |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | はい | Apollo APIキー |
|
||||
| `opportunity_id` | string | はい | 取得する案件のID |
|
||||
|
||||
#### 出力
|
||||
|
||||
| パラメータ | 型 | 説明 |
|
||||
| --------- | ---- | ----------- |
|
||||
| `opportunity` | json | Apolloからの完全な案件データ |
|
||||
| `metadata` | json | 検索状態を含む取得メタデータ |
|
||||
|
||||
### `apollo_opportunity_update`
|
||||
|
||||
Apolloデータベース内の既存の商談/案件を更新する
|
||||
|
||||
#### 入力
|
||||
|
||||
| パラメータ | 型 | 必須 | 説明 |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | はい | Apollo APIキー |
|
||||
| `opportunity_id` | string | はい | 更新する案件のID |
|
||||
| `name` | string | いいえ | 案件/取引の名前 |
|
||||
| `amount` | number | いいえ | 案件の金銭的価値 |
|
||||
| `stage_id` | string | いいえ | 取引ステージのID |
|
||||
| `owner_id` | string | いいえ | 案件所有者のユーザーID |
|
||||
| `close_date` | string | いいえ | 予定成約日(ISO 8601形式) |
|
||||
| `description` | string | いいえ | 案件に関する説明やメモ |
|
||||
|
||||
#### 出力
|
||||
|
||||
| パラメータ | 型 | 説明 |
|
||||
| --------- | ---- | ----------- |
|
||||
| `opportunity` | json | Apolloからの更新された案件データ |
|
||||
| `metadata` | json | 更新ステータスを含む更新メタデータ |
|
||||
|
||||
### `apollo_sequence_search`
|
||||
|
||||
チーム内のシーケンス/キャンペーンを検索する
|
||||
|
||||
#### 入力
|
||||
|
||||
| パラメータ | 型 | 必須 | 説明 |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | はい | Apollo APIキー(マスターキーが必要) |
|
||||
| `q_name` | string | いいえ | 名前でシーケンスを検索 |
|
||||
| `active` | boolean | いいえ | アクティブステータスでフィルタリング(アクティブなシーケンスはtrue、非アクティブはfalse) |
|
||||
| `page` | number | いいえ | ページネーションのページ番号 |
|
||||
| `per_page` | number | いいえ | 1ページあたりの結果数(最大:100) |
|
||||
|
||||
#### 出力
|
||||
|
||||
| パラメータ | 型 | 説明 |
|
||||
| --------- | ---- | ----------- |
|
||||
| `sequences` | json | 検索条件に一致するシーケンス/キャンペーンの配列 |
|
||||
| `metadata` | json | ページ、per_page、total_entriesを含むページネーション情報 |
|
||||
|
||||
### `apollo_sequence_add_contacts`
|
||||
|
||||
Apolloシーケンスに連絡先を追加する
|
||||
|
||||
#### 入力
|
||||
|
||||
| パラメータ | 型 | 必須 | 説明 |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | はい | Apollo APIキー(マスターキーが必要) |
|
||||
| `sequence_id` | string | はい | 連絡先を追加するシーケンスのID |
|
||||
| `contact_ids` | array | はい | シーケンスに追加する連絡先IDの配列 |
|
||||
| `emailer_campaign_id` | string | いいえ | オプションのメーラーキャンペーンID |
|
||||
| `send_email_from_user_id` | string | いいえ | メールの送信元となるユーザーID |
|
||||
|
||||
#### 出力
|
||||
|
||||
| パラメータ | 型 | 説明 |
|
||||
| --------- | ---- | ----------- |
|
||||
| `contacts_added` | json | シーケンスに追加された連絡先IDの配列 |
|
||||
| `metadata` | json | sequence_idとtotal_added数を含むシーケンスメタデータ |
|
||||
|
||||
### `apollo_task_create`
|
||||
|
||||
Apolloで新しいタスクを作成する
|
||||
|
||||
#### 入力
|
||||
|
||||
| パラメータ | 型 | 必須 | 説明 |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | はい | Apollo APIキー(マスターキーが必要) |
|
||||
| `note` | string | はい | タスクのメモ/説明 |
|
||||
| `contact_id` | string | いいえ | 関連付ける連絡先ID |
|
||||
| `account_id` | string | いいえ | 関連付けるアカウントID |
|
||||
| `due_at` | string | いいえ | ISO形式の期日 |
|
||||
| `priority` | string | いいえ | タスクの優先度 |
|
||||
| `type` | string | いいえ | タスクのタイプ |
|
||||
|
||||
#### 出力
|
||||
|
||||
| パラメータ | 型 | 説明 |
|
||||
| --------- | ---- | ----------- |
|
||||
| `task` | json | Apolloから作成されたタスクデータ |
|
||||
| `metadata` | json | 作成ステータスを含む作成メタデータ |
|
||||
|
||||
### `apollo_task_search`
|
||||
|
||||
Apolloでタスクを検索する
|
||||
|
||||
#### 入力
|
||||
|
||||
| パラメータ | 型 | 必須 | 説明 |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | はい | Apollo APIキー(マスターキーが必要) |
|
||||
| `contact_id` | string | いいえ | 連絡先IDでフィルタリング |
|
||||
| `account_id` | string | いいえ | アカウントIDでフィルタリング |
|
||||
| `completed` | boolean | いいえ | 完了ステータスでフィルタリング |
|
||||
| `page` | number | いいえ | ページネーション用のページ番号 |
|
||||
| `per_page` | number | いいえ | 1ページあたりの結果数(最大:100) |
|
||||
|
||||
#### 出力
|
||||
|
||||
| パラメータ | 型 | 説明 |
|
||||
| --------- | ---- | ----------- |
|
||||
| `tasks` | json | 検索条件に一致するタスクの配列 |
|
||||
| `metadata` | json | ページ、per_page、total_entriesを含むページネーション情報 |
|
||||
|
||||
### `apollo_email_accounts`
|
||||
|
||||
チームのリストを取得する
|
||||
|
||||
#### 入力
|
||||
|
||||
| パラメータ | 型 | 必須 | 説明 |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | はい | Apollo APIキー(マスターキーが必要) |
|
||||
|
||||
#### 出力
|
||||
|
||||
| パラメータ | 型 | 説明 |
|
||||
| --------- | ---- | ----------- |
|
||||
| `email_accounts` | json | Apolloにリンクされているチームのメールアカウントの配列 |
|
||||
| `metadata` | json | メールアカウントの総数を含むメタデータ |
|
||||
|
||||
## メモ
|
||||
|
||||
- カテゴリー: `tools`
|
||||
- タイプ: `apollo`
|
||||
@@ -70,8 +70,10 @@ Slack APIを通じてSlackチャンネルまたはユーザーにメッセージ
|
||||
|
||||
| パラメータ | 型 | 説明 |
|
||||
| --------- | ---- | ----------- |
|
||||
| `message` | object | Slackから返されたすべてのプロパティを含む完全なメッセージオブジェクト |
|
||||
| `ts` | string | メッセージのタイムスタンプ |
|
||||
| `channel` | string | メッセージが送信されたチャンネルID |
|
||||
| `fileCount` | number | アップロードされたファイル数(ファイルが添付されている場合) |
|
||||
|
||||
### `slack_canvas`
|
||||
|
||||
@@ -154,6 +156,7 @@ Slackでボットが以前送信したメッセージを更新する
|
||||
|
||||
| パラメータ | 型 | 説明 |
|
||||
| --------- | ---- | ----------- |
|
||||
| `message` | object | Slackから返されたすべてのプロパティを含む完全に更新されたメッセージオブジェクト |
|
||||
| `content` | string | 成功メッセージ |
|
||||
| `metadata` | object | 更新されたメッセージのメタデータ |
|
||||
|
||||
|
||||
574
apps/docs/content/docs/zh/tools/apollo.mdx
Normal file
574
apps/docs/content/docs/zh/tools/apollo.mdx
Normal file
@@ -0,0 +1,574 @@
|
||||
---
|
||||
title: Apollo
|
||||
description: 使用 Apollo.io 搜索、丰富和管理联系人
|
||||
---
|
||||
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="apollo"
|
||||
color="#EBF212"
|
||||
/>
|
||||
|
||||
{/* MANUAL-CONTENT-START:intro */}
|
||||
[Apollo.io](https://apollo.io/) 是一个领先的销售情报和互动平台,帮助用户大规模地查找、丰富和互动联系人及公司。Apollo.io 将广泛的联系人数据库与强大的数据丰富和工作流自动化工具相结合,帮助销售、市场营销和招聘团队加速增长。
|
||||
|
||||
使用 Apollo.io,您可以:
|
||||
|
||||
- **搜索数百万联系人和公司**:使用高级筛选器找到精准的潜在客户
|
||||
- **丰富潜在客户和账户信息**:用经过验证的数据和最新信息补充缺失的细节
|
||||
- **管理和组织 CRM 记录**:保持人员和公司数据的准确性和可操作性
|
||||
- **自动化外联**:直接从 Apollo.io 将联系人添加到序列中并创建后续任务
|
||||
|
||||
在 Sim 中,Apollo.io 集成允许您的代理以编程方式执行核心 Apollo 操作:
|
||||
|
||||
- **搜索人员和公司**:使用 `apollo_people_search` 通过灵活的筛选器发现新的潜在客户。
|
||||
- **丰富人员数据**:使用 `apollo_people_enrich` 为联系人补充经过验证的信息。
|
||||
- **批量丰富人员数据**:使用 `apollo_people_bulk_enrich` 一次性大规模丰富多个联系人。
|
||||
- **搜索和丰富公司信息**:使用 `apollo_company_search` 和 `apollo_company_enrich` 发现和更新关键的公司信息。
|
||||
|
||||
这使您的代理能够构建强大的工作流,用于潜在客户开发、CRM 数据丰富和自动化,而无需手动输入数据或切换标签页。将 Apollo.io 集成为动态数据源和 CRM 引擎,帮助您的代理在日常操作中无缝识别、筛选和联系潜在客户。
|
||||
{/* MANUAL-CONTENT-END */}
|
||||
|
||||
## 使用说明
|
||||
|
||||
将 Apollo.io 集成到工作流程中。搜索人员和公司,丰富联系数据,管理您的 CRM 联系人和账户,将联系人添加到序列中,并创建任务。
|
||||
|
||||
## 工具
|
||||
|
||||
### `apollo_people_search`
|
||||
|
||||
搜索 Apollo
|
||||
|
||||
#### 输入
|
||||
|
||||
| 参数 | 类型 | 必需 | 描述 |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | 是 | Apollo API 密钥 |
|
||||
| `person_titles` | array | 否 | 要搜索的职位 \(例如,\["CEO", "VP of Sales"\]\) |
|
||||
| `person_locations` | array | 否 | 要搜索的地点 \(例如,\["San Francisco, CA", "New York, NY"\]\) |
|
||||
| `person_seniorities` | array | 否 | 职级 \(例如,\["senior", "executive", "manager"\]\) |
|
||||
| `organization_names` | array | 否 | 要搜索的公司名称 |
|
||||
| `q_keywords` | string | 否 | 要搜索的关键词 |
|
||||
| `page` | number | 否 | 分页的页码 \(默认值:1\) |
|
||||
| `per_page` | number | 否 | 每页的结果数 \(默认值:25,最大值:100\) |
|
||||
|
||||
#### 输出
|
||||
|
||||
| 参数 | 类型 | 描述 |
|
||||
| --------- | ---- | ----------- |
|
||||
| `people` | json | 符合搜索条件的人员数组 |
|
||||
| `metadata` | json | 包括页码、每页条目数和总条目数的分页信息 |
|
||||
|
||||
### `apollo_people_enrich`
|
||||
|
||||
使用 Apollo 丰富单个人的数据
|
||||
|
||||
#### 输入
|
||||
|
||||
| 参数 | 类型 | 必需 | 描述 |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | 是 | Apollo API 密钥 |
|
||||
| `first_name` | string | 否 | 该人的名字 |
|
||||
| `last_name` | string | 否 | 该人的姓氏 |
|
||||
| `email` | string | 否 | 该人的电子邮件地址 |
|
||||
| `organization_name` | string | 否 | 该人工作的公司名称 |
|
||||
| `domain` | string | 否 | 公司域名 \(例如,apollo.io\) |
|
||||
| `linkedin_url` | string | 否 | LinkedIn 个人资料 URL |
|
||||
| `reveal_personal_emails` | boolean | 否 | 显示个人电子邮件地址 \(使用积分\) |
|
||||
| `reveal_phone_number` | boolean | 否 | 显示电话号码 \(使用积分\) |
|
||||
|
||||
#### 输出
|
||||
|
||||
| 参数 | 类型 | 描述 |
|
||||
| --------- | ---- | ----------- |
|
||||
| `person` | json | 来自 Apollo 的丰富人员数据 |
|
||||
| `metadata` | json | 包括丰富状态的元数据 |
|
||||
|
||||
### `apollo_people_bulk_enrich`
|
||||
|
||||
使用 Apollo 一次丰富最多 10 人的数据
|
||||
|
||||
#### 输入
|
||||
|
||||
| 参数 | 类型 | 必需 | 描述 |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | 是 | Apollo API 密钥 |
|
||||
| `people` | array | 是 | 要丰富的人员数组(最多 10 人) |
|
||||
| `reveal_personal_emails` | boolean | 否 | 显示个人电子邮件地址(使用积分) |
|
||||
| `reveal_phone_number` | boolean | 否 | 显示电话号码(使用积分) |
|
||||
|
||||
#### 输出
|
||||
|
||||
| 参数 | 类型 | 描述 |
|
||||
| --------- | ---- | ----------- |
|
||||
| `people` | json | 丰富的人员数据数组 |
|
||||
| `metadata` | json | 批量丰富元数据,包括总数和丰富计数 |
|
||||
|
||||
### `apollo_organization_search`
|
||||
|
||||
搜索 Apollo
|
||||
|
||||
#### 输入
|
||||
|
||||
| 参数 | 类型 | 必需 | 描述 |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | 是 | Apollo API 密钥 |
|
||||
| `organization_locations` | array | 否 | 要搜索的公司位置 |
|
||||
| `organization_num_employees_ranges` | array | 否 | 员工数量范围(例如,\["1-10", "11-50"\]) |
|
||||
| `q_organization_keyword_tags` | array | 否 | 行业或关键词标签 |
|
||||
| `q_organization_name` | string | 否 | 要搜索的组织名称 |
|
||||
| `page` | number | 否 | 分页的页码 |
|
||||
| `per_page` | number | 否 | 每页结果数(最多:100) |
|
||||
|
||||
#### 输出
|
||||
|
||||
| 参数 | 类型 | 描述 |
|
||||
| --------- | ---- | ----------- |
|
||||
| `organizations` | json | 符合搜索条件的组织数组 |
|
||||
| `metadata` | json | 分页信息,包括页面、每页条目数和总条目数 |
|
||||
|
||||
### `apollo_organization_enrich`
|
||||
|
||||
使用 Apollo 为单个组织丰富数据
|
||||
|
||||
#### 输入
|
||||
|
||||
| 参数 | 类型 | 必需 | 描述 |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | 是 | Apollo API 密钥 |
|
||||
| `organization_name` | string | 否 | 组织名称(组织名称或域名至少需要一个) |
|
||||
| `domain` | string | 否 | 公司域名(例如,apollo.io)(域名或组织名称至少需要一个) |
|
||||
|
||||
#### 输出
|
||||
|
||||
| 参数 | 类型 | 描述 |
|
||||
| --------- | ---- | ----------- |
|
||||
| `organization` | json | 来自 Apollo 的丰富组织数据 |
|
||||
| `metadata` | json | 丰富的元数据,包括丰富状态 |
|
||||
|
||||
### `apollo_organization_bulk_enrich`
|
||||
|
||||
使用 Apollo 一次为最多 10 个组织丰富数据
|
||||
|
||||
#### 输入
|
||||
|
||||
| 参数 | 类型 | 必需 | 描述 |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | 是 | Apollo API 密钥 |
|
||||
| `organizations` | array | 是 | 要丰富的组织数组(最多 10 个) |
|
||||
|
||||
#### 输出
|
||||
|
||||
| 参数 | 类型 | 描述 |
|
||||
| --------- | ---- | ----------- |
|
||||
| `organizations` | json | 丰富的组织数据数组 |
|
||||
| `metadata` | json | 批量丰富的元数据,包括总数和丰富的计数 |
|
||||
|
||||
### `apollo_contact_create`
|
||||
|
||||
在您的 Apollo 数据库中创建一个新联系人
|
||||
|
||||
#### 输入
|
||||
|
||||
| 参数 | 类型 | 必需 | 描述 |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | 是 | Apollo API 密钥 |
|
||||
| `first_name` | string | 是 | 联系人的名字 |
|
||||
| `last_name` | string | 是 | 联系人的姓氏 |
|
||||
| `email` | string | 否 | 联系人的电子邮件地址 |
|
||||
| `title` | string | 否 | 职位名称 |
|
||||
| `account_id` | string | 否 | 要关联的 Apollo 账户 ID |
|
||||
| `owner_id` | string | 否 | 联系人所有者的用户 ID |
|
||||
|
||||
#### 输出
|
||||
|
||||
| 参数 | 类型 | 描述 |
|
||||
| --------- | ---- | ----------- |
|
||||
| `contact` | json | 从 Apollo 创建的联系人数据 |
|
||||
| `metadata` | json | 创建元数据,包括创建状态 |
|
||||
|
||||
### `apollo_contact_update`
|
||||
|
||||
更新您 Apollo 数据库中的现有联系人
|
||||
|
||||
#### 输入
|
||||
|
||||
| 参数 | 类型 | 必需 | 描述 |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | 是 | Apollo API 密钥 |
|
||||
| `contact_id` | string | 是 | 要更新的联系人的 ID |
|
||||
| `first_name` | string | 否 | 联系人的名字 |
|
||||
| `last_name` | string | 否 | 联系人的姓氏 |
|
||||
| `email` | string | 否 | 电子邮件地址 |
|
||||
| `title` | string | 否 | 职位名称 |
|
||||
| `account_id` | string | 否 | Apollo 账户 ID |
|
||||
| `owner_id` | string | 否 | 联系人所有者的用户 ID |
|
||||
|
||||
#### 输出
|
||||
|
||||
| 参数 | 类型 | 描述 |
|
||||
| --------- | ---- | ----------- |
|
||||
| `contact` | json | 来自 Apollo 的更新联系人数据 |
|
||||
| `metadata` | json | 更新元数据,包括更新状态 |
|
||||
|
||||
### `apollo_contact_search`
|
||||
|
||||
搜索您的团队
|
||||
|
||||
#### 输入
|
||||
|
||||
| 参数 | 类型 | 必需 | 描述 |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | 是 | Apollo API 密钥 |
|
||||
| `q_keywords` | string | 否 | 要搜索的关键字 |
|
||||
| `contact_stage_ids` | array | 否 | 按联系人阶段 ID 过滤 |
|
||||
| `page` | number | 否 | 分页的页码 |
|
||||
| `per_page` | number | 否 | 每页结果数 \(最大值:100\) |
|
||||
|
||||
#### 输出
|
||||
|
||||
| 参数 | 类型 | 描述 |
|
||||
| --------- | ---- | ----------- |
|
||||
| `contacts` | json | 符合搜索条件的联系人数组 |
|
||||
| `metadata` | json | 分页信息,包括页码、每页条目数和总条目数 |
|
||||
|
||||
### `apollo_contact_bulk_create`
|
||||
|
||||
在您的 Apollo 数据库中一次创建最多 100 个联系人。支持去重以防止创建重复联系人。需要主密钥。
|
||||
|
||||
#### 输入
|
||||
|
||||
| 参数 | 类型 | 必需 | 描述 |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | 是 | Apollo API 密钥 \(需要主密钥\) |
|
||||
| `contacts` | array | 是 | 要创建的联系人数组 \(最多 100 个\)。每个联系人应包括 first_name、last_name,以及可选的 email、title、account_id、owner_id |
|
||||
| `run_dedupe` | boolean | 否 | 启用去重以防止创建重复联系人。当为 true 时,返回现有联系人而不进行修改 |
|
||||
|
||||
#### 输出
|
||||
|
||||
| 参数 | 类型 | 描述 |
|
||||
| --------- | ---- | ----------- |
|
||||
| `created_contacts` | json | 新创建联系人数组 |
|
||||
| `existing_contacts` | json | 已存在联系人数组(当启用去重时) |
|
||||
| `metadata` | json | 批量创建元数据,包括创建和已存在联系人的计数 |
|
||||
|
||||
### `apollo_contact_bulk_update`
|
||||
|
||||
一次更新最多 100 个现有联系人到您的 Apollo 数据库中。每个联系人必须包含一个 id 字段。需要主密钥。
|
||||
|
||||
#### 输入
|
||||
|
||||
| 参数 | 类型 | 必需 | 描述 |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | 是 | Apollo API 密钥(需要主密钥) |
|
||||
| `contacts` | array | 是 | 要更新的联系人数组(最多 100 个)。每个联系人必须包含 id 字段,并可选包含 first_name、last_name、email、title、account_id、owner_id |
|
||||
|
||||
#### 输出
|
||||
|
||||
| 参数 | 类型 | 描述 |
|
||||
| --------- | ---- | ----------- |
|
||||
| `updated_contacts` | json | 成功更新的联系人数组 |
|
||||
| `failed_contacts` | json | 更新失败的联系人数组 |
|
||||
| `metadata` | json | 批量更新元数据,包括更新和失败联系人的计数 |
|
||||
|
||||
### `apollo_account_create`
|
||||
|
||||
在您的 Apollo 数据库中创建一个新账户(公司)
|
||||
|
||||
#### 输入
|
||||
|
||||
| 参数 | 类型 | 必需 | 描述 |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | 是 | Apollo API 密钥 |
|
||||
| `name` | string | 是 | 公司名称 |
|
||||
| `website_url` | string | 否 | 公司网站 URL |
|
||||
| `phone` | string | 否 | 公司电话号码 |
|
||||
| `owner_id` | string | 否 | 账户所有者的用户 ID |
|
||||
|
||||
#### 输出
|
||||
|
||||
| 参数 | 类型 | 描述 |
|
||||
| --------- | ---- | ----------- |
|
||||
| `account` | json | 从 Apollo 创建的账户数据 |
|
||||
| `metadata` | json | 包括创建状态的元数据 |
|
||||
|
||||
### `apollo_account_update`
|
||||
|
||||
更新 Apollo 数据库中的现有账户
|
||||
|
||||
#### 输入
|
||||
|
||||
| 参数 | 类型 | 必需 | 描述 |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | 是 | Apollo API 密钥 |
|
||||
| `account_id` | string | 是 | 要更新的账户 ID |
|
||||
| `name` | string | 否 | 公司名称 |
|
||||
| `website_url` | string | 否 | 公司网站 URL |
|
||||
| `phone` | string | 否 | 公司电话号码 |
|
||||
| `owner_id` | string | 否 | 账户所有者的用户 ID |
|
||||
|
||||
#### 输出
|
||||
|
||||
| 参数 | 类型 | 描述 |
|
||||
| --------- | ---- | ----------- |
|
||||
| `account` | json | 从 Apollo 更新的账户数据 |
|
||||
| `metadata` | json | 包括更新状态的元数据 |
|
||||
|
||||
### `apollo_account_search`
|
||||
|
||||
搜索您的团队
|
||||
|
||||
#### 输入
|
||||
|
||||
| 参数 | 类型 | 必需 | 描述 |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | 是 | Apollo API 密钥(需要主密钥) |
|
||||
| `q_keywords` | string | 否 | 在账户数据中搜索的关键字 |
|
||||
| `owner_id` | string | 否 | 按账户所有者用户 ID 过滤 |
|
||||
| `account_stage_ids` | array | 否 | 按账户阶段 ID 过滤 |
|
||||
| `page` | number | 否 | 分页的页码 |
|
||||
| `per_page` | number | 否 | 每页结果数(最大值:100) |
|
||||
|
||||
#### 输出
|
||||
|
||||
| 参数 | 类型 | 描述 |
|
||||
| --------- | ---- | ----------- |
|
||||
| `accounts` | json | 符合搜索条件的账户数组 |
|
||||
| `metadata` | json | 分页信息,包括页面、每页条目数和总条目数 |
|
||||
|
||||
### `apollo_account_bulk_create`
|
||||
|
||||
在您的 Apollo 数据库中一次最多创建 100 个账户。注意:Apollo 不会进行去重处理——如果条目具有相似的名称或域名,可能会创建重复账户。需要主密钥。
|
||||
|
||||
#### 输入
|
||||
|
||||
| 参数 | 类型 | 必需 | 描述 |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | 是 | Apollo API 密钥(需要主密钥) |
|
||||
| `accounts` | array | 是 | 要创建的账户数组(最多 100 个)。每个账户应包括名称(必需),以及可选的 website_url、phone、owner_id |
|
||||
|
||||
#### 输出
|
||||
|
||||
| 参数 | 类型 | 描述 |
|
||||
| --------- | ---- | ----------- |
|
||||
| `created_accounts` | json | 新创建账户的数组 |
|
||||
| `failed_accounts` | json | 创建失败的账户数组 |
|
||||
| `metadata` | json | 批量创建的元数据,包括创建和失败账户的计数 |
|
||||
|
||||
### `apollo_account_bulk_update`
|
||||
|
||||
在您的 Apollo 数据库中一次最多更新 1000 个现有账户(比联系人限制更高!)。每个账户必须包含一个 id 字段。需要主密钥。
|
||||
|
||||
#### 输入
|
||||
|
||||
| 参数 | 类型 | 必需 | 描述 |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | 是 | Apollo API 密钥(需要主密钥) |
|
||||
| `accounts` | array | 是 | 要更新的账户数组(最多 1000 个)。每个账户必须包含 id 字段,以及可选的 name、website_url、phone、owner_id |
|
||||
|
||||
#### 输出
|
||||
|
||||
| 参数 | 类型 | 描述 |
|
||||
| --------- | ---- | ----------- |
|
||||
| `updated_accounts` | json | 成功更新的账户数组 |
|
||||
| `failed_accounts` | json | 更新失败的账户数组 |
|
||||
| `metadata` | json | 批量更新的元数据,包括更新和失败账户的计数 |
|
||||
|
||||
### `apollo_opportunity_create`
|
||||
|
||||
在您的 Apollo 数据库中为一个账户创建一个新交易(需要主密钥)
|
||||
|
||||
#### 输入
|
||||
|
||||
| 参数 | 类型 | 必需 | 描述 |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | 是 | Apollo API 密钥(需要主密钥) |
|
||||
| `name` | string | 是 | 机会/交易的名称 |
|
||||
| `account_id` | string | 是 | 此机会所属账户的 ID |
|
||||
| `amount` | number | 否 | 机会的货币价值 |
|
||||
| `stage_id` | string | 否 | 交易阶段的 ID |
|
||||
| `owner_id` | string | 否 | 机会所有者的用户 ID |
|
||||
| `close_date` | string | 否 | 预期的关闭日期(ISO 8601 格式) |
|
||||
| `description` | string | 否 | 关于机会的描述或备注 |
|
||||
|
||||
#### 输出
|
||||
|
||||
| 参数 | 类型 | 描述 |
|
||||
| --------- | ---- | ----------- |
|
||||
| `opportunity` | json | 来自 Apollo 的已创建机会数据 |
|
||||
| `metadata` | json | 创建元数据,包括创建状态 |
|
||||
|
||||
### `apollo_opportunity_search`
|
||||
|
||||
搜索并列出您团队中的所有交易/机会
|
||||
|
||||
#### 输入
|
||||
|
||||
| 参数 | 类型 | 必需 | 描述 |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | 是 | Apollo API 密钥 |
|
||||
| `q_keywords` | string | 否 | 在机会名称中搜索的关键字 |
|
||||
| `account_ids` | array | 否 | 按特定账户 ID 过滤 |
|
||||
| `stage_ids` | array | 否 | 按交易阶段 ID 过滤 |
|
||||
| `owner_ids` | array | 否 | 按机会所有者 ID 过滤 |
|
||||
| `page` | number | 否 | 分页的页码 |
|
||||
| `per_page` | number | 否 | 每页结果数 \(最大值: 100\) |
|
||||
|
||||
#### 输出
|
||||
|
||||
| 参数 | 类型 | 描述 |
|
||||
| --------- | ---- | ----------- |
|
||||
| `opportunities` | json | 符合搜索条件的机会数组 |
|
||||
| `metadata` | json | 分页信息,包括页码、每页条目数和总条目数 |
|
||||
|
||||
### `apollo_opportunity_get`
|
||||
|
||||
通过 ID 检索特定交易/机会的完整详细信息
|
||||
|
||||
#### 输入
|
||||
|
||||
| 参数 | 类型 | 必需 | 描述 |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | 是 | Apollo API 密钥 |
|
||||
| `opportunity_id` | string | 是 | 要检索的机会 ID |
|
||||
|
||||
#### 输出
|
||||
|
||||
| 参数 | 类型 | 描述 |
|
||||
| --------- | ---- | ----------- |
|
||||
| `opportunity` | json | Apollo 提供的完整机会数据 |
|
||||
| `metadata` | json | 检索元数据,包括找到的状态 |
|
||||
|
||||
### `apollo_opportunity_update`
|
||||
|
||||
更新 Apollo 数据库中现有的交易/机会
|
||||
|
||||
#### 输入
|
||||
|
||||
| 参数 | 类型 | 必需 | 描述 |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | 是 | Apollo API 密钥 |
|
||||
| `opportunity_id` | string | 是 | 要更新的机会 ID |
|
||||
| `name` | string | 否 | 机会/交易的名称 |
|
||||
| `amount` | number | 否 | 机会的货币价值 |
|
||||
| `stage_id` | string | 否 | 交易阶段的 ID |
|
||||
| `owner_id` | string | 否 | 机会所有者的用户 ID |
|
||||
| `close_date` | string | 否 | 预期的结束日期(ISO 8601 格式) |
|
||||
| `description` | string | 否 | 关于机会的描述或备注 |
|
||||
|
||||
#### 输出
|
||||
|
||||
| 参数 | 类型 | 描述 |
|
||||
| --------- | ---- | ----------- |
|
||||
| `opportunity` | json | 来自 Apollo 的更新机会数据 |
|
||||
| `metadata` | json | 更新的元数据,包括更新状态 |
|
||||
|
||||
### `apollo_sequence_search`
|
||||
|
||||
在您的团队中搜索序列/活动
|
||||
|
||||
#### 输入
|
||||
|
||||
| 参数 | 类型 | 必需 | 描述 |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | 是 | Apollo API 密钥(需要主密钥) |
|
||||
| `q_name` | string | 否 | 按名称搜索序列 |
|
||||
| `active` | boolean | 否 | 按活动状态筛选(true 表示活动序列,false 表示非活动序列) |
|
||||
| `page` | number | 否 | 分页的页码 |
|
||||
| `per_page` | number | 否 | 每页结果数(最大值:100) |
|
||||
|
||||
#### 输出
|
||||
|
||||
| 参数 | 类型 | 描述 |
|
||||
| --------- | ---- | ----------- |
|
||||
| `sequences` | json | 符合搜索条件的序列/活动数组 |
|
||||
| `metadata` | json | 分页信息,包括页面、每页条目数和总条目数 |
|
||||
|
||||
### `apollo_sequence_add_contacts`
|
||||
|
||||
将联系人添加到 Apollo 序列
|
||||
|
||||
#### 输入
|
||||
|
||||
| 参数 | 类型 | 必需 | 描述 |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | 是 | Apollo API 密钥(需要主密钥) |
|
||||
| `sequence_id` | string | 是 | 要添加联系人的序列 ID |
|
||||
| `contact_ids` | array | 是 | 要添加到序列的联系人 ID 数组 |
|
||||
| `emailer_campaign_id` | string | 否 | 可选的电子邮件活动 ID |
|
||||
| `send_email_from_user_id` | string | 否 | 发送电子邮件的用户 ID |
|
||||
|
||||
#### 输出
|
||||
|
||||
| 参数 | 类型 | 描述 |
|
||||
| --------- | ---- | ----------- |
|
||||
| `contacts_added` | json | 添加到序列的联系人 ID 数组 |
|
||||
| `metadata` | json | 序列元数据,包括 sequence_id 和 total_added 计数 |
|
||||
|
||||
### `apollo_task_create`
|
||||
|
||||
在 Apollo 中创建新任务
|
||||
|
||||
#### 输入
|
||||
|
||||
| 参数 | 类型 | 必需 | 描述 |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | 是 | Apollo API 密钥(需要主密钥) |
|
||||
| `note` | string | 是 | 任务备注/描述 |
|
||||
| `contact_id` | string | 否 | 要关联的联系人 ID |
|
||||
| `account_id` | string | 否 | 要关联的账户 ID |
|
||||
| `due_at` | string | 否 | ISO 格式的截止日期 |
|
||||
| `priority` | string | 否 | 任务优先级 |
|
||||
| `type` | string | 否 | 任务类型 |
|
||||
|
||||
#### 输出
|
||||
|
||||
| 参数 | 类型 | 描述 |
|
||||
| --------- | ---- | ----------- |
|
||||
| `task` | json | 从 Apollo 创建的任务数据 |
|
||||
| `metadata` | json | 包括创建状态的元数据 |
|
||||
|
||||
### `apollo_task_search`
|
||||
|
||||
在 Apollo 中搜索任务
|
||||
|
||||
#### 输入
|
||||
|
||||
| 参数 | 类型 | 必需 | 描述 |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | 是 | Apollo API 密钥(需要主密钥) |
|
||||
| `contact_id` | string | 否 | 按联系人 ID 过滤 |
|
||||
| `account_id` | string | 否 | 按账户 ID 过滤 |
|
||||
| `completed` | boolean | 否 | 按完成状态过滤 |
|
||||
| `page` | number | 否 | 分页的页码 |
|
||||
| `per_page` | number | 否 | 每页结果数(最大值:100) |
|
||||
|
||||
#### 输出
|
||||
|
||||
| 参数 | 类型 | 描述 |
|
||||
| --------- | ---- | ----------- |
|
||||
| `tasks` | json | 符合搜索条件的任务数组 |
|
||||
| `metadata` | json | 分页信息,包括页码、每页数量和总条目数 |
|
||||
|
||||
### `apollo_email_accounts`
|
||||
|
||||
获取团队列表
|
||||
|
||||
#### 输入
|
||||
|
||||
| 参数 | 类型 | 必需 | 描述 |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | 是 | Apollo API 密钥(需要主密钥) |
|
||||
|
||||
#### 输出
|
||||
|
||||
| 参数 | 类型 | 描述 |
|
||||
| --------- | ---- | ----------- |
|
||||
| `email_accounts` | json | 在 Apollo 中链接的团队电子邮件账户数组 |
|
||||
| `metadata` | json | 元数据,包括电子邮件账户的总数 |
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 类别:`tools`
|
||||
- 类型:`apollo`
|
||||
@@ -69,8 +69,10 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
| 参数 | 类型 | 描述 |
|
||||
| --------- | ---- | ----------- |
|
||||
| `message` | object | 包含 Slack 返回的所有属性的完整消息对象 |
|
||||
| `ts` | string | 消息时间戳 |
|
||||
| `channel` | string | 消息发送的频道 ID |
|
||||
| `channel` | string | 发送消息的频道 ID |
|
||||
| `fileCount` | number | 上传的文件数量(当附加文件时) |
|
||||
|
||||
### `slack_canvas`
|
||||
|
||||
@@ -153,8 +155,9 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
| 参数 | 类型 | 描述 |
|
||||
| --------- | ---- | ----------- |
|
||||
| `message` | object | 包含 Slack 返回的所有属性的完整更新消息对象 |
|
||||
| `content` | string | 成功消息 |
|
||||
| `metadata` | object | 更新后的消息元数据 |
|
||||
| `metadata` | object | 更新的消息元数据 |
|
||||
|
||||
### `slack_delete_message`
|
||||
|
||||
|
||||
@@ -894,7 +894,7 @@ checksums:
|
||||
content/14: 371d0e46b4bd2c23f559b8bc112f6955
|
||||
content/15: 2f696275726cdeefd7d7280b5bb43b21
|
||||
content/16: bcadfc362b69078beee0088e5936c98b
|
||||
content/17: 1f31e78210417a7f251f29e0b93a8528
|
||||
content/17: bb43e4f36fdc1eb6211f46ddeed9e0aa
|
||||
content/18: 05540cb3028d4d781521c14e5f9e3835
|
||||
content/19: 14583a25c48ebea2cef414b4758b883d
|
||||
content/20: 371d0e46b4bd2c23f559b8bc112f6955
|
||||
@@ -918,7 +918,7 @@ checksums:
|
||||
content/38: 371d0e46b4bd2c23f559b8bc112f6955
|
||||
content/39: 5319bf5409aced353e6b9d67f597ffef
|
||||
content/40: bcadfc362b69078beee0088e5936c98b
|
||||
content/41: bc5ec1d17d0dbc502ad0f27b6b8b1242
|
||||
content/41: d67f49be147c5ea63f88554f3b8eaed1
|
||||
content/42: e10ecb501eb65fd1a59501a40b707c7a
|
||||
content/43: d829a82e9bcbcfb6239ca2ed9e10ba77
|
||||
content/44: 371d0e46b4bd2c23f559b8bc112f6955
|
||||
@@ -45028,3 +45028,169 @@ checksums:
|
||||
content/38: 6140e8df144c799f9ad293cab91f87b5
|
||||
content/39: ebbaea1438438f691d0f84ad89bcfc18
|
||||
content/40: ac72cc2c55acf316557607a817dce4db
|
||||
fd4428447037efb6f1fdcdb1cb0c0019:
|
||||
meta/title: d322235b2ee086191aa5bc73646c7ebd
|
||||
meta/description: eca9d4e0907d689f1dec8397acdff5f2
|
||||
content/0: 1b031fb0c62c46b177aeed5c3d3f8f80
|
||||
content/1: 6335dab76a38445c8367a069fc035a9a
|
||||
content/2: 0385e919444c586c63091d3ee20bce90
|
||||
content/3: aceaaf0857904513c19857cdf7f309d6
|
||||
content/4: 9091fba2c80a89bf3a36ce581831e9ad
|
||||
content/5: da00278f849ef04f7a6ed713b6eba2f8
|
||||
content/6: 4d860e38ee1c2551fa66f9b088226f38
|
||||
content/7: b778e4673a3415dcf105121f25deb1ea
|
||||
content/8: 821e6394b0a953e2b0842b04ae8f3105
|
||||
content/9: 4e56a0009fc320a6b2cdee2c8a42afe7
|
||||
content/10: 9c8aa3f09c9b2bd50ea4cdff3598ea4e
|
||||
content/11: de33e442f1f340252c811bd7c42b61b0
|
||||
content/12: 334d84e6dd04c3d542912dfdfb263c05
|
||||
content/13: 371d0e46b4bd2c23f559b8bc112f6955
|
||||
content/14: 207ec07169d9e5d8372e1ff765aff4d3
|
||||
content/15: bcadfc362b69078beee0088e5936c98b
|
||||
content/16: 4901d7a460798dd849ebc6b74494264d
|
||||
content/17: 5e4935360e00b6ea57291fafe15e104d
|
||||
content/18: e9beb5edc9139c53754e929a6bf470a6
|
||||
content/19: 371d0e46b4bd2c23f559b8bc112f6955
|
||||
content/20: fc8d4286283ee9b8d374dd0b85102fdd
|
||||
content/21: bcadfc362b69078beee0088e5936c98b
|
||||
content/22: 6e5f8e14c30b075d46bce342c5fc7865
|
||||
content/23: b8a0120d7eecedf101e1338f181feb6e
|
||||
content/24: 07a1b9ee6eaef529ec9d2c30b27b5440
|
||||
content/25: 371d0e46b4bd2c23f559b8bc112f6955
|
||||
content/26: 50e419e7a03c621bf92d745665a4bf6a
|
||||
content/27: bcadfc362b69078beee0088e5936c98b
|
||||
content/28: e95c41ae76f88ac54e3fbbcebb0a88d4
|
||||
content/29: 13747b857ab43cf125259d9d58da0121
|
||||
content/30: 334d84e6dd04c3d542912dfdfb263c05
|
||||
content/31: 371d0e46b4bd2c23f559b8bc112f6955
|
||||
content/32: 9fadddb9651b5b80a01400e3fc4274db
|
||||
content/33: bcadfc362b69078beee0088e5936c98b
|
||||
content/34: d3df313379f8b7495f6fc81da7cb4b99
|
||||
content/35: 1d3f987dc94f4fb0634910a88ab959f0
|
||||
content/36: 36722410714fa3ca192ca462b48df61e
|
||||
content/37: 371d0e46b4bd2c23f559b8bc112f6955
|
||||
content/38: f7ec5c6fa6fe9775ab8ad27807e55a8a
|
||||
content/39: bcadfc362b69078beee0088e5936c98b
|
||||
content/40: b6ab50ed4e824c5d4fbbfca1d337ef1a
|
||||
content/41: 15fe7951afdbbb5f6727e42e6d6e8dd1
|
||||
content/42: a6d225acd601dd15cb0f5c3aa0967a34
|
||||
content/43: 371d0e46b4bd2c23f559b8bc112f6955
|
||||
content/44: 1ecaa277fb2a86eb3951e35950b26a2d
|
||||
content/45: bcadfc362b69078beee0088e5936c98b
|
||||
content/46: 698c88b8d2835f3dc0b410882b6726e6
|
||||
content/47: 024ae5260640857007249003da2ecc34
|
||||
content/48: 53f5ac34170fc92cbddce8e82aeec295
|
||||
content/49: 371d0e46b4bd2c23f559b8bc112f6955
|
||||
content/50: 5734d7d8da47574794f00a231963168d
|
||||
content/51: bcadfc362b69078beee0088e5936c98b
|
||||
content/52: 014668581a1f0e1300f174798e99155a
|
||||
content/53: 0f266d84627c2b1afb4474bf3d29c0c2
|
||||
content/54: a8d63ec1fe0f439f2b73fcdd24e8096d
|
||||
content/55: 371d0e46b4bd2c23f559b8bc112f6955
|
||||
content/56: d3487b6aea8c7aef255548912a1f884f
|
||||
content/57: bcadfc362b69078beee0088e5936c98b
|
||||
content/58: 5b013f07822152f3762efe3c3a05a525
|
||||
content/59: df8fb91a0117aab1fd389fc0c45d5463
|
||||
content/60: 5d37bda5eabaf494fc1b634c3ec10604
|
||||
content/61: 371d0e46b4bd2c23f559b8bc112f6955
|
||||
content/62: d7f063f2a50ce69a6838d5d1fed73d68
|
||||
content/63: bcadfc362b69078beee0088e5936c98b
|
||||
content/64: 5804cc57d55b0a2b69f663a6b6175664
|
||||
content/65: b52e06780703fbc5562eeca991cda5ed
|
||||
content/66: 49cdedbe6a85a50425e44edae9ab3807
|
||||
content/67: 371d0e46b4bd2c23f559b8bc112f6955
|
||||
content/68: 45a432ec65846ccc3d75177cf62b3327
|
||||
content/69: bcadfc362b69078beee0088e5936c98b
|
||||
content/70: 30c796112578f56385e527b0b510cccd
|
||||
content/71: e9eeb0c45ae7667b2b7bef13d7410fea
|
||||
content/72: 9cdadfeb63fc742922f59eab42508f7f
|
||||
content/73: 371d0e46b4bd2c23f559b8bc112f6955
|
||||
content/74: 0a8cb6c46f1a9a12bc308bacd253c029
|
||||
content/75: bcadfc362b69078beee0088e5936c98b
|
||||
content/76: 4c6c12405327c20da91484b6e7333706
|
||||
content/77: e2a10746d92f38b473df73badd7c70b8
|
||||
content/78: fe523ab7f0c1c2881cad308fe1f83872
|
||||
content/79: 371d0e46b4bd2c23f559b8bc112f6955
|
||||
content/80: 7a0e820a1021874d84545371628c9203
|
||||
content/81: bcadfc362b69078beee0088e5936c98b
|
||||
content/82: d861918462b4f32885d3e803a65cd64d
|
||||
content/83: 2aa69f1e049a29229417ba6d2e3b1445
|
||||
content/84: d42080a386e0e4d06f0b898df1506148
|
||||
content/85: 371d0e46b4bd2c23f559b8bc112f6955
|
||||
content/86: 2becb1ef7a9a19a566b799dcd21c1094
|
||||
content/87: bcadfc362b69078beee0088e5936c98b
|
||||
content/88: e0337ea36fc756079a94cf02dc114ec1
|
||||
content/89: 88e29a7727d33d6c643a38d7a82970d8
|
||||
content/90: 5d37bda5eabaf494fc1b634c3ec10604
|
||||
content/91: 371d0e46b4bd2c23f559b8bc112f6955
|
||||
content/92: c9c84b54a4a04fae6663adc5b2829bfd
|
||||
content/93: bcadfc362b69078beee0088e5936c98b
|
||||
content/94: 07b8f3c216f8ae79aa06d76be5eee802
|
||||
content/95: c38cd9052e578c2d7ccbd15b1830a010
|
||||
content/96: fe36912a2b796f3c4e5a2e2691c1b3e2
|
||||
content/97: 371d0e46b4bd2c23f559b8bc112f6955
|
||||
content/98: fc62639a1dcb33c6ef193f30b4bf0b3a
|
||||
content/99: bcadfc362b69078beee0088e5936c98b
|
||||
content/100: e48d2fb4e91c9e8b4358e7ac4f5093db
|
||||
content/101: d3608791907fbda6daf4a17875f8a346
|
||||
content/102: e177a7922b42d5070180066c7792a14f
|
||||
content/103: 371d0e46b4bd2c23f559b8bc112f6955
|
||||
content/104: ef4b2c847b0e6a6a8850b67405307485
|
||||
content/105: bcadfc362b69078beee0088e5936c98b
|
||||
content/106: 0a1e1ca063dbd230eb650d7a48641527
|
||||
content/107: efec5de90c6a5a9a3581c58b29f69bb3
|
||||
content/108: c32368f2b4588ccadf506e1c9ed39d75
|
||||
content/109: 371d0e46b4bd2c23f559b8bc112f6955
|
||||
content/110: 8cabab943e91cdd79d9bfae711f994f9
|
||||
content/111: bcadfc362b69078beee0088e5936c98b
|
||||
content/112: 9937f636bf6fe375c6dd6f9487dfc29b
|
||||
content/113: eaac5385f11112d94f64d82c01d8aa2e
|
||||
content/114: 0b0729e62563093467511ead5748d915
|
||||
content/115: 371d0e46b4bd2c23f559b8bc112f6955
|
||||
content/116: 76ba25e29652de483508549f8b90ab48
|
||||
content/117: bcadfc362b69078beee0088e5936c98b
|
||||
content/118: b4f907e7eed3be38e121c002deafe05b
|
||||
content/119: 864754086908946a6c0d6bd85ad45f28
|
||||
content/120: 5f33febd2cc081df4a95cd69fb78d4c9
|
||||
content/121: 371d0e46b4bd2c23f559b8bc112f6955
|
||||
content/122: 428a41b3a47626eb3839a17ea62d1249
|
||||
content/123: bcadfc362b69078beee0088e5936c98b
|
||||
content/124: 4b395627e7c1e29f76e12c33dff42807
|
||||
content/125: dd774884430bc5bc1ea292e12966a2f6
|
||||
content/126: df64e6a7a29140da7ef6ebc001d3b1fa
|
||||
content/127: 371d0e46b4bd2c23f559b8bc112f6955
|
||||
content/128: c2e58111c9310934719943f7f874e7a2
|
||||
content/129: bcadfc362b69078beee0088e5936c98b
|
||||
content/130: eb99048c37273bd44e33f946a1a990f9
|
||||
content/131: 3398f33c56b20395e10f74d7912bd359
|
||||
content/132: a257be3bbd676f5b92271bb09b6b4005
|
||||
content/133: 371d0e46b4bd2c23f559b8bc112f6955
|
||||
content/134: 0967329ecb6aaffbcf4808689d6c7692
|
||||
content/135: bcadfc362b69078beee0088e5936c98b
|
||||
content/136: 3b9ffef9f524a83ab0e6b92fa7ef5036
|
||||
content/137: 9396f5c2552c1a6029d49ca65fdf3cbc
|
||||
content/138: f308e8c0e6411b58416fd819faed8b17
|
||||
content/139: 371d0e46b4bd2c23f559b8bc112f6955
|
||||
content/140: d7376059810ebcff0f5a197096ed3e89
|
||||
content/141: bcadfc362b69078beee0088e5936c98b
|
||||
content/142: acba021f8dfa66acf74aef7dfa06b384
|
||||
content/143: a28a71db4d9d9e3ac1306331c1498458
|
||||
content/144: 290089fc05ce94f36485132a262c931e
|
||||
content/145: 371d0e46b4bd2c23f559b8bc112f6955
|
||||
content/146: 7607f6ceabb96a1e76be0486e81605a7
|
||||
content/147: bcadfc362b69078beee0088e5936c98b
|
||||
content/148: 011e17b44011848f2681d7c92d2a4193
|
||||
content/149: 62d24bdd0997e5ac5fa2e948dab82b0f
|
||||
content/150: 2cff4b4ece8a12f73f36dc5292625342
|
||||
content/151: 371d0e46b4bd2c23f559b8bc112f6955
|
||||
content/152: a770b4c0251752f171e20fbfaaca6c04
|
||||
content/153: bcadfc362b69078beee0088e5936c98b
|
||||
content/154: 0831b561d4286c23d0994689c97a4a04
|
||||
content/155: f58c506bdc8e1c2484433ee0fcc52e2f
|
||||
content/156: 2b986bfb8651602493e0e1f52b1af961
|
||||
content/157: 371d0e46b4bd2c23f559b8bc112f6955
|
||||
content/158: 1b1ed0ef0ad24a173d701afeaf41385b
|
||||
content/159: bcadfc362b69078beee0088e5936c98b
|
||||
content/160: fb835329fe9cde08d9ad9730b0305ab3
|
||||
content/161: b3f310d5ef115bea5a8b75bf25d7ea9a
|
||||
content/162: 0dc746c73de459c0e96845487cc48997
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@tabler/icons-react": "^3.31.0",
|
||||
"@vercel/analytics": "1.5.0",
|
||||
"@vercel/og": "^0.6.5",
|
||||
"clsx": "^2.1.1",
|
||||
"fumadocs-core": "15.8.2",
|
||||
|
||||
@@ -513,7 +513,7 @@ function SignupFormContent({
|
||||
disabled={isLoading}
|
||||
>
|
||||
<span className='flex items-center gap-1'>
|
||||
{isLoading ? 'Creating account...' : 'Create account'}
|
||||
{isLoading ? 'Creating account' : 'Create account'}
|
||||
<span className='inline-flex transition-transform duration-200 group-hover:translate-x-0.5'>
|
||||
{isButtonHovered ? (
|
||||
<ArrowRight className='h-4 w-4' aria-hidden='true' />
|
||||
|
||||
@@ -215,7 +215,7 @@ export default function Hero() {
|
||||
{ key: 'linear', icon: LinearIcon, label: 'Linear', style: { color: '#5E6AD2' } },
|
||||
{ key: 'discord', icon: DiscordIcon, label: 'Discord', style: { color: '#5765F2' } },
|
||||
{ key: 'airtable', icon: AirtableIcon, label: 'Airtable' },
|
||||
{ key: 'stripe', icon: StripeIcon, label: 'Stripe' },
|
||||
{ key: 'stripe', icon: StripeIcon, label: 'Stripe', style: { color: '#635BFF' } },
|
||||
{ key: 'notion', icon: NotionIcon, label: 'Notion' },
|
||||
{ key: 'googleSheets', icon: GoogleSheetsIcon, label: 'Google Sheets' },
|
||||
{ key: 'googleDrive', icon: GoogleDriveIcon, label: 'Google Drive' },
|
||||
|
||||
@@ -60,7 +60,12 @@ export async function GET(request: NextRequest) {
|
||||
let conditions: SQL | undefined = eq(workflow.workspaceId, params.workspaceId)
|
||||
|
||||
if (params.level && params.level !== 'all') {
|
||||
conditions = and(conditions, eq(workflowExecutionLogs.level, params.level))
|
||||
const levels = params.level.split(',').filter(Boolean)
|
||||
if (levels.length === 1) {
|
||||
conditions = and(conditions, eq(workflowExecutionLogs.level, levels[0]))
|
||||
} else if (levels.length > 1) {
|
||||
conditions = and(conditions, inArray(workflowExecutionLogs.level, levels))
|
||||
}
|
||||
}
|
||||
|
||||
if (params.workflowIds) {
|
||||
|
||||
@@ -126,9 +126,14 @@ export async function GET(request: NextRequest) {
|
||||
// Build additional conditions for the query
|
||||
let conditions: SQL | undefined
|
||||
|
||||
// Filter by level
|
||||
// Filter by level (supports comma-separated for OR conditions)
|
||||
if (params.level && params.level !== 'all') {
|
||||
conditions = and(conditions, eq(workflowExecutionLogs.level, params.level))
|
||||
const levels = params.level.split(',').filter(Boolean)
|
||||
if (levels.length === 1) {
|
||||
conditions = and(conditions, eq(workflowExecutionLogs.level, levels[0]))
|
||||
} else if (levels.length > 1) {
|
||||
conditions = and(conditions, inArray(workflowExecutionLogs.level, levels))
|
||||
}
|
||||
}
|
||||
|
||||
// Filter by specific workflow IDs
|
||||
|
||||
@@ -1,12 +1,49 @@
|
||||
import { db } from '@sim/db'
|
||||
import { member } from '@sim/db/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { member, organization } from '@sim/db/schema'
|
||||
import { and, eq, or } from 'drizzle-orm'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { createOrganizationForTeamPlan } from '@/lib/billing/organization'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('CreateTeamOrganization')
|
||||
const logger = createLogger('OrganizationsAPI')
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Get organizations where user is owner or admin
|
||||
const userOrganizations = await db
|
||||
.select({
|
||||
id: organization.id,
|
||||
name: organization.name,
|
||||
role: member.role,
|
||||
})
|
||||
.from(member)
|
||||
.innerJoin(organization, eq(member.organizationId, organization.id))
|
||||
.where(
|
||||
and(
|
||||
eq(member.userId, session.user.id),
|
||||
or(eq(member.role, 'owner'), eq(member.role, 'admin'))
|
||||
)
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
organizations: userOrganizations,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch organizations', {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
})
|
||||
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
|
||||
@@ -70,6 +70,7 @@ describe('Custom Tools API Routes', () => {
|
||||
const mockSelect = vi.fn()
|
||||
const mockFrom = vi.fn()
|
||||
const mockWhere = vi.fn()
|
||||
const mockOrderBy = vi.fn()
|
||||
const mockInsert = vi.fn()
|
||||
const mockValues = vi.fn()
|
||||
const mockUpdate = vi.fn()
|
||||
@@ -84,10 +85,23 @@ describe('Custom Tools API Routes', () => {
|
||||
// Reset all mock implementations
|
||||
mockSelect.mockReturnValue({ from: mockFrom })
|
||||
mockFrom.mockReturnValue({ where: mockWhere })
|
||||
// where() can be called with limit() or directly awaited
|
||||
// Create a mock query builder that supports both patterns
|
||||
// where() can be called with orderBy(), limit(), or directly awaited
|
||||
// Create a mock query builder that supports all patterns
|
||||
mockWhere.mockImplementation((condition) => {
|
||||
// Return an object that is both awaitable and has a limit() method
|
||||
// Return an object that is both awaitable and has orderBy() and limit() methods
|
||||
const queryBuilder = {
|
||||
orderBy: mockOrderBy,
|
||||
limit: mockLimit,
|
||||
then: (resolve: (value: typeof sampleTools) => void) => {
|
||||
resolve(sampleTools)
|
||||
return queryBuilder
|
||||
},
|
||||
catch: (reject: (error: Error) => void) => queryBuilder,
|
||||
}
|
||||
return queryBuilder
|
||||
})
|
||||
mockOrderBy.mockImplementation(() => {
|
||||
// orderBy returns an awaitable query builder
|
||||
const queryBuilder = {
|
||||
limit: mockLimit,
|
||||
then: (resolve: (value: typeof sampleTools) => void) => {
|
||||
@@ -120,9 +134,22 @@ describe('Custom Tools API Routes', () => {
|
||||
const txMockUpdate = vi.fn().mockReturnValue({ set: mockSet })
|
||||
const txMockDelete = vi.fn().mockReturnValue({ where: mockWhere })
|
||||
|
||||
// Transaction where() should also support the query builder pattern
|
||||
// Transaction where() should also support the query builder pattern with orderBy
|
||||
const txMockOrderBy = vi.fn().mockImplementation(() => {
|
||||
const queryBuilder = {
|
||||
limit: mockLimit,
|
||||
then: (resolve: (value: typeof sampleTools) => void) => {
|
||||
resolve(sampleTools)
|
||||
return queryBuilder
|
||||
},
|
||||
catch: (reject: (error: Error) => void) => queryBuilder,
|
||||
}
|
||||
return queryBuilder
|
||||
})
|
||||
|
||||
const txMockWhere = vi.fn().mockImplementation((condition) => {
|
||||
const queryBuilder = {
|
||||
orderBy: txMockOrderBy,
|
||||
limit: mockLimit,
|
||||
then: (resolve: (value: typeof sampleTools) => void) => {
|
||||
resolve(sampleTools)
|
||||
@@ -201,6 +228,7 @@ describe('Custom Tools API Routes', () => {
|
||||
or: vi.fn().mockImplementation((...conditions) => ({ operator: 'or', conditions })),
|
||||
isNull: vi.fn().mockImplementation((field) => ({ field, operator: 'isNull' })),
|
||||
ne: vi.fn().mockImplementation((field, value) => ({ field, value, operator: 'ne' })),
|
||||
desc: vi.fn().mockImplementation((field) => ({ field, operator: 'desc' })),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -208,6 +236,11 @@ describe('Custom Tools API Routes', () => {
|
||||
vi.doMock('@/lib/utils', () => ({
|
||||
generateRequestId: vi.fn().mockReturnValue('test-request-id'),
|
||||
}))
|
||||
|
||||
// Mock custom tools operations
|
||||
vi.doMock('@/lib/custom-tools/operations', () => ({
|
||||
upsertCustomTools: vi.fn().mockResolvedValue(sampleTools),
|
||||
}))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -224,8 +257,10 @@ describe('Custom Tools API Routes', () => {
|
||||
'http://localhost:3000/api/tools/custom?workspaceId=workspace-123'
|
||||
)
|
||||
|
||||
// Simulate DB returning tools
|
||||
mockWhere.mockReturnValueOnce(Promise.resolve(sampleTools))
|
||||
// Simulate DB returning tools with orderBy chain
|
||||
mockWhere.mockReturnValueOnce({
|
||||
orderBy: mockOrderBy.mockReturnValueOnce(Promise.resolve(sampleTools)),
|
||||
})
|
||||
|
||||
// Import handler after mocks are set up
|
||||
const { GET } = await import('@/app/api/tools/custom/route')
|
||||
@@ -243,6 +278,7 @@ describe('Custom Tools API Routes', () => {
|
||||
expect(mockSelect).toHaveBeenCalled()
|
||||
expect(mockFrom).toHaveBeenCalled()
|
||||
expect(mockWhere).toHaveBeenCalled()
|
||||
expect(mockOrderBy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle unauthorized access', async () => {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { db } from '@sim/db'
|
||||
import { customTools, workflow } from '@sim/db/schema'
|
||||
import { and, eq, isNull, ne, or } from 'drizzle-orm'
|
||||
import { and, desc, eq, isNull, or } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { upsertCustomTools } from '@/lib/custom-tools/operations'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getUserEntityPermissions } from '@/lib/permissions/utils'
|
||||
import { generateRequestId } from '@/lib/utils'
|
||||
@@ -101,6 +102,7 @@ export async function GET(request: NextRequest) {
|
||||
.select()
|
||||
.from(customTools)
|
||||
.where(or(...conditions))
|
||||
.orderBy(desc(customTools.createdAt))
|
||||
|
||||
return NextResponse.json({ data: result }, { status: 200 })
|
||||
} catch (error) {
|
||||
@@ -150,96 +152,15 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({ error: 'Write permission required' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Use a transaction for multi-step database operations
|
||||
return await db.transaction(async (tx) => {
|
||||
// Process each tool: either update existing or create new
|
||||
for (const tool of tools) {
|
||||
const nowTime = new Date()
|
||||
|
||||
if (tool.id) {
|
||||
// Check if tool exists and belongs to the workspace
|
||||
const existingTool = await tx
|
||||
.select()
|
||||
.from(customTools)
|
||||
.where(and(eq(customTools.id, tool.id), eq(customTools.workspaceId, workspaceId)))
|
||||
.limit(1)
|
||||
|
||||
if (existingTool.length > 0) {
|
||||
// Tool exists - check if name changed and if new name conflicts
|
||||
if (existingTool[0].title !== tool.title) {
|
||||
// Check for duplicate name in workspace (excluding current tool)
|
||||
const duplicateTool = await tx
|
||||
.select()
|
||||
.from(customTools)
|
||||
.where(
|
||||
and(
|
||||
eq(customTools.workspaceId, workspaceId),
|
||||
eq(customTools.title, tool.title),
|
||||
ne(customTools.id, tool.id)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (duplicateTool.length > 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `A tool with the name "${tool.title}" already exists in this workspace`,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Update existing tool
|
||||
await tx
|
||||
.update(customTools)
|
||||
.set({
|
||||
title: tool.title,
|
||||
schema: tool.schema,
|
||||
code: tool.code,
|
||||
updatedAt: nowTime,
|
||||
})
|
||||
.where(and(eq(customTools.id, tool.id), eq(customTools.workspaceId, workspaceId)))
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Creating new tool - check for duplicate names in workspace
|
||||
const duplicateTool = await tx
|
||||
.select()
|
||||
.from(customTools)
|
||||
.where(and(eq(customTools.workspaceId, workspaceId), eq(customTools.title, tool.title)))
|
||||
.limit(1)
|
||||
|
||||
if (duplicateTool.length > 0) {
|
||||
return NextResponse.json(
|
||||
{ error: `A tool with the name "${tool.title}" already exists in this workspace` },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Create new tool
|
||||
const newToolId = tool.id || crypto.randomUUID()
|
||||
await tx.insert(customTools).values({
|
||||
id: newToolId,
|
||||
workspaceId,
|
||||
userId,
|
||||
title: tool.title,
|
||||
schema: tool.schema,
|
||||
code: tool.code,
|
||||
createdAt: nowTime,
|
||||
updatedAt: nowTime,
|
||||
})
|
||||
}
|
||||
|
||||
// Fetch and return the created/updated tools
|
||||
const resultTools = await tx
|
||||
.select()
|
||||
.from(customTools)
|
||||
.where(eq(customTools.workspaceId, workspaceId))
|
||||
|
||||
return NextResponse.json({ success: true, data: resultTools })
|
||||
// Use the extracted upsert function
|
||||
const resultTools = await upsertCustomTools({
|
||||
tools,
|
||||
workspaceId,
|
||||
userId,
|
||||
requestId,
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true, data: resultTools })
|
||||
} catch (validationError) {
|
||||
if (validationError instanceof z.ZodError) {
|
||||
logger.warn(`[${requestId}] Invalid custom tools data`, {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { processSingleFileToUserFile } from '@/lib/uploads/utils/file-utils'
|
||||
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
|
||||
import { generateRequestId } from '@/lib/utils'
|
||||
import { normalizeExcelValues } from '@/tools/onedrive/utils'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -13,6 +14,14 @@ const logger = createLogger('OneDriveUploadAPI')
|
||||
|
||||
const MICROSOFT_GRAPH_BASE = 'https://graph.microsoft.com/v1.0'
|
||||
|
||||
const ExcelCellSchema = z.union([z.string(), z.number(), z.boolean(), z.null()])
|
||||
const ExcelRowSchema = z.array(ExcelCellSchema)
|
||||
const ExcelValuesSchema = z.union([
|
||||
z.string(),
|
||||
z.array(ExcelRowSchema),
|
||||
z.array(z.record(ExcelCellSchema)),
|
||||
])
|
||||
|
||||
const OneDriveUploadSchema = z.object({
|
||||
accessToken: z.string().min(1, 'Access token is required'),
|
||||
fileName: z.string().min(1, 'File name is required'),
|
||||
@@ -20,7 +29,7 @@ const OneDriveUploadSchema = z.object({
|
||||
folderId: z.string().optional().nullable(),
|
||||
mimeType: z.string().optional(),
|
||||
// Optional Excel write-after-create inputs
|
||||
values: z.array(z.array(z.union([z.string(), z.number(), z.boolean(), z.null()]))).optional(),
|
||||
values: ExcelValuesSchema.optional(),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
@@ -46,6 +55,7 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
const body = await request.json()
|
||||
const validatedData = OneDriveUploadSchema.parse(body)
|
||||
const excelValues = normalizeExcelValues(validatedData.values)
|
||||
|
||||
let fileBuffer: Buffer
|
||||
let mimeType: string
|
||||
@@ -180,7 +190,7 @@ export async function POST(request: NextRequest) {
|
||||
// If this is an Excel creation and values were provided, write them using the Excel API
|
||||
let excelWriteResult: any | undefined
|
||||
const shouldWriteExcelContent =
|
||||
isExcelCreation && Array.isArray(validatedData.values) && validatedData.values.length > 0
|
||||
isExcelCreation && Array.isArray(excelValues) && excelValues.length > 0
|
||||
|
||||
if (shouldWriteExcelContent) {
|
||||
try {
|
||||
@@ -232,7 +242,7 @@ export async function POST(request: NextRequest) {
|
||||
logger.warn(`[${requestId}] Error listing worksheets, using default Sheet1`, listError)
|
||||
}
|
||||
|
||||
let processedValues: any = validatedData.values || []
|
||||
let processedValues: any = excelValues || []
|
||||
|
||||
if (
|
||||
Array.isArray(processedValues) &&
|
||||
|
||||
@@ -78,9 +78,16 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Message sent successfully`)
|
||||
const messageObj = data.message || {
|
||||
type: 'message',
|
||||
ts: data.ts,
|
||||
text: validatedData.text,
|
||||
channel: data.channel,
|
||||
}
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: {
|
||||
message: messageObj,
|
||||
ts: data.ts,
|
||||
channel: data.channel,
|
||||
},
|
||||
@@ -107,9 +114,16 @@ export async function POST(request: NextRequest) {
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
const messageObj = data.message || {
|
||||
type: 'message',
|
||||
ts: data.ts,
|
||||
text: validatedData.text,
|
||||
channel: data.channel,
|
||||
}
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: {
|
||||
message: messageObj,
|
||||
ts: data.ts,
|
||||
channel: data.channel,
|
||||
},
|
||||
@@ -174,9 +188,16 @@ export async function POST(request: NextRequest) {
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
const messageObj = data.message || {
|
||||
type: 'message',
|
||||
ts: data.ts,
|
||||
text: validatedData.text,
|
||||
channel: data.channel,
|
||||
}
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: {
|
||||
message: messageObj,
|
||||
ts: data.ts,
|
||||
channel: data.channel,
|
||||
},
|
||||
@@ -211,10 +232,28 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
logger.info(`[${requestId}] Files uploaded and shared successfully`)
|
||||
|
||||
// For file uploads, construct a message object
|
||||
const fileTs = completeData.files?.[0]?.created?.toString() || (Date.now() / 1000).toString()
|
||||
const fileMessage = {
|
||||
type: 'message',
|
||||
ts: fileTs,
|
||||
text: validatedData.text,
|
||||
channel: validatedData.channel,
|
||||
files: completeData.files?.map((file: any) => ({
|
||||
id: file?.id,
|
||||
name: file?.name,
|
||||
mimetype: file?.mimetype,
|
||||
size: file?.size,
|
||||
url_private: file?.url_private,
|
||||
permalink: file?.permalink,
|
||||
})),
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: {
|
||||
ts: completeData.files?.[0]?.created || Date.now() / 1000,
|
||||
message: fileMessage,
|
||||
ts: fileTs,
|
||||
channel: validatedData.channel,
|
||||
fileCount: uploadedFileIds.length,
|
||||
},
|
||||
|
||||
@@ -78,14 +78,22 @@ export async function POST(request: NextRequest) {
|
||||
timestamp: data.ts,
|
||||
})
|
||||
|
||||
const messageObj = data.message || {
|
||||
type: 'message',
|
||||
ts: data.ts,
|
||||
text: data.text || validatedData.text,
|
||||
channel: data.channel,
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: {
|
||||
message: messageObj,
|
||||
content: 'Message updated successfully',
|
||||
metadata: {
|
||||
channel: data.channel,
|
||||
timestamp: data.ts,
|
||||
text: data.text,
|
||||
text: data.text || validatedData.text,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -10,6 +10,8 @@ import { extractAndPersistCustomTools } from '@/lib/workflows/custom-tools-persi
|
||||
import { saveWorkflowToNormalizedTables } from '@/lib/workflows/db-helpers'
|
||||
import { getWorkflowAccessContext } from '@/lib/workflows/utils'
|
||||
import { sanitizeAgentToolsInBlocks } from '@/lib/workflows/validation'
|
||||
import type { BlockState } from '@/stores/workflows/workflow/types'
|
||||
import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils'
|
||||
|
||||
const logger = createLogger('WorkflowStateAPI')
|
||||
|
||||
@@ -175,11 +177,15 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
|
||||
{} as typeof state.blocks
|
||||
)
|
||||
|
||||
const typedBlocks = filteredBlocks as Record<string, BlockState>
|
||||
const canonicalLoops = generateLoopBlocks(typedBlocks)
|
||||
const canonicalParallels = generateParallelBlocks(typedBlocks)
|
||||
|
||||
const workflowState = {
|
||||
blocks: filteredBlocks,
|
||||
edges: state.edges,
|
||||
loops: state.loops || {},
|
||||
parallels: state.parallels || {},
|
||||
loops: canonicalLoops,
|
||||
parallels: canonicalParallels,
|
||||
lastSaved: state.lastSaved || Date.now(),
|
||||
isDeployed: state.isDeployed || false,
|
||||
deployedAt: state.deployedAt,
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
--panel-width: 244px;
|
||||
--toolbar-triggers-height: 300px;
|
||||
--editor-connections-height: 200px;
|
||||
--terminal-height: 145px;
|
||||
--terminal-height: 196px;
|
||||
}
|
||||
|
||||
.sidebar-container {
|
||||
@@ -74,6 +74,30 @@
|
||||
animation: dash-animation 1.5s linear infinite !important;
|
||||
}
|
||||
|
||||
/**
|
||||
* Active block ring animation - cycles through gray tones using box-shadow
|
||||
*/
|
||||
@keyframes ring-pulse-colors {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 0 4px var(--surface-14);
|
||||
}
|
||||
33% {
|
||||
box-shadow: 0 0 0 4px var(--surface-12);
|
||||
}
|
||||
66% {
|
||||
box-shadow: 0 0 0 4px var(--surface-15);
|
||||
}
|
||||
}
|
||||
|
||||
.dark .animate-ring-pulse {
|
||||
animation: ring-pulse-colors 2s ease-in-out infinite !important;
|
||||
}
|
||||
|
||||
.light .animate-ring-pulse {
|
||||
animation: ring-pulse-colors 2s ease-in-out infinite !important;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dark color tokens - single source of truth for all colors (dark-only)
|
||||
*/
|
||||
|
||||
@@ -6,9 +6,11 @@ import { generateBrandedMetadata, generateStructuredData } from '@/lib/branding/
|
||||
import { PostHogProvider } from '@/lib/posthog/provider'
|
||||
import '@/app/globals.css'
|
||||
|
||||
import { OneDollarStats } from '@/components/analytics/onedollarstats'
|
||||
import { SessionProvider } from '@/lib/session/session-context'
|
||||
import { season } from '@/app/fonts/season/season'
|
||||
import { HydrationErrorHandler } from '@/app/hydration-error-handler'
|
||||
import { QueryProvider } from '@/app/providers/query-client-provider'
|
||||
import { ThemeProvider } from '@/app/theme-provider'
|
||||
import { ZoomPrevention } from '@/app/zoom-prevention'
|
||||
|
||||
@@ -55,6 +57,9 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
<meta name='format-detection' content='telephone=no' />
|
||||
<meta httpEquiv='x-ua-compatible' content='ie=edge' />
|
||||
|
||||
{/* OneDollarStats Analytics */}
|
||||
<script defer src='https://assets.onedollarstats.com/stonks.js' />
|
||||
|
||||
{/* Blocking script to prevent sidebar dimensions flash on page load */}
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
@@ -166,14 +171,17 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
</head>
|
||||
<body className={`${season.variable} font-season`} suppressHydrationWarning>
|
||||
<HydrationErrorHandler />
|
||||
<OneDollarStats />
|
||||
<PostHogProvider>
|
||||
<ThemeProvider>
|
||||
<SessionProvider>
|
||||
<BrandedLayout>
|
||||
<ZoomPrevention />
|
||||
{children}
|
||||
</BrandedLayout>
|
||||
</SessionProvider>
|
||||
<QueryProvider>
|
||||
<SessionProvider>
|
||||
<BrandedLayout>
|
||||
<ZoomPrevention />
|
||||
{children}
|
||||
</BrandedLayout>
|
||||
</SessionProvider>
|
||||
</QueryProvider>
|
||||
</ThemeProvider>
|
||||
</PostHogProvider>
|
||||
</body>
|
||||
|
||||
26
apps/sim/app/providers/query-client-provider.tsx
Normal file
26
apps/sim/app/providers/query-client-provider.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
'use client'
|
||||
|
||||
import { type ReactNode, useState } from 'react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
|
||||
export function QueryProvider({ children }: { children: ReactNode }) {
|
||||
const [queryClient] = useState(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 30 * 1000,
|
||||
gcTime: 5 * 60 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: 1,
|
||||
retryOnMount: false,
|
||||
},
|
||||
mutations: {
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
}
|
||||
44
apps/sim/app/templates/[id]/layout.tsx
Normal file
44
apps/sim/app/templates/[id]/layout.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { db } from '@sim/db'
|
||||
import { permissions, workspace } from '@sim/db/schema'
|
||||
import { and, desc, eq } from 'drizzle-orm'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { getSession } from '@/lib/auth'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const revalidate = 0
|
||||
|
||||
interface TemplateLayoutProps {
|
||||
children: React.ReactNode
|
||||
params: Promise<{
|
||||
id: string
|
||||
}>
|
||||
}
|
||||
|
||||
/**
|
||||
* Template detail layout (public scope).
|
||||
* - If user is authenticated, redirect to workspace-scoped template detail.
|
||||
* - Otherwise render the public template detail children.
|
||||
*/
|
||||
export default async function TemplateDetailLayout({ children, params }: TemplateLayoutProps) {
|
||||
const { id } = await params
|
||||
const session = await getSession()
|
||||
|
||||
if (session?.user?.id) {
|
||||
const userWorkspaces = await db
|
||||
.select({
|
||||
workspace: workspace,
|
||||
})
|
||||
.from(permissions)
|
||||
.innerJoin(workspace, eq(permissions.entityId, workspace.id))
|
||||
.where(and(eq(permissions.userId, session.user.id), eq(permissions.entityType, 'workspace')))
|
||||
.orderBy(desc(workspace.createdAt))
|
||||
.limit(1)
|
||||
|
||||
if (userWorkspaces.length > 0) {
|
||||
const firstWorkspace = userWorkspaces[0].workspace
|
||||
redirect(`/workspace/${firstWorkspace.id}/templates/${id}`)
|
||||
}
|
||||
}
|
||||
|
||||
return children
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
import TemplateDetails from './template'
|
||||
|
||||
/**
|
||||
* Public template detail page for unauthenticated users.
|
||||
* Authenticated-user redirect is handled in templates/[id]/layout.tsx.
|
||||
*/
|
||||
export default function TemplatePage() {
|
||||
return <TemplateDetails />
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,33 +0,0 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface NavigationTab {
|
||||
id: string
|
||||
label: string
|
||||
count?: number
|
||||
}
|
||||
|
||||
interface NavigationTabsProps {
|
||||
tabs: NavigationTab[]
|
||||
activeTab?: string
|
||||
onTabClick?: (tabId: string) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function NavigationTabs({ tabs, activeTab, onTabClick, className }: NavigationTabsProps) {
|
||||
return (
|
||||
<div className={cn('flex items-center gap-2', className)}>
|
||||
{tabs.map((tab, index) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => onTabClick?.(tab.id)}
|
||||
className={cn(
|
||||
'flex h-[38px] items-center gap-1 rounded-[14px] px-3 font-[440] font-sans text-muted-foreground text-sm transition-all duration-200',
|
||||
activeTab === tab.id ? 'bg-secondary' : 'bg-transparent hover:bg-secondary/50'
|
||||
)}
|
||||
>
|
||||
<span>{tab.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,189 +1,72 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Award,
|
||||
BarChart3,
|
||||
Bell,
|
||||
BookOpen,
|
||||
Bot,
|
||||
Brain,
|
||||
Briefcase,
|
||||
Calculator,
|
||||
Cloud,
|
||||
Code,
|
||||
Cpu,
|
||||
CreditCard,
|
||||
Database,
|
||||
DollarSign,
|
||||
Edit,
|
||||
FileText,
|
||||
Folder,
|
||||
Globe,
|
||||
HeadphonesIcon,
|
||||
Layers,
|
||||
Lightbulb,
|
||||
LineChart,
|
||||
Mail,
|
||||
Megaphone,
|
||||
MessageSquare,
|
||||
NotebookPen,
|
||||
Phone,
|
||||
Play,
|
||||
Search,
|
||||
Server,
|
||||
Settings,
|
||||
ShoppingCart,
|
||||
Star,
|
||||
Target,
|
||||
TrendingUp,
|
||||
User,
|
||||
Users,
|
||||
Workflow,
|
||||
Wrench,
|
||||
Zap,
|
||||
} from 'lucide-react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Star, User } from 'lucide-react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/workflow-preview/workflow-preview'
|
||||
import { getBlock } from '@/blocks/registry'
|
||||
import type { WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
const logger = createLogger('TemplateCard')
|
||||
|
||||
// Icon mapping for template icons
|
||||
const iconMap = {
|
||||
// Content & Documentation
|
||||
FileText,
|
||||
NotebookPen,
|
||||
BookOpen,
|
||||
Edit,
|
||||
|
||||
// Analytics & Charts
|
||||
BarChart3,
|
||||
LineChart,
|
||||
TrendingUp,
|
||||
Target,
|
||||
|
||||
// Database & Storage
|
||||
Database,
|
||||
Server,
|
||||
Cloud,
|
||||
Folder,
|
||||
|
||||
// Marketing & Communication
|
||||
Megaphone,
|
||||
Mail,
|
||||
MessageSquare,
|
||||
Phone,
|
||||
Bell,
|
||||
|
||||
// Sales & Finance
|
||||
DollarSign,
|
||||
CreditCard,
|
||||
Calculator,
|
||||
ShoppingCart,
|
||||
Briefcase,
|
||||
|
||||
// Support & Service
|
||||
HeadphonesIcon,
|
||||
User,
|
||||
Users,
|
||||
Settings,
|
||||
Wrench,
|
||||
|
||||
// AI & Technology
|
||||
Bot,
|
||||
Brain,
|
||||
Cpu,
|
||||
Code,
|
||||
Zap,
|
||||
|
||||
// Workflow & Process
|
||||
Workflow,
|
||||
Search,
|
||||
Play,
|
||||
Layers,
|
||||
|
||||
// General
|
||||
Lightbulb,
|
||||
Star,
|
||||
Globe,
|
||||
Award,
|
||||
}
|
||||
|
||||
interface TemplateCardProps {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
author: string
|
||||
authorImageUrl?: string | null
|
||||
usageCount: string
|
||||
stars?: number
|
||||
icon?: React.ReactNode | string
|
||||
iconColor?: string
|
||||
blocks?: string[]
|
||||
tags?: string[]
|
||||
onClick?: () => void
|
||||
className?: string
|
||||
state?: {
|
||||
blocks?: Record<string, { type: string; name?: string }>
|
||||
}
|
||||
// Workflow state for rendering preview
|
||||
state?: WorkflowState
|
||||
isStarred?: boolean
|
||||
// Optional callback when template is successfully used (for closing modals, etc.)
|
||||
onTemplateUsed?: () => void
|
||||
// Callback when star state changes (for parent state updates)
|
||||
onStarChange?: (templateId: string, isStarred: boolean, newStarCount: number) => void
|
||||
// User authentication status
|
||||
isAuthenticated?: boolean
|
||||
}
|
||||
|
||||
// Skeleton component for loading states
|
||||
/**
|
||||
* Skeleton component for loading states
|
||||
*/
|
||||
export function TemplateCardSkeleton({ className }: { className?: string }) {
|
||||
return (
|
||||
<div className={cn('rounded-[8px] border bg-card shadow-xs', 'flex h-[142px]', className)}>
|
||||
{/* Left side - Info skeleton */}
|
||||
<div className='flex min-w-0 flex-1 flex-col justify-between p-4'>
|
||||
{/* Top section skeleton */}
|
||||
<div className='space-y-2'>
|
||||
<div className='flex min-w-0 items-center justify-between gap-2.5'>
|
||||
<div className='flex min-w-0 items-center gap-2.5'>
|
||||
{/* Icon skeleton */}
|
||||
<div className='h-5 w-5 flex-shrink-0 animate-pulse rounded-md bg-gray-200' />
|
||||
{/* Title skeleton */}
|
||||
<div className='h-4 w-32 animate-pulse rounded bg-gray-200' />
|
||||
</div>
|
||||
<div className={cn('h-[268px] w-full rounded-[8px] bg-[#202020] p-[8px]', className)}>
|
||||
{/* Workflow preview skeleton */}
|
||||
<div className='h-[180px] w-full animate-pulse rounded-[6px] bg-gray-700' />
|
||||
|
||||
{/* Star and Use button skeleton */}
|
||||
<div className='flex flex-shrink-0 items-center gap-3'>
|
||||
<div className='h-4 w-4 animate-pulse rounded bg-gray-200' />
|
||||
<div className='h-6 w-10 animate-pulse rounded-md bg-gray-200' />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description skeleton */}
|
||||
<div className='space-y-1.5'>
|
||||
<div className='h-3 w-full animate-pulse rounded bg-gray-200' />
|
||||
<div className='h-3 w-4/5 animate-pulse rounded bg-gray-200' />
|
||||
<div className='h-3 w-3/5 animate-pulse rounded bg-gray-200' />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom section skeleton */}
|
||||
<div className='flex min-w-0 items-center gap-1.5 pt-1.5'>
|
||||
<div className='h-3 w-6 animate-pulse rounded bg-gray-200' />
|
||||
<div className='h-3 w-16 animate-pulse rounded bg-gray-200' />
|
||||
<div className='h-2 w-1 animate-pulse rounded bg-gray-200' />
|
||||
<div className='h-3 w-3 animate-pulse rounded bg-gray-200' />
|
||||
<div className='h-3 w-8 animate-pulse rounded bg-gray-200' />
|
||||
{/* Stars section - hidden on smaller screens */}
|
||||
<div className='hidden flex-shrink-0 items-center gap-1.5 sm:flex'>
|
||||
<div className='h-2 w-1 animate-pulse rounded bg-gray-200' />
|
||||
<div className='h-3 w-3 animate-pulse rounded bg-gray-200' />
|
||||
<div className='h-3 w-6 animate-pulse rounded bg-gray-200' />
|
||||
</div>
|
||||
{/* Title and blocks row skeleton */}
|
||||
<div className='mt-[14px] flex items-center justify-between'>
|
||||
<div className='h-4 w-32 animate-pulse rounded bg-gray-700' />
|
||||
<div className='flex items-center gap-[-4px]'>
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className='h-[18px] w-[18px] animate-pulse rounded-[4px] bg-gray-700'
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right side - Block Icons skeleton */}
|
||||
<div className='flex w-16 flex-col items-center justify-center gap-2 rounded-r-[8px] border-border border-l bg-secondary p-2'>
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className='animate-pulse rounded bg-gray-200'
|
||||
style={{ width: '30px', height: '30px' }}
|
||||
/>
|
||||
))}
|
||||
{/* Creator and stats row skeleton */}
|
||||
<div className='mt-[14px] flex items-center justify-between'>
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
<div className='h-[14px] w-[14px] animate-pulse rounded-full bg-gray-700' />
|
||||
<div className='h-3 w-20 animate-pulse rounded bg-gray-700' />
|
||||
</div>
|
||||
<div className='flex items-center gap-[6px]'>
|
||||
<div className='h-3 w-3 animate-pulse rounded bg-gray-700' />
|
||||
<div className='h-3 w-6 animate-pulse rounded bg-gray-700' />
|
||||
<div className='h-3 w-3 animate-pulse rounded bg-gray-700' />
|
||||
<div className='h-3 w-6 animate-pulse rounded bg-gray-700' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -204,39 +87,117 @@ const extractBlockTypesFromState = (state?: {
|
||||
return [...new Set(blockTypes)]
|
||||
}
|
||||
|
||||
// Utility function to get block display name
|
||||
// Utility function to get the full block config for colored icon display
|
||||
const getBlockConfig = (blockType: string) => {
|
||||
const block = getBlock(blockType)
|
||||
return block
|
||||
}
|
||||
|
||||
export function TemplateCard({
|
||||
/**
|
||||
* Normalize an arbitrary workflow-like object into a valid WorkflowState for preview rendering.
|
||||
* Ensures required fields exist: blocks with required properties, edges array, loops and parallels maps.
|
||||
*/
|
||||
function normalizeWorkflowState(input?: any): WorkflowState | null {
|
||||
if (!input || !input.blocks) return null
|
||||
|
||||
const normalizedBlocks: WorkflowState['blocks'] = {}
|
||||
for (const [id, raw] of Object.entries<any>(input.blocks || {})) {
|
||||
if (!raw || !raw.type) continue
|
||||
normalizedBlocks[id] = {
|
||||
id: raw.id ?? id,
|
||||
type: raw.type,
|
||||
name: raw.name ?? raw.type,
|
||||
position: raw.position ?? { x: 0, y: 0 },
|
||||
subBlocks: raw.subBlocks ?? {},
|
||||
outputs: raw.outputs ?? {},
|
||||
enabled: typeof raw.enabled === 'boolean' ? raw.enabled : true,
|
||||
horizontalHandles: raw.horizontalHandles,
|
||||
height: raw.height,
|
||||
advancedMode: raw.advancedMode,
|
||||
triggerMode: raw.triggerMode,
|
||||
data: raw.data ?? {},
|
||||
layout: raw.layout,
|
||||
}
|
||||
}
|
||||
|
||||
const normalized: WorkflowState = {
|
||||
blocks: normalizedBlocks,
|
||||
edges: Array.isArray(input.edges) ? input.edges : [],
|
||||
loops: input.loops ?? {},
|
||||
parallels: input.parallels ?? {},
|
||||
lastSaved: input.lastSaved,
|
||||
lastUpdate: input.lastUpdate,
|
||||
metadata: input.metadata,
|
||||
variables: input.variables,
|
||||
isDeployed: input.isDeployed,
|
||||
deployedAt: input.deployedAt,
|
||||
deploymentStatuses: input.deploymentStatuses,
|
||||
needsRedeployment: input.needsRedeployment,
|
||||
dragStartPosition: input.dragStartPosition ?? null,
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
function TemplateCardInner({
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
author,
|
||||
authorImageUrl,
|
||||
usageCount,
|
||||
stars = 0,
|
||||
icon,
|
||||
iconColor = 'bg-blue-500',
|
||||
blocks = [],
|
||||
tags = [],
|
||||
onClick,
|
||||
className,
|
||||
state,
|
||||
isStarred = false,
|
||||
onTemplateUsed,
|
||||
onStarChange,
|
||||
isAuthenticated = true,
|
||||
}: TemplateCardProps) {
|
||||
const router = useRouter()
|
||||
const params = useParams()
|
||||
|
||||
// Local state for optimistic updates
|
||||
const [localIsStarred, setLocalIsStarred] = useState(isStarred)
|
||||
const [localStarCount, setLocalStarCount] = useState(stars)
|
||||
const [isStarLoading, setIsStarLoading] = useState(false)
|
||||
|
||||
// Memoize normalized workflow state to avoid recalculation on every render
|
||||
const normalizedState = useMemo(() => normalizeWorkflowState(state), [state])
|
||||
|
||||
// Use IntersectionObserver to defer rendering the heavy WorkflowPreview until in viewport
|
||||
const previewRef = useRef<HTMLDivElement | null>(null)
|
||||
const [isInView, setIsInView] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!previewRef.current) return
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
setIsInView(true)
|
||||
observer.disconnect()
|
||||
}
|
||||
},
|
||||
{ root: null, rootMargin: '200px', threshold: 0 }
|
||||
)
|
||||
observer.observe(previewRef.current)
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
// Extract block types from state if provided, otherwise use the blocks prop
|
||||
// Filter out starter blocks in both cases and sort for consistent rendering
|
||||
const blockTypes = state
|
||||
? extractBlockTypesFromState(state)
|
||||
: blocks.filter((blockType) => blockType !== 'starter').sort()
|
||||
// Memoized to prevent recalculation on every render
|
||||
const blockTypes = useMemo(
|
||||
() =>
|
||||
state
|
||||
? extractBlockTypesFromState(state)
|
||||
: blocks.filter((blockType) => blockType !== 'starter').sort(),
|
||||
[state, blocks]
|
||||
)
|
||||
|
||||
// Handle star toggle with optimistic updates
|
||||
const handleStarClick = async (e: React.MouseEvent) => {
|
||||
@@ -291,173 +252,163 @@ export function TemplateCard({
|
||||
}
|
||||
}
|
||||
|
||||
// Handle use click - just navigate to detail page
|
||||
const handleUseClick = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
router.push(`/templates/${id}`)
|
||||
}
|
||||
|
||||
const handleCardClick = (e: React.MouseEvent) => {
|
||||
// Don't navigate if clicking on action buttons
|
||||
const target = e.target as HTMLElement
|
||||
if (target.closest('button') || target.closest('[data-action]')) {
|
||||
return
|
||||
/**
|
||||
* Get the appropriate template detail page URL based on context.
|
||||
* If we're in a workspace context, navigate to the workspace template page.
|
||||
* Otherwise, navigate to the global template page.
|
||||
* Memoized to avoid recalculation on every render.
|
||||
*/
|
||||
const templateUrl = useMemo(() => {
|
||||
const workspaceId = params?.workspaceId as string | undefined
|
||||
if (workspaceId) {
|
||||
return `/workspace/${workspaceId}/templates/${id}`
|
||||
}
|
||||
return `/templates/${id}`
|
||||
}, [params?.workspaceId, id])
|
||||
|
||||
router.push(`/templates/${id}`)
|
||||
}
|
||||
/**
|
||||
* Handle use button click - navigate to template detail page
|
||||
*/
|
||||
const handleUseClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
router.push(templateUrl)
|
||||
},
|
||||
[router, templateUrl]
|
||||
)
|
||||
|
||||
/**
|
||||
* Handle card click - navigate to template detail page
|
||||
*/
|
||||
const handleCardClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
// Don't navigate if clicking on action buttons
|
||||
const target = e.target as HTMLElement
|
||||
if (target.closest('button') || target.closest('[data-action]')) {
|
||||
return
|
||||
}
|
||||
|
||||
router.push(templateUrl)
|
||||
},
|
||||
[router, templateUrl]
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={handleCardClick}
|
||||
className={cn(
|
||||
'group cursor-pointer rounded-[8px] border bg-card shadow-xs transition-shadow duration-200 hover:border-border/80 hover:shadow-sm',
|
||||
'flex h-[142px]',
|
||||
className
|
||||
)}
|
||||
className={cn('w-full cursor-pointer rounded-[8px] bg-[#202020] p-[8px]', className)}
|
||||
>
|
||||
{/* Left side - Info */}
|
||||
<div className='flex min-w-0 flex-1 flex-col justify-between p-4'>
|
||||
{/* Top section */}
|
||||
<div className='space-y-2'>
|
||||
<div className='flex min-w-0 items-center justify-between gap-2.5'>
|
||||
<div className='flex min-w-0 items-center gap-2.5'>
|
||||
{/* Template name */}
|
||||
<h3 className='truncate font-medium font-sans text-card-foreground text-sm leading-tight'>
|
||||
{title}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className='flex flex-shrink-0 items-center gap-2'>
|
||||
{/* Star button - only for authenticated users */}
|
||||
{isAuthenticated && (
|
||||
<Star
|
||||
onClick={handleStarClick}
|
||||
className={cn(
|
||||
'h-4 w-4 cursor-pointer transition-colors duration-50',
|
||||
localIsStarred
|
||||
? 'fill-yellow-400 text-yellow-400'
|
||||
: 'text-muted-foreground hover:fill-yellow-400 hover:text-yellow-400',
|
||||
isStarLoading && 'opacity-50'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
onClick={handleUseClick}
|
||||
className={cn(
|
||||
'rounded-[8px] px-3 py-1 font-medium font-sans text-white text-xs transition-[background-color,box-shadow] duration-200',
|
||||
'bg-[var(--brand-primary-hex)] hover:bg-[var(--brand-primary-hover-hex)]',
|
||||
'shadow-[0_0_0_0_var(--brand-primary-hex)] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)]'
|
||||
)}
|
||||
>
|
||||
Use
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<p className='line-clamp-2 break-words font-sans text-muted-foreground text-xs leading-relaxed'>
|
||||
{description}
|
||||
</p>
|
||||
|
||||
{/* Tags */}
|
||||
{tags && tags.length > 0 && (
|
||||
<div className='mt-1 flex flex-wrap gap-1'>
|
||||
{tags.slice(0, 3).map((tag, index) => (
|
||||
<Badge
|
||||
key={index}
|
||||
variant='secondary'
|
||||
className='h-5 border-0 bg-muted/60 px-1.5 text-[10px] hover:bg-muted/80'
|
||||
>
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
{tags.length > 3 && (
|
||||
<Badge
|
||||
variant='secondary'
|
||||
className='h-5 border-0 bg-muted/60 px-1.5 text-[10px] hover:bg-muted/80'
|
||||
>
|
||||
+{tags.length - 3}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bottom section */}
|
||||
<div className='flex min-w-0 items-center gap-1.5 pt-1.5 font-sans text-muted-foreground text-xs'>
|
||||
<span className='flex-shrink-0'>by</span>
|
||||
<span className='min-w-0 truncate'>{author}</span>
|
||||
<span className='flex-shrink-0'>•</span>
|
||||
<User className='h-3 w-3 flex-shrink-0' />
|
||||
<span className='flex-shrink-0'>{usageCount}</span>
|
||||
{/* Stars section - hidden on smaller screens when space is constrained */}
|
||||
<div className='hidden flex-shrink-0 items-center gap-1.5 sm:flex'>
|
||||
<span>•</span>
|
||||
<Star className='h-3 w-3' />
|
||||
<span>{localStarCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Workflow Preview */}
|
||||
<div
|
||||
ref={previewRef}
|
||||
className='pointer-events-none h-[180px] w-full overflow-hidden rounded-[6px]'
|
||||
>
|
||||
{normalizedState && isInView ? (
|
||||
<WorkflowPreview
|
||||
workflowState={normalizedState}
|
||||
showSubBlocks={false}
|
||||
height={180}
|
||||
width='100%'
|
||||
isPannable={false}
|
||||
defaultZoom={0.8}
|
||||
fitPadding={0.2}
|
||||
/>
|
||||
) : (
|
||||
<div className='h-full w-full bg-[#2A2A2A]' />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right side - Block Icons */}
|
||||
<div className='flex w-16 flex-col items-center justify-center gap-2 rounded-r-[8px] border-border border-l bg-secondary p-2'>
|
||||
{blockTypes.length > 3 ? (
|
||||
<>
|
||||
{/* Show first 2 blocks when there are more than 3 */}
|
||||
{blockTypes.slice(0, 2).map((blockType, index) => {
|
||||
{/* Title and Blocks Row */}
|
||||
<div className='mt-[10px] flex items-center justify-between'>
|
||||
{/* Template Name */}
|
||||
<h3 className='truncate pr-[8px] pl-[2px] font-medium text-[16px] text-white'>{title}</h3>
|
||||
|
||||
{/* Block Icons */}
|
||||
<div className='flex flex-shrink-0'>
|
||||
{blockTypes.length > 4 ? (
|
||||
<>
|
||||
{/* Show first 3 blocks when there are more than 4 */}
|
||||
{blockTypes.slice(0, 3).map((blockType, index) => {
|
||||
const blockConfig = getBlockConfig(blockType)
|
||||
if (!blockConfig) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className='flex h-[18px] w-[18px] flex-shrink-0 items-center justify-center rounded-[4px]'
|
||||
style={{
|
||||
backgroundColor: blockConfig.bgColor || 'gray',
|
||||
marginLeft: index > 0 ? '-4px' : '0',
|
||||
}}
|
||||
>
|
||||
<blockConfig.icon className='h-[10px] w-[10px] text-white' />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{/* Show +n for remaining blocks */}
|
||||
<div
|
||||
className='flex h-[18px] w-[18px] flex-shrink-0 items-center justify-center rounded-[4px] bg-[#4A4A4A]'
|
||||
style={{ marginLeft: '-4px' }}
|
||||
>
|
||||
<span className='font-medium text-[10px] text-white'>+{blockTypes.length - 3}</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
/* Show all blocks when 4 or fewer */
|
||||
blockTypes.map((blockType, index) => {
|
||||
const blockConfig = getBlockConfig(blockType)
|
||||
if (!blockConfig) return null
|
||||
|
||||
return (
|
||||
<div key={index} className='flex items-center justify-center'>
|
||||
<div
|
||||
className='flex flex-shrink-0 items-center justify-center rounded-[8px]'
|
||||
style={{
|
||||
backgroundColor: blockConfig.bgColor || 'gray',
|
||||
width: '30px',
|
||||
height: '30px',
|
||||
}}
|
||||
>
|
||||
<blockConfig.icon className='h-4 w-4 text-white' />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{/* Show +n block for remaining blocks */}
|
||||
<div className='flex items-center justify-center'>
|
||||
<div
|
||||
className='flex flex-shrink-0 items-center justify-center rounded-[8px] bg-muted-foreground'
|
||||
style={{ width: '30px', height: '30px' }}
|
||||
>
|
||||
<span className='font-medium text-white text-xs'>+{blockTypes.length - 2}</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
/* Show all blocks when 3 or fewer */
|
||||
blockTypes.map((blockType, index) => {
|
||||
const blockConfig = getBlockConfig(blockType)
|
||||
if (!blockConfig) return null
|
||||
|
||||
return (
|
||||
<div key={index} className='flex items-center justify-center'>
|
||||
<div
|
||||
className='flex flex-shrink-0 items-center justify-center rounded-[8px]'
|
||||
key={index}
|
||||
className='flex h-[18px] w-[18px] flex-shrink-0 items-center justify-center rounded-[4px]'
|
||||
style={{
|
||||
backgroundColor: blockConfig.bgColor || 'gray',
|
||||
width: '30px',
|
||||
height: '30px',
|
||||
marginLeft: index > 0 ? '-4px' : '0',
|
||||
}}
|
||||
>
|
||||
<blockConfig.icon className='h-4 w-4 text-white' />
|
||||
<blockConfig.icon className='h-[10px] w-[10px] text-white' />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Creator and Stats Row */}
|
||||
<div className='mt-[10px] flex items-center justify-between'>
|
||||
{/* Creator Info */}
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
{authorImageUrl ? (
|
||||
<div className='h-[26px] w-[26px] flex-shrink-0 overflow-hidden rounded-full'>
|
||||
<img src={authorImageUrl} alt={author} className='h-full w-full object-cover' />
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex h-[26px] w-[26px] flex-shrink-0 items-center justify-center rounded-full bg-[#4A4A4A]'>
|
||||
<User className='h-[18px] w-[18px] text-[#888888]' />
|
||||
</div>
|
||||
)}
|
||||
<span className='truncate font-medium text-[#888888] text-[12px]'>{author}</span>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className='flex flex-shrink-0 items-center gap-[6px] font-medium text-[#888888] text-[12px]'>
|
||||
<User className='h-[12px] w-[12px]' />
|
||||
<span>{usageCount}</span>
|
||||
<Star
|
||||
onClick={handleStarClick}
|
||||
className={cn(
|
||||
'h-[12px] w-[12px] cursor-pointer transition-colors',
|
||||
localIsStarred ? 'fill-yellow-500 text-yellow-500' : 'text-[#888888]',
|
||||
isStarLoading && 'opacity-50'
|
||||
)}
|
||||
/>
|
||||
<span>{localStarCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const TemplateCard = memo(TemplateCardInner)
|
||||
|
||||
12
apps/sim/app/templates/layout-client.tsx
Normal file
12
apps/sim/app/templates/layout-client.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { Tooltip } from '@/components/emcn'
|
||||
import { season } from '@/app/fonts/season/season'
|
||||
|
||||
export default function TemplatesLayoutClient({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<Tooltip.Provider delayDuration={600} skipDelayDuration={0}>
|
||||
<div className={`${season.variable} font-season`}>{children}</div>
|
||||
</Tooltip.Provider>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
import { season } from '@/app/fonts/season/season'
|
||||
import TemplatesLayoutClient from './layout-client'
|
||||
|
||||
/**
|
||||
* Templates layout - server component wrapper for client layout.
|
||||
* Redirect logic is handled by individual pages to preserve paths.
|
||||
*/
|
||||
export default function TemplatesLayout({ children }: { children: React.ReactNode }) {
|
||||
return <div className={`${season.variable} font-season`}>{children}</div>
|
||||
return <TemplatesLayoutClient>{children}</TemplatesLayoutClient>
|
||||
}
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface NavigationTab {
|
||||
id: string
|
||||
label: string
|
||||
count?: number
|
||||
}
|
||||
|
||||
interface NavigationTabsProps {
|
||||
tabs: NavigationTab[]
|
||||
activeTab?: string
|
||||
onTabClick?: (tabId: string) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function NavigationTabs({ tabs, activeTab, onTabClick, className }: NavigationTabsProps) {
|
||||
return (
|
||||
<div className={cn('flex items-center gap-2', className)}>
|
||||
{tabs.map((tab, index) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => onTabClick?.(tab.id)}
|
||||
className={cn(
|
||||
'flex h-[38px] items-center gap-1 rounded-[14px] px-3 font-[440] font-sans text-muted-foreground text-sm transition-all duration-200',
|
||||
activeTab === tab.id ? 'bg-secondary' : 'bg-transparent hover:bg-secondary/50'
|
||||
)}
|
||||
>
|
||||
<span>{tab.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,99 +1,66 @@
|
||||
import { db } from '@sim/db'
|
||||
import { settings, templateCreators, templateStars, templates, user } from '@sim/db/schema'
|
||||
import { and, desc, eq, sql } from 'drizzle-orm'
|
||||
import { permissions, templateCreators, templates, workspace } from '@sim/db/schema'
|
||||
import { and, desc, eq } from 'drizzle-orm'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import type { Template } from '@/app/templates/templates'
|
||||
import Templates from '@/app/templates/templates'
|
||||
|
||||
/**
|
||||
* Public templates list page.
|
||||
* Redirects authenticated users to their workspace-scoped templates page.
|
||||
* Allows unauthenticated users to view templates for SEO and discovery.
|
||||
*/
|
||||
export default async function TemplatesPage() {
|
||||
const session = await getSession()
|
||||
|
||||
// Check if user is a super user and if super user mode is enabled
|
||||
let effectiveSuperUser = false
|
||||
// Authenticated users: redirect to workspace-scoped templates
|
||||
if (session?.user?.id) {
|
||||
const currentUser = await db
|
||||
.select({ isSuperUser: user.isSuperUser })
|
||||
.from(user)
|
||||
.where(eq(user.id, session.user.id))
|
||||
.limit(1)
|
||||
const userSettings = await db
|
||||
.select({ superUserModeEnabled: settings.superUserModeEnabled })
|
||||
.from(settings)
|
||||
.where(eq(settings.userId, session.user.id))
|
||||
const userWorkspaces = await db
|
||||
.select({
|
||||
workspace: workspace,
|
||||
})
|
||||
.from(permissions)
|
||||
.innerJoin(workspace, eq(permissions.entityId, workspace.id))
|
||||
.where(and(eq(permissions.userId, session.user.id), eq(permissions.entityType, 'workspace')))
|
||||
.orderBy(desc(workspace.createdAt))
|
||||
.limit(1)
|
||||
|
||||
const isSuperUser = currentUser[0]?.isSuperUser || false
|
||||
const superUserModeEnabled = userSettings[0]?.superUserModeEnabled ?? true
|
||||
|
||||
// Effective super user = database status AND UI mode enabled
|
||||
effectiveSuperUser = isSuperUser && superUserModeEnabled
|
||||
if (userWorkspaces.length > 0) {
|
||||
const firstWorkspace = userWorkspaces[0].workspace
|
||||
redirect(`/workspace/${firstWorkspace.id}/templates`)
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch templates based on user status
|
||||
let templatesData
|
||||
|
||||
if (session?.user?.id) {
|
||||
// Build where condition based on super user status
|
||||
const whereCondition = effectiveSuperUser ? undefined : eq(templates.status, 'approved')
|
||||
|
||||
// Logged-in users: include star status
|
||||
templatesData = await db
|
||||
.select({
|
||||
id: templates.id,
|
||||
workflowId: templates.workflowId,
|
||||
name: templates.name,
|
||||
details: templates.details,
|
||||
creatorId: templates.creatorId,
|
||||
creator: templateCreators,
|
||||
views: templates.views,
|
||||
stars: templates.stars,
|
||||
status: templates.status,
|
||||
tags: templates.tags,
|
||||
requiredCredentials: templates.requiredCredentials,
|
||||
state: templates.state,
|
||||
createdAt: templates.createdAt,
|
||||
updatedAt: templates.updatedAt,
|
||||
isStarred: sql<boolean>`CASE WHEN ${templateStars.id} IS NOT NULL THEN true ELSE false END`,
|
||||
})
|
||||
.from(templates)
|
||||
.leftJoin(
|
||||
templateStars,
|
||||
and(eq(templateStars.templateId, templates.id), eq(templateStars.userId, session.user.id))
|
||||
)
|
||||
.leftJoin(templateCreators, eq(templates.creatorId, templateCreators.id))
|
||||
.where(whereCondition)
|
||||
.orderBy(desc(templates.views), desc(templates.createdAt))
|
||||
} else {
|
||||
// Non-logged-in users: only approved templates, no star status
|
||||
templatesData = await db
|
||||
.select({
|
||||
id: templates.id,
|
||||
workflowId: templates.workflowId,
|
||||
name: templates.name,
|
||||
details: templates.details,
|
||||
creatorId: templates.creatorId,
|
||||
creator: templateCreators,
|
||||
views: templates.views,
|
||||
stars: templates.stars,
|
||||
status: templates.status,
|
||||
tags: templates.tags,
|
||||
requiredCredentials: templates.requiredCredentials,
|
||||
state: templates.state,
|
||||
createdAt: templates.createdAt,
|
||||
updatedAt: templates.updatedAt,
|
||||
})
|
||||
.from(templates)
|
||||
.leftJoin(templateCreators, eq(templates.creatorId, templateCreators.id))
|
||||
.where(eq(templates.status, 'approved'))
|
||||
.orderBy(desc(templates.views), desc(templates.createdAt))
|
||||
.then((rows) => rows.map((row) => ({ ...row, isStarred: false })))
|
||||
}
|
||||
// Unauthenticated users: show public templates
|
||||
const templatesData = await db
|
||||
.select({
|
||||
id: templates.id,
|
||||
workflowId: templates.workflowId,
|
||||
name: templates.name,
|
||||
details: templates.details,
|
||||
creatorId: templates.creatorId,
|
||||
creator: templateCreators,
|
||||
views: templates.views,
|
||||
stars: templates.stars,
|
||||
status: templates.status,
|
||||
tags: templates.tags,
|
||||
requiredCredentials: templates.requiredCredentials,
|
||||
state: templates.state,
|
||||
createdAt: templates.createdAt,
|
||||
updatedAt: templates.updatedAt,
|
||||
})
|
||||
.from(templates)
|
||||
.leftJoin(templateCreators, eq(templates.creatorId, templateCreators.id))
|
||||
.where(eq(templates.status, 'approved'))
|
||||
.orderBy(desc(templates.views), desc(templates.createdAt))
|
||||
.then((rows) => rows.map((row) => ({ ...row, isStarred: false })))
|
||||
|
||||
return (
|
||||
<Templates
|
||||
initialTemplates={templatesData as unknown as Template[]}
|
||||
currentUserId={session?.user?.id || null}
|
||||
isSuperUser={effectiveSuperUser}
|
||||
currentUserId={null}
|
||||
isSuperUser={false}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,564 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Award,
|
||||
BarChart3,
|
||||
Bell,
|
||||
BookOpen,
|
||||
Bot,
|
||||
Brain,
|
||||
Briefcase,
|
||||
Calculator,
|
||||
Cloud,
|
||||
Code,
|
||||
Cpu,
|
||||
CreditCard,
|
||||
Database,
|
||||
DollarSign,
|
||||
Edit,
|
||||
FileText,
|
||||
Folder,
|
||||
Globe,
|
||||
HeadphonesIcon,
|
||||
Layers,
|
||||
Lightbulb,
|
||||
LineChart,
|
||||
Mail,
|
||||
Megaphone,
|
||||
MessageSquare,
|
||||
NotebookPen,
|
||||
Phone,
|
||||
Play,
|
||||
Search,
|
||||
Server,
|
||||
Settings,
|
||||
ShoppingCart,
|
||||
Star,
|
||||
Target,
|
||||
TrendingUp,
|
||||
User,
|
||||
Users,
|
||||
Workflow,
|
||||
Wrench,
|
||||
Zap,
|
||||
} from 'lucide-react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { getBlock } from '@/blocks/registry'
|
||||
|
||||
const logger = createLogger('TemplateCard')
|
||||
|
||||
// Icon mapping for template icons
|
||||
const iconMap = {
|
||||
// Content & Documentation
|
||||
FileText,
|
||||
NotebookPen,
|
||||
BookOpen,
|
||||
Edit,
|
||||
|
||||
// Analytics & Charts
|
||||
BarChart3,
|
||||
LineChart,
|
||||
TrendingUp,
|
||||
Target,
|
||||
|
||||
// Database & Storage
|
||||
Database,
|
||||
Server,
|
||||
Cloud,
|
||||
Folder,
|
||||
|
||||
// Marketing & Communication
|
||||
Megaphone,
|
||||
Mail,
|
||||
MessageSquare,
|
||||
Phone,
|
||||
Bell,
|
||||
|
||||
// Sales & Finance
|
||||
DollarSign,
|
||||
CreditCard,
|
||||
Calculator,
|
||||
ShoppingCart,
|
||||
Briefcase,
|
||||
|
||||
// Support & Service
|
||||
HeadphonesIcon,
|
||||
User,
|
||||
Users,
|
||||
Settings,
|
||||
Wrench,
|
||||
|
||||
// AI & Technology
|
||||
Bot,
|
||||
Brain,
|
||||
Cpu,
|
||||
Code,
|
||||
Zap,
|
||||
|
||||
// Workflow & Process
|
||||
Workflow,
|
||||
Search,
|
||||
Play,
|
||||
Layers,
|
||||
|
||||
// General
|
||||
Lightbulb,
|
||||
Star,
|
||||
Globe,
|
||||
Award,
|
||||
}
|
||||
|
||||
interface TemplateCardProps {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
author: string
|
||||
usageCount: string
|
||||
stars?: number
|
||||
blocks?: string[]
|
||||
onClick?: () => void
|
||||
className?: string
|
||||
// Add state prop to extract block types
|
||||
state?: {
|
||||
blocks?: Record<string, { type: string; name?: string }>
|
||||
}
|
||||
isStarred?: boolean
|
||||
// Optional callback when template is successfully used (for closing modals, etc.)
|
||||
onTemplateUsed?: () => void
|
||||
// Callback when star state changes (for parent state updates)
|
||||
onStarChange?: (templateId: string, isStarred: boolean, newStarCount: number) => void
|
||||
// Super user props for approval
|
||||
status?: 'pending' | 'approved' | 'rejected'
|
||||
isSuperUser?: boolean
|
||||
onApprove?: (templateId: string) => void
|
||||
onReject?: (templateId: string) => void
|
||||
}
|
||||
|
||||
// Skeleton component for loading states
|
||||
export function TemplateCardSkeleton({ className }: { className?: string }) {
|
||||
return (
|
||||
<div className={cn('rounded-[8px] border bg-card shadow-xs', 'flex h-[142px]', className)}>
|
||||
{/* Left side - Info skeleton */}
|
||||
<div className='flex min-w-0 flex-1 flex-col justify-between p-4'>
|
||||
{/* Top section skeleton */}
|
||||
<div className='space-y-2'>
|
||||
<div className='flex min-w-0 items-center justify-between gap-2.5'>
|
||||
<div className='flex min-w-0 items-center gap-2.5'>
|
||||
{/* Icon skeleton */}
|
||||
<div className='h-5 w-5 flex-shrink-0 animate-pulse rounded-md bg-gray-200' />
|
||||
{/* Title skeleton */}
|
||||
<div className='h-4 w-32 animate-pulse rounded bg-gray-200' />
|
||||
</div>
|
||||
|
||||
{/* Star and Use button skeleton */}
|
||||
<div className='flex flex-shrink-0 items-center gap-3'>
|
||||
<div className='h-4 w-4 animate-pulse rounded bg-gray-200' />
|
||||
<div className='h-6 w-10 animate-pulse rounded-md bg-gray-200' />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description skeleton */}
|
||||
<div className='space-y-1.5'>
|
||||
<div className='h-3 w-full animate-pulse rounded bg-gray-200' />
|
||||
<div className='h-3 w-4/5 animate-pulse rounded bg-gray-200' />
|
||||
<div className='h-3 w-3/5 animate-pulse rounded bg-gray-200' />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom section skeleton */}
|
||||
<div className='flex min-w-0 items-center gap-1.5 pt-1.5'>
|
||||
<div className='h-3 w-6 animate-pulse rounded bg-gray-200' />
|
||||
<div className='h-3 w-16 animate-pulse rounded bg-gray-200' />
|
||||
<div className='h-2 w-1 animate-pulse rounded bg-gray-200' />
|
||||
<div className='h-3 w-3 animate-pulse rounded bg-gray-200' />
|
||||
<div className='h-3 w-8 animate-pulse rounded bg-gray-200' />
|
||||
{/* Stars section - hidden on smaller screens */}
|
||||
<div className='hidden flex-shrink-0 items-center gap-1.5 sm:flex'>
|
||||
<div className='h-2 w-1 animate-pulse rounded bg-gray-200' />
|
||||
<div className='h-3 w-3 animate-pulse rounded bg-gray-200' />
|
||||
<div className='h-3 w-6 animate-pulse rounded bg-gray-200' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right side - Block Icons skeleton */}
|
||||
<div className='flex w-16 flex-col items-center justify-center gap-2 rounded-r-[8px] border-border border-l bg-secondary p-2'>
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className='animate-pulse rounded bg-gray-200'
|
||||
style={{ width: '30px', height: '30px' }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Utility function to extract block types from workflow state
|
||||
const extractBlockTypesFromState = (state?: {
|
||||
blocks?: Record<string, { type: string; name?: string }>
|
||||
}): string[] => {
|
||||
if (!state?.blocks) return []
|
||||
|
||||
// Get unique block types from the state, excluding starter blocks
|
||||
// Sort the keys to ensure consistent ordering between server and client
|
||||
const blockTypes = Object.keys(state.blocks)
|
||||
.sort() // Sort keys to ensure consistent order
|
||||
.map((key) => state.blocks![key].type)
|
||||
.filter((type) => type !== 'starter')
|
||||
return [...new Set(blockTypes)]
|
||||
}
|
||||
|
||||
// Utility function to get block display name
|
||||
const getBlockDisplayName = (blockType: string): string => {
|
||||
const block = getBlock(blockType)
|
||||
return block?.name || blockType
|
||||
}
|
||||
|
||||
// Utility function to get the full block config for colored icon display
|
||||
const getBlockConfig = (blockType: string) => {
|
||||
const block = getBlock(blockType)
|
||||
return block
|
||||
}
|
||||
|
||||
export function TemplateCard({
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
author,
|
||||
usageCount,
|
||||
stars = 0,
|
||||
blocks = [],
|
||||
onClick,
|
||||
className,
|
||||
state,
|
||||
isStarred = false,
|
||||
onTemplateUsed,
|
||||
onStarChange,
|
||||
status,
|
||||
isSuperUser,
|
||||
onApprove,
|
||||
onReject,
|
||||
}: TemplateCardProps) {
|
||||
const router = useRouter()
|
||||
const params = useParams()
|
||||
const [isApproving, setIsApproving] = useState(false)
|
||||
const [isRejecting, setIsRejecting] = useState(false)
|
||||
|
||||
// Local state for optimistic updates
|
||||
const [localIsStarred, setLocalIsStarred] = useState(isStarred)
|
||||
const [localStarCount, setLocalStarCount] = useState(stars)
|
||||
const [isStarLoading, setIsStarLoading] = useState(false)
|
||||
|
||||
// Extract block types from state if provided, otherwise use the blocks prop
|
||||
// Filter out starter blocks in both cases and sort for consistent rendering
|
||||
const blockTypes = state
|
||||
? extractBlockTypesFromState(state)
|
||||
: blocks.filter((blockType) => blockType !== 'starter').sort()
|
||||
|
||||
// Handle star toggle with optimistic updates
|
||||
const handleStarClick = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
|
||||
// Prevent multiple clicks while loading
|
||||
if (isStarLoading) return
|
||||
|
||||
setIsStarLoading(true)
|
||||
|
||||
// Optimistic update - update UI immediately
|
||||
const newIsStarred = !localIsStarred
|
||||
const newStarCount = newIsStarred ? localStarCount + 1 : localStarCount - 1
|
||||
|
||||
setLocalIsStarred(newIsStarred)
|
||||
setLocalStarCount(newStarCount)
|
||||
|
||||
// Notify parent component immediately for optimistic update
|
||||
if (onStarChange) {
|
||||
onStarChange(id, newIsStarred, newStarCount)
|
||||
}
|
||||
|
||||
try {
|
||||
const method = localIsStarred ? 'DELETE' : 'POST'
|
||||
const response = await fetch(`/api/templates/${id}/star`, { method })
|
||||
|
||||
if (!response.ok) {
|
||||
// Rollback on error
|
||||
setLocalIsStarred(localIsStarred)
|
||||
setLocalStarCount(localStarCount)
|
||||
|
||||
// Rollback parent state too
|
||||
if (onStarChange) {
|
||||
onStarChange(id, localIsStarred, localStarCount)
|
||||
}
|
||||
|
||||
logger.error('Failed to toggle star:', response.statusText)
|
||||
}
|
||||
} catch (error) {
|
||||
// Rollback on error
|
||||
setLocalIsStarred(localIsStarred)
|
||||
setLocalStarCount(localStarCount)
|
||||
|
||||
// Rollback parent state too
|
||||
if (onStarChange) {
|
||||
onStarChange(id, localIsStarred, localStarCount)
|
||||
}
|
||||
|
||||
logger.error('Error toggling star:', error)
|
||||
} finally {
|
||||
setIsStarLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle use template
|
||||
const handleUseClick = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
try {
|
||||
const response = await fetch(`/api/templates/${id}/use`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
workspaceId: params.workspaceId,
|
||||
}),
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
logger.info('Template use API response:', data)
|
||||
|
||||
if (!data.workflowId) {
|
||||
logger.error('No workflowId returned from API:', data)
|
||||
return
|
||||
}
|
||||
|
||||
const workflowUrl = `/workspace/${params.workspaceId}/w/${data.workflowId}`
|
||||
logger.info('Template used successfully, navigating to:', workflowUrl)
|
||||
|
||||
// Call the callback if provided (for closing modals, etc.)
|
||||
if (onTemplateUsed) {
|
||||
onTemplateUsed()
|
||||
}
|
||||
|
||||
// Use window.location.href for more reliable navigation
|
||||
window.location.href = workflowUrl
|
||||
} else {
|
||||
const errorText = await response.text()
|
||||
logger.error('Failed to use template:', response.statusText, errorText)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error using template:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCardClick = (e: React.MouseEvent) => {
|
||||
// Don't navigate if clicking on action buttons
|
||||
const target = e.target as HTMLElement
|
||||
if (target.closest('button') || target.closest('[data-action]')) {
|
||||
return
|
||||
}
|
||||
|
||||
const workspaceId = params?.workspaceId as string
|
||||
if (workspaceId) {
|
||||
router.push(`/workspace/${workspaceId}/templates/${id}`)
|
||||
}
|
||||
}
|
||||
|
||||
const handleApprove = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (isApproving || !onApprove) return
|
||||
|
||||
setIsApproving(true)
|
||||
try {
|
||||
const response = await fetch(`/api/templates/${id}/approve`, {
|
||||
method: 'POST',
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
onApprove(id)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error approving template:', error)
|
||||
} finally {
|
||||
setIsApproving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleReject = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (isRejecting || !onReject) return
|
||||
|
||||
setIsRejecting(true)
|
||||
try {
|
||||
const response = await fetch(`/api/templates/${id}/reject`, {
|
||||
method: 'POST',
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
onReject(id)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error rejecting template:', error)
|
||||
} finally {
|
||||
setIsRejecting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={handleCardClick}
|
||||
className={cn(
|
||||
'group cursor-pointer rounded-[8px] border bg-card shadow-xs transition-shadow duration-200 hover:border-border/80 hover:shadow-sm',
|
||||
'flex h-[142px]',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Left side - Info */}
|
||||
<div className='flex min-w-0 flex-1 flex-col justify-between p-4'>
|
||||
{/* Top section */}
|
||||
<div className='space-y-2'>
|
||||
<div className='flex min-w-0 items-center justify-between gap-2.5'>
|
||||
<div className='flex min-w-0 items-center gap-2.5'>
|
||||
{/* Template name */}
|
||||
<h3 className='truncate font-medium font-sans text-card-foreground text-sm leading-tight'>
|
||||
{title}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className='flex flex-shrink-0 items-center gap-2'>
|
||||
{/* Approve/Reject buttons for pending templates (super users only) */}
|
||||
{isSuperUser && status === 'pending' ? (
|
||||
<>
|
||||
<button
|
||||
onClick={handleApprove}
|
||||
disabled={isApproving}
|
||||
className={cn(
|
||||
'rounded-[8px] px-3 py-1 font-medium font-sans text-white text-xs transition-colors duration-200',
|
||||
'bg-green-600 hover:bg-green-700 disabled:opacity-50'
|
||||
)}
|
||||
>
|
||||
{isApproving ? '...' : 'Approve'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleReject}
|
||||
disabled={isRejecting}
|
||||
className={cn(
|
||||
'rounded-[8px] px-3 py-1 font-medium font-sans text-white text-xs transition-colors duration-200',
|
||||
'bg-red-600 hover:bg-red-700 disabled:opacity-50'
|
||||
)}
|
||||
>
|
||||
{isRejecting ? '...' : 'Reject'}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Star
|
||||
onClick={handleStarClick}
|
||||
className={cn(
|
||||
'h-4 w-4 cursor-pointer transition-colors duration-50',
|
||||
localIsStarred
|
||||
? 'fill-yellow-400 text-yellow-400'
|
||||
: 'text-muted-foreground hover:fill-yellow-400 hover:text-yellow-400',
|
||||
isStarLoading && 'opacity-50'
|
||||
)}
|
||||
/>
|
||||
<button
|
||||
onClick={handleUseClick}
|
||||
className={cn(
|
||||
'rounded-[8px] px-3 py-1 font-medium font-sans text-white text-xs transition-[background-color,box-shadow] duration-200',
|
||||
'bg-[var(--brand-primary-hex)] hover:bg-[var(--brand-primary-hover-hex)]',
|
||||
'shadow-[0_0_0_0_var(--brand-primary-hex)] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)]'
|
||||
)}
|
||||
>
|
||||
Use
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<p className='line-clamp-3 break-words font-sans text-muted-foreground text-xs leading-relaxed'>
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Bottom section */}
|
||||
<div className='flex min-w-0 items-center gap-1.5 pt-1.5 font-sans text-muted-foreground text-xs'>
|
||||
<span className='flex-shrink-0'>by</span>
|
||||
<span className='min-w-0 truncate'>{author}</span>
|
||||
<span className='flex-shrink-0'>•</span>
|
||||
<User className='h-3 w-3 flex-shrink-0' />
|
||||
<span className='flex-shrink-0'>{usageCount}</span>
|
||||
{/* Stars section - hidden on smaller screens when space is constrained */}
|
||||
<div className='hidden flex-shrink-0 items-center gap-1.5 sm:flex'>
|
||||
<span>•</span>
|
||||
<Star className='h-3 w-3' />
|
||||
<span>{localStarCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right side - Block Icons */}
|
||||
<div className='flex w-16 flex-col items-center justify-center gap-2 rounded-r-[8px] border-border border-l bg-secondary p-2'>
|
||||
{blockTypes.length > 3 ? (
|
||||
<>
|
||||
{/* Show first 2 blocks when there are more than 3 */}
|
||||
{blockTypes.slice(0, 2).map((blockType, index) => {
|
||||
const blockConfig = getBlockConfig(blockType)
|
||||
if (!blockConfig) return null
|
||||
|
||||
return (
|
||||
<div key={index} className='flex items-center justify-center'>
|
||||
<div
|
||||
className='flex flex-shrink-0 items-center justify-center rounded-[8px]'
|
||||
style={{
|
||||
backgroundColor: blockConfig.bgColor || 'gray',
|
||||
width: '30px',
|
||||
height: '30px',
|
||||
}}
|
||||
>
|
||||
<blockConfig.icon className='h-4 w-4 text-white' />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{/* Show +n block for remaining blocks */}
|
||||
<div className='flex items-center justify-center'>
|
||||
<div
|
||||
className='flex flex-shrink-0 items-center justify-center rounded-[8px] bg-muted-foreground'
|
||||
style={{ width: '30px', height: '30px' }}
|
||||
>
|
||||
<span className='font-medium text-white text-xs'>+{blockTypes.length - 2}</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
/* Show all blocks when 3 or fewer */
|
||||
blockTypes.map((blockType, index) => {
|
||||
const blockConfig = getBlockConfig(blockType)
|
||||
if (!blockConfig) return null
|
||||
|
||||
return (
|
||||
<div key={index} className='flex items-center justify-center'>
|
||||
<div
|
||||
className='flex flex-shrink-0 items-center justify-center rounded-[8px]'
|
||||
style={{
|
||||
backgroundColor: blockConfig.bgColor || 'gray',
|
||||
width: '30px',
|
||||
height: '30px',
|
||||
}}
|
||||
>
|
||||
<blockConfig.icon className='h-4 w-4 text-white' />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,13 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { ArrowLeft, Search } from 'lucide-react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Layout, Search } from 'lucide-react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Button } from '@/components/emcn'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import type { CredentialRequirement } from '@/lib/workflows/credential-extractor'
|
||||
import { NavigationTabs } from '@/app/templates/components/navigation-tabs'
|
||||
import { TemplateCard, TemplateCardSkeleton } from '@/app/templates/components/template-card'
|
||||
import type { WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
import type { CreatorProfileDetails } from '@/types/creator-profile'
|
||||
@@ -60,11 +59,30 @@ export default function Templates({
|
||||
const [templates, setTemplates] = useState<Template[]>(initialTemplates)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleTabClick = (tabId: string) => {
|
||||
setActiveTab(tabId)
|
||||
}
|
||||
// Redirect authenticated users to workspace templates
|
||||
useEffect(() => {
|
||||
if (currentUserId) {
|
||||
const redirectToWorkspace = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/workspaces')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
const defaultWorkspace = data.workspaces?.[0]
|
||||
if (defaultWorkspace) {
|
||||
router.push(`/workspace/${defaultWorkspace.id}/templates`)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error redirecting to workspace:', error)
|
||||
}
|
||||
}
|
||||
redirectToWorkspace()
|
||||
}
|
||||
}, [currentUserId, router])
|
||||
|
||||
// Handle star change callback from template card
|
||||
/**
|
||||
* Update star status for a template
|
||||
*/
|
||||
const handleStarChange = (templateId: string, isStarred: boolean, newStarCount: number) => {
|
||||
setTemplates((prevTemplates) =>
|
||||
prevTemplates.map((template) =>
|
||||
@@ -73,238 +91,137 @@ export default function Templates({
|
||||
)
|
||||
}
|
||||
|
||||
const matchesSearch = (template: Template) => {
|
||||
if (!searchQuery) return true
|
||||
/**
|
||||
* Filter templates based on active tab and search query
|
||||
* Memoized to prevent unnecessary recalculations on render
|
||||
*/
|
||||
const filteredTemplates = useMemo(() => {
|
||||
const query = searchQuery.toLowerCase()
|
||||
return (
|
||||
template.name.toLowerCase().includes(query) ||
|
||||
template.details?.tagline?.toLowerCase().includes(query) ||
|
||||
template.creator?.name?.toLowerCase().includes(query)
|
||||
)
|
||||
}
|
||||
|
||||
const ownedTemplates = currentUserId
|
||||
? templates.filter(
|
||||
(template) =>
|
||||
template.creator?.referenceType === 'user' &&
|
||||
template.creator?.referenceId === currentUserId
|
||||
)
|
||||
: []
|
||||
const starredTemplates = currentUserId
|
||||
? templates.filter(
|
||||
(template) =>
|
||||
template.isStarred &&
|
||||
!(
|
||||
template.creator?.referenceType === 'user' &&
|
||||
template.creator?.referenceId === currentUserId
|
||||
)
|
||||
)
|
||||
: []
|
||||
return templates.filter((template) => {
|
||||
// Filter by tab - only gallery and pending for public page
|
||||
const tabMatch =
|
||||
activeTab === 'gallery' ? template.status === 'approved' : template.status === 'pending'
|
||||
|
||||
const filteredOwnedTemplates = ownedTemplates.filter(matchesSearch)
|
||||
const filteredStarredTemplates = starredTemplates.filter(matchesSearch)
|
||||
if (!tabMatch) return false
|
||||
|
||||
const galleryTemplates = templates
|
||||
.filter((template) => template.status === 'approved')
|
||||
.filter(matchesSearch)
|
||||
// Filter by search query
|
||||
if (!query) return true
|
||||
|
||||
const pendingTemplates = templates
|
||||
.filter((template) => template.status === 'pending')
|
||||
.filter(matchesSearch)
|
||||
const searchableText = [template.name, template.details?.tagline, template.creator?.name]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
|
||||
// Helper function to render template cards
|
||||
const renderTemplateCard = (template: Template) => (
|
||||
<TemplateCard
|
||||
key={template.id}
|
||||
id={template.id}
|
||||
title={template.name}
|
||||
description={template.details?.tagline || ''}
|
||||
author={template.creator?.name || 'Unknown'}
|
||||
usageCount={template.views.toString()}
|
||||
stars={template.stars}
|
||||
tags={template.tags}
|
||||
state={template.state as { blocks?: Record<string, { type: string; name?: string }> }}
|
||||
isStarred={template.isStarred}
|
||||
onStarChange={handleStarChange}
|
||||
isAuthenticated={!!currentUserId}
|
||||
/>
|
||||
)
|
||||
return searchableText.includes(query)
|
||||
})
|
||||
}, [templates, activeTab, searchQuery])
|
||||
|
||||
// Render skeleton cards for loading state
|
||||
const renderSkeletonCards = () => {
|
||||
return Array.from({ length: 8 }).map((_, index) => (
|
||||
<TemplateCardSkeleton key={`skeleton-${index}`} />
|
||||
))
|
||||
}
|
||||
|
||||
// Calculate counts for tabs
|
||||
const yourTemplatesCount = ownedTemplates.length + starredTemplates.length
|
||||
const galleryCount = templates.filter((template) => template.status === 'approved').length
|
||||
const pendingCount = templates.filter((template) => template.status === 'pending').length
|
||||
|
||||
// Build tabs based on user status
|
||||
const navigationTabs = [
|
||||
{
|
||||
id: 'gallery',
|
||||
label: 'Gallery',
|
||||
count: galleryCount,
|
||||
},
|
||||
...(currentUserId
|
||||
? [
|
||||
{
|
||||
id: 'your',
|
||||
label: 'Your Templates',
|
||||
count: yourTemplatesCount,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(isSuperUser
|
||||
? [
|
||||
{
|
||||
id: 'pending',
|
||||
label: 'Pending',
|
||||
count: pendingCount,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]
|
||||
|
||||
// Show tabs if there's more than one tab
|
||||
const showTabs = navigationTabs.length > 1
|
||||
|
||||
const handleBackToWorkspace = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/workspaces')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
const defaultWorkspace = data.workspaces?.[0]
|
||||
if (defaultWorkspace) {
|
||||
router.push(`/workspace/${defaultWorkspace.id}`)
|
||||
}
|
||||
/**
|
||||
* Get empty state message based on current filters
|
||||
* Memoized to prevent unnecessary recalculations on render
|
||||
*/
|
||||
const emptyState = useMemo(() => {
|
||||
if (searchQuery) {
|
||||
return {
|
||||
title: 'No templates found',
|
||||
description: 'Try a different search term',
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error navigating to workspace:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const messages = {
|
||||
pending: {
|
||||
title: 'No pending templates',
|
||||
description: 'New submissions will appear here',
|
||||
},
|
||||
gallery: {
|
||||
title: 'No templates available',
|
||||
description: 'Templates will appear once approved',
|
||||
},
|
||||
}
|
||||
|
||||
return messages[activeTab as keyof typeof messages] || messages.gallery
|
||||
}, [searchQuery, activeTab])
|
||||
|
||||
return (
|
||||
<div className='flex h-[100vh] flex-col'>
|
||||
<div className='flex flex-1 overflow-hidden'>
|
||||
<div className='flex flex-1 flex-col overflow-auto p-6'>
|
||||
{/* Header with Back Button */}
|
||||
<div className='mb-6'>
|
||||
{currentUserId && (
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={handleBackToWorkspace}
|
||||
className='-ml-2 mb-4 text-muted-foreground hover:text-foreground'
|
||||
>
|
||||
<ArrowLeft className='mr-2 h-4 w-4' />
|
||||
Back to Workspace
|
||||
</Button>
|
||||
)}
|
||||
<h1 className='mb-2 font-sans font-semibold text-3xl text-foreground tracking-[0.01em]'>
|
||||
Templates
|
||||
</h1>
|
||||
<p className='font-[350] font-sans text-muted-foreground text-sm leading-[1.5] tracking-[0.01em]'>
|
||||
Grab a template and start building, or make
|
||||
<br />
|
||||
one from scratch.
|
||||
<div className='flex flex-1 flex-col overflow-auto px-[24px] pt-[24px] pb-[24px]'>
|
||||
<div>
|
||||
<div className='flex items-start gap-[12px]'>
|
||||
<div className='flex h-[26px] w-[26px] items-center justify-center rounded-[6px] border border-[#7A5F11] bg-[#514215]'>
|
||||
<Layout className='h-[14px] w-[14px] text-[#FBBC04]' />
|
||||
</div>
|
||||
<h1 className='font-medium text-[18px]'>Templates</h1>
|
||||
</div>
|
||||
<p className='mt-[10px] font-base text-[#888888] text-[14px]'>
|
||||
Grab a template and start building, or make one from scratch.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className='mb-6 flex items-center justify-between'>
|
||||
<div className='flex h-9 w-[460px] items-center gap-2 rounded-lg border bg-transparent pr-2 pl-3'>
|
||||
<Search className='h-4 w-4 text-muted-foreground' strokeWidth={2} />
|
||||
<div className='mt-[14px] flex items-center justify-between'>
|
||||
<div className='flex h-[32px] w-[400px] items-center gap-[6px] rounded-[8px] bg-[var(--surface-5)] px-[8px]'>
|
||||
<Search className='h-[14px] w-[14px] text-[var(--text-subtle)]' />
|
||||
<Input
|
||||
placeholder='Search templates...'
|
||||
placeholder='Search'
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className='flex-1 border-0 bg-transparent px-0 font-normal font-sans text-base text-foreground leading-none placeholder:text-muted-foreground focus-visible:ring-0 focus-visible:ring-offset-0'
|
||||
className='flex-1 border-0 bg-transparent px-0 font-medium text-[var(--text-secondary)] text-small leading-none placeholder:text-[var(--text-subtle)] focus-visible:ring-0 focus-visible:ring-offset-0'
|
||||
/>
|
||||
</div>
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
<Button
|
||||
variant={activeTab === 'gallery' ? 'active' : 'default'}
|
||||
className='h-[32px] rounded-[6px]'
|
||||
onClick={() => setActiveTab('gallery')}
|
||||
>
|
||||
Gallery
|
||||
</Button>
|
||||
{isSuperUser && (
|
||||
<Button
|
||||
variant={activeTab === 'pending' ? 'active' : 'default'}
|
||||
className='h-[32px] rounded-[6px]'
|
||||
onClick={() => setActiveTab('pending')}
|
||||
>
|
||||
Pending
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation - only show if multiple tabs */}
|
||||
{showTabs && (
|
||||
<div className='mb-6'>
|
||||
<NavigationTabs
|
||||
tabs={navigationTabs}
|
||||
activeTab={activeTab}
|
||||
onTabClick={handleTabClick}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className='mt-[24px] h-[1px] w-full border-[var(--border)] border-t' />
|
||||
|
||||
{loading ? (
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'>
|
||||
{renderSkeletonCards()}
|
||||
</div>
|
||||
) : activeTab === 'your' ? (
|
||||
filteredOwnedTemplates.length === 0 && filteredStarredTemplates.length === 0 ? (
|
||||
<div className='flex h-64 items-center justify-center rounded-lg border border-muted-foreground/25 border-dashed bg-muted/20'>
|
||||
<div className='mt-[24px] grid grid-cols-1 gap-x-[20px] gap-y-[40px] md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'>
|
||||
{loading ? (
|
||||
Array.from({ length: 8 }).map((_, index) => (
|
||||
<TemplateCardSkeleton key={`skeleton-${index}`} />
|
||||
))
|
||||
) : filteredTemplates.length === 0 ? (
|
||||
<div className='col-span-full flex h-64 items-center justify-center rounded-lg border border-muted-foreground/25 border-dashed bg-muted/20'>
|
||||
<div className='text-center'>
|
||||
<p className='font-medium text-muted-foreground text-sm'>
|
||||
{searchQuery ? 'No templates found' : 'No templates yet'}
|
||||
</p>
|
||||
<p className='mt-1 text-muted-foreground/70 text-xs'>
|
||||
{searchQuery
|
||||
? 'Try a different search term'
|
||||
: 'Create or star templates to see them here'}
|
||||
</p>
|
||||
<p className='font-medium text-muted-foreground text-sm'>{emptyState.title}</p>
|
||||
<p className='mt-1 text-muted-foreground/70 text-xs'>{emptyState.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className='space-y-8'>
|
||||
{filteredOwnedTemplates.length > 0 && (
|
||||
<section>
|
||||
<h2 className='mb-3 font-semibold text-lg'>Your Templates</h2>
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'>
|
||||
{filteredOwnedTemplates.map((template) => renderTemplateCard(template))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{filteredStarredTemplates.length > 0 && (
|
||||
<section>
|
||||
<h2 className='mb-3 font-semibold text-lg'>Starred Templates</h2>
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'>
|
||||
{filteredStarredTemplates.map((template) => renderTemplateCard(template))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'>
|
||||
{(activeTab === 'gallery' ? galleryTemplates : pendingTemplates).length === 0 ? (
|
||||
<div className='col-span-full flex h-64 items-center justify-center rounded-lg border border-muted-foreground/25 border-dashed bg-muted/20'>
|
||||
<div className='text-center'>
|
||||
<p className='font-medium text-muted-foreground text-sm'>
|
||||
{searchQuery
|
||||
? 'No templates found'
|
||||
: activeTab === 'pending'
|
||||
? 'No pending templates'
|
||||
: 'No templates available'}
|
||||
</p>
|
||||
<p className='mt-1 text-muted-foreground/70 text-xs'>
|
||||
{searchQuery
|
||||
? 'Try a different search term'
|
||||
: activeTab === 'pending'
|
||||
? 'New submissions will appear here'
|
||||
: 'Templates will appear once approved'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
(activeTab === 'gallery' ? galleryTemplates : pendingTemplates).map((template) =>
|
||||
renderTemplateCard(template)
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
filteredTemplates.map((template) => (
|
||||
<TemplateCard
|
||||
key={template.id}
|
||||
id={template.id}
|
||||
title={template.name}
|
||||
description={template.details?.tagline || ''}
|
||||
author={template.creator?.name || 'Unknown'}
|
||||
authorImageUrl={template.creator?.profileImageUrl || null}
|
||||
usageCount={template.views.toString()}
|
||||
stars={template.stars}
|
||||
state={template.state}
|
||||
isStarred={template.isStarred}
|
||||
onStarChange={handleStarChange}
|
||||
isAuthenticated={!!currentUserId}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,24 +7,25 @@ import { ThemeProvider as NextThemesProvider } from 'next-themes'
|
||||
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||
const pathname = usePathname()
|
||||
|
||||
// Force dark mode for workspace pages
|
||||
// Force dark mode for workspace pages and templates
|
||||
// Force light mode for certain public pages
|
||||
const forcedTheme = pathname.startsWith('/workspace')
|
||||
? 'dark'
|
||||
: pathname === '/' ||
|
||||
pathname.startsWith('/login') ||
|
||||
pathname.startsWith('/signup') ||
|
||||
pathname.startsWith('/sso') ||
|
||||
pathname.startsWith('/terms') ||
|
||||
pathname.startsWith('/privacy') ||
|
||||
pathname.startsWith('/invite') ||
|
||||
pathname.startsWith('/verify') ||
|
||||
pathname.startsWith('/careers') ||
|
||||
pathname.startsWith('/changelog') ||
|
||||
pathname.startsWith('/chat') ||
|
||||
pathname.startsWith('/studio')
|
||||
? 'light'
|
||||
: undefined
|
||||
const forcedTheme =
|
||||
pathname.startsWith('/workspace') || pathname.startsWith('/templates')
|
||||
? 'dark'
|
||||
: pathname === '/' ||
|
||||
pathname.startsWith('/login') ||
|
||||
pathname.startsWith('/signup') ||
|
||||
pathname.startsWith('/sso') ||
|
||||
pathname.startsWith('/terms') ||
|
||||
pathname.startsWith('/privacy') ||
|
||||
pathname.startsWith('/invite') ||
|
||||
pathname.startsWith('/verify') ||
|
||||
pathname.startsWith('/careers') ||
|
||||
pathname.startsWith('/changelog') ||
|
||||
pathname.startsWith('/chat') ||
|
||||
pathname.startsWith('/studio')
|
||||
? 'light'
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<NextThemesProvider
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
RotateCcw,
|
||||
} from 'lucide-react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { Tooltip } from '@/components/emcn'
|
||||
import { Button, Tooltip } from '@/components/emcn'
|
||||
import { Trash } from '@/components/emcn/icons/trash'
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -28,7 +28,6 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { SearchHighlight } from '@/components/ui/search-highlight'
|
||||
import type { DocumentSortField, SortOrder } from '@/lib/knowledge/documents/types'
|
||||
@@ -45,8 +44,12 @@ import {
|
||||
SearchInput,
|
||||
} from '@/app/workspace/[workspaceId]/knowledge/components'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import { useKnowledgeBase, useKnowledgeBaseDocuments } from '@/hooks/use-knowledge'
|
||||
import { type DocumentData, useKnowledgeStore } from '@/stores/knowledge/store'
|
||||
import {
|
||||
useKnowledgeBase,
|
||||
useKnowledgeBaseDocuments,
|
||||
useKnowledgeBasesList,
|
||||
} from '@/hooks/use-knowledge'
|
||||
import type { DocumentData } from '@/stores/knowledge/store'
|
||||
|
||||
const logger = createLogger('KnowledgeBase')
|
||||
|
||||
@@ -126,10 +129,10 @@ export function KnowledgeBase({
|
||||
id,
|
||||
knowledgeBaseName: passedKnowledgeBaseName,
|
||||
}: KnowledgeBaseProps) {
|
||||
const { removeKnowledgeBase } = useKnowledgeStore()
|
||||
const userPermissions = useUserPermissionsContext()
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
const { removeKnowledgeBase } = useKnowledgeBasesList(workspaceId, { enabled: false })
|
||||
const userPermissions = useUserPermissionsContext()
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
|
||||
@@ -1006,7 +1009,6 @@ export function KnowledgeBase({
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleRetryDocument(doc.id)
|
||||
@@ -1024,7 +1026,6 @@ export function KnowledgeBase({
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleToggleEnabled(doc.id)
|
||||
@@ -1059,7 +1060,6 @@ export function KnowledgeBase({
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleDeleteDocument(doc.id)
|
||||
@@ -1070,7 +1070,7 @@ export function KnowledgeBase({
|
||||
}
|
||||
className='h-8 w-8 p-0 text-gray-500 hover:text-red-600 disabled:opacity-50'
|
||||
>
|
||||
<Trash className='h-4 w-4' />
|
||||
<Trash className='h-[14px] w-[14px]' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>
|
||||
@@ -1097,7 +1097,6 @@ export function KnowledgeBase({
|
||||
<div className='flex items-center gap-1'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={prevPage}
|
||||
disabled={!hasPrevPage || isLoadingDocuments}
|
||||
className='h-8 w-8 p-0'
|
||||
@@ -1138,7 +1137,6 @@ export function KnowledgeBase({
|
||||
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={nextPage}
|
||||
disabled={!hasNextPage || isLoadingDocuments}
|
||||
className='h-8 w-8 p-0'
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Button } from '@/components/emcn'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface PrimaryButtonProps {
|
||||
children: React.ReactNode
|
||||
onClick?: () => void
|
||||
disabled?: boolean
|
||||
size?: 'sm' | 'default' | 'lg'
|
||||
className?: string
|
||||
type?: 'button' | 'submit' | 'reset'
|
||||
}
|
||||
@@ -16,7 +15,6 @@ export function PrimaryButton({
|
||||
children,
|
||||
onClick,
|
||||
disabled = false,
|
||||
size = 'sm',
|
||||
className,
|
||||
type = 'button',
|
||||
}: PrimaryButtonProps) {
|
||||
@@ -25,9 +23,9 @@ export function PrimaryButton({
|
||||
type={type}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
size={size}
|
||||
variant='primary'
|
||||
className={cn(
|
||||
'flex items-center gap-1 bg-[var(--brand-primary-hex)] font-[480] text-white shadow-[0_0_0_0_var(--brand-primary-hex)] transition-all duration-200 hover:bg-[var(--brand-primary-hover-hex)] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)]',
|
||||
'flex h-8 items-center gap-1 px-[8px] py-[6px] font-[480] shadow-[0_0_0_0_var(--brand-primary-hex)] transition-all duration-200 hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)]',
|
||||
disabled && 'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Check, ChevronDown, LibraryBig, Plus } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { Tooltip } from '@/components/emcn'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Button, Tooltip } from '@/components/emcn'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -111,7 +110,7 @@ export function Knowledge() {
|
||||
{/* Sort Dropdown */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant='outline' size='sm' className={filterButtonClass}>
|
||||
<Button variant='outline' className={filterButtonClass}>
|
||||
{currentSortLabel}
|
||||
<ChevronDown className='ml-2 h-4 w-4 text-muted-foreground' />
|
||||
</Button>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Loader2, RefreshCw, Search } from 'lucide-react'
|
||||
import { Tooltip } from '@/components/emcn'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ArrowUp, Loader2, RefreshCw, Search } from 'lucide-react'
|
||||
import { Button, Tooltip } from '@/components/emcn'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { soehne } from '@/app/fonts/soehne/soehne'
|
||||
@@ -17,7 +16,6 @@ export function Controls({
|
||||
viewMode,
|
||||
setViewMode,
|
||||
searchComponent,
|
||||
showExport = true,
|
||||
onExport,
|
||||
}: {
|
||||
searchQuery?: string
|
||||
@@ -49,7 +47,7 @@ export function Controls({
|
||||
placeholder='Search workflows...'
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery?.(e.target.value)}
|
||||
className='h-9 w-full rounded-[11px] border-[#E5E5E5] bg-[var(--white)] pr-10 pl-9 dark:border-[#414141] dark:bg-[var(--surface-elevated)]'
|
||||
className='h-9 w-full border-[#E5E5E5] bg-[var(--white)] pr-10 pl-9 dark:border-[#414141] dark:bg-[var(--surface-elevated)]'
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
@@ -73,19 +71,35 @@ export function Controls({
|
||||
)}
|
||||
|
||||
<div className='ml-auto flex flex-shrink-0 items-center gap-3'>
|
||||
{viewMode !== 'dashboard' && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={onExport}
|
||||
className='h-9 w-9 p-0 hover:bg-secondary'
|
||||
aria-label='Export CSV'
|
||||
>
|
||||
<ArrowUp className='h-4 w-4' />
|
||||
<span className='sr-only'>Export CSV</span>
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>Export CSV</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
onClick={resetToNow}
|
||||
className='h-9 rounded-[11px] hover:bg-secondary'
|
||||
className='h-9 w-9 p-0 hover:bg-secondary'
|
||||
disabled={isRefetching}
|
||||
>
|
||||
{isRefetching ? (
|
||||
<Loader2 className='h-5 w-5 animate-spin' />
|
||||
<Loader2 className='h-4 w-4 animate-spin' />
|
||||
) : (
|
||||
<RefreshCw className='h-5 w-5' />
|
||||
<RefreshCw className='h-4 w-4' />
|
||||
)}
|
||||
<span className='sr-only'>Refresh</span>
|
||||
</Button>
|
||||
@@ -93,37 +107,9 @@ export function Controls({
|
||||
<Tooltip.Content>{isRefetching ? 'Refreshing...' : 'Refresh'}</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
onClick={onExport}
|
||||
className='h-9 rounded-[11px] hover:bg-secondary'
|
||||
aria-label='Export CSV'
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
className='h-5 w-5'
|
||||
>
|
||||
<path d='M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4' />
|
||||
<polyline points='7 10 12 15 17 10' />
|
||||
<line x1='12' y1='15' x2='12' y2='3' />
|
||||
</svg>
|
||||
<span className='sr-only'>Export CSV</span>
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>Export CSV</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
|
||||
<div className='inline-flex h-9 items-center rounded-[11px] border bg-card p-1 shadow-sm'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => setLive((v) => !v)}
|
||||
className={cn(
|
||||
'h-7 rounded-[8px] px-3 font-normal text-xs',
|
||||
@@ -140,7 +126,6 @@ export function Controls({
|
||||
<div className='inline-flex h-9 items-center rounded-[11px] border bg-card p-1 shadow-sm'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => setViewMode('logs')}
|
||||
className={cn(
|
||||
'h-7 rounded-[8px] px-3 font-normal text-xs',
|
||||
@@ -154,7 +139,6 @@ export function Controls({
|
||||
</Button>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => setViewMode('dashboard')}
|
||||
className={cn(
|
||||
'h-7 rounded-[8px] px-3 font-normal text-xs',
|
||||
|
||||
@@ -9,25 +9,25 @@ export interface AggregateMetrics {
|
||||
export function KPIs({ aggregate }: { aggregate: AggregateMetrics }) {
|
||||
return (
|
||||
<div className='mb-2 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4'>
|
||||
<div className='rounded-[12px] border bg-card p-4 shadow-sm'>
|
||||
<div className='rounded-[11px] border bg-card p-4 shadow-sm'>
|
||||
<div className='text-muted-foreground text-xs'>Total executions</div>
|
||||
<div className='mt-1 font-[440] text-[22px] leading-6'>
|
||||
{aggregate.totalExecutions.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className='rounded-[12px] border bg-card p-4 shadow-sm'>
|
||||
<div className='rounded-[11px] border bg-card p-4 shadow-sm'>
|
||||
<div className='text-muted-foreground text-xs'>Success rate</div>
|
||||
<div className='mt-1 font-[440] text-[22px] leading-6'>
|
||||
{aggregate.successRate.toFixed(1)}%
|
||||
</div>
|
||||
</div>
|
||||
<div className='rounded-[12px] border bg-card p-4 shadow-sm'>
|
||||
<div className='rounded-[11px] border bg-card p-4 shadow-sm'>
|
||||
<div className='text-muted-foreground text-xs'>Failed executions</div>
|
||||
<div className='mt-1 font-[440] text-[22px] leading-6'>
|
||||
{aggregate.failedExecutions.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className='rounded-[12px] border bg-card p-4 shadow-sm'>
|
||||
<div className='rounded-[11px] border bg-card p-4 shadow-sm'>
|
||||
<div className='text-muted-foreground text-xs'>Active workflows</div>
|
||||
<div className='mt-1 font-[440] text-[22px] leading-6'>{aggregate.activeWorkflows}</div>
|
||||
</div>
|
||||
|
||||
@@ -174,55 +174,48 @@ export function LineChart({
|
||||
ref={containerRef}
|
||||
className='w-full overflow-hidden rounded-[11px] border bg-card p-4 shadow-sm'
|
||||
>
|
||||
<div className='mb-3 flex items-center justify-between'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<h4 className='font-medium text-foreground text-sm'>{label}</h4>
|
||||
{allSeries.length > 1 && (
|
||||
<div className='flex items-center gap-2'>
|
||||
{scaledSeries.slice(1).map((s) => {
|
||||
const isActive = activeSeriesId ? activeSeriesId === s.id : true
|
||||
const isHovered = hoverSeriesId === s.id
|
||||
const dimmed = activeSeriesId ? !isActive : false
|
||||
return (
|
||||
<button
|
||||
key={`legend-${s.id}`}
|
||||
type='button'
|
||||
aria-pressed={activeSeriesId === s.id}
|
||||
aria-label={`Toggle ${s.label}`}
|
||||
className='inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px]'
|
||||
style={{
|
||||
color: s.color,
|
||||
opacity: dimmed ? 0.4 : isHovered ? 1 : 0.9,
|
||||
border: '1px solid hsl(var(--border))',
|
||||
background: 'transparent',
|
||||
}}
|
||||
onMouseEnter={() => setHoverSeriesId(s.id || null)}
|
||||
onMouseLeave={() => setHoverSeriesId((prev) => (prev === s.id ? null : prev))}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
setActiveSeriesId((prev) => (prev === s.id ? null : s.id || null))
|
||||
}
|
||||
}}
|
||||
onClick={() =>
|
||||
<div className='mb-3 flex items-center gap-3'>
|
||||
<h4 className='font-medium text-foreground text-sm'>{label}</h4>
|
||||
{allSeries.length > 1 && (
|
||||
<div className='flex items-center gap-2'>
|
||||
{scaledSeries.slice(1).map((s) => {
|
||||
const isActive = activeSeriesId ? activeSeriesId === s.id : true
|
||||
const isHovered = hoverSeriesId === s.id
|
||||
const dimmed = activeSeriesId ? !isActive : false
|
||||
return (
|
||||
<button
|
||||
key={`legend-${s.id}`}
|
||||
type='button'
|
||||
aria-pressed={activeSeriesId === s.id}
|
||||
aria-label={`Toggle ${s.label}`}
|
||||
className='inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px]'
|
||||
style={{
|
||||
color: s.color,
|
||||
opacity: dimmed ? 0.4 : isHovered ? 1 : 0.9,
|
||||
border: '1px solid hsl(var(--border))',
|
||||
background: 'transparent',
|
||||
}}
|
||||
onMouseEnter={() => setHoverSeriesId(s.id || null)}
|
||||
onMouseLeave={() => setHoverSeriesId((prev) => (prev === s.id ? null : prev))}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
setActiveSeriesId((prev) => (prev === s.id ? null : s.id || null))
|
||||
}
|
||||
>
|
||||
<span
|
||||
aria-hidden='true'
|
||||
className='inline-block h-[6px] w-[6px] rounded-full'
|
||||
style={{ backgroundColor: s.color }}
|
||||
/>
|
||||
<span style={{ color: 'hsl(var(--muted-foreground))' }}>{s.label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{currentHoverDate ? (
|
||||
<div className='text-[10px] text-muted-foreground'>{currentHoverDate}</div>
|
||||
) : null}
|
||||
}}
|
||||
onClick={() => setActiveSeriesId((prev) => (prev === s.id ? null : s.id || null))}
|
||||
>
|
||||
<span
|
||||
aria-hidden='true'
|
||||
className='inline-block h-[6px] w-[6px] rounded-full'
|
||||
style={{ backgroundColor: s.color }}
|
||||
/>
|
||||
<span style={{ color: 'hsl(var(--muted-foreground))' }}>{s.label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className='relative' style={{ width, height }}>
|
||||
<svg
|
||||
@@ -556,6 +549,9 @@ export function LineChart({
|
||||
className='pointer-events-none absolute rounded-md bg-background/80 px-2 py-1 font-medium text-[11px] shadow-sm ring-1 ring-border backdrop-blur'
|
||||
style={{ left, top }}
|
||||
>
|
||||
{currentHoverDate && (
|
||||
<div className='mb-1 text-[10px] text-muted-foreground'>{currentHoverDate}</div>
|
||||
)}
|
||||
{toDisplay.map((s) => {
|
||||
const seriesIndex = allSeries.findIndex((x) => x.id === s.id)
|
||||
const val = allSeries[seriesIndex]?.data?.[hoverIndex]?.value
|
||||
|
||||
@@ -2,13 +2,20 @@ import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { ArrowUpRight, Info, Loader2 } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { highlight, languages } from 'prismjs'
|
||||
import 'prismjs/components/prism-javascript'
|
||||
import 'prismjs/components/prism-python'
|
||||
import 'prismjs/components/prism-json'
|
||||
import { CopyButton } from '@/components/ui/copy-button'
|
||||
import { cn } from '@/lib/utils'
|
||||
import LineChart, {
|
||||
type LineChartPoint,
|
||||
} from '@/app/workspace/[workspaceId]/logs/components/dashboard/line-chart'
|
||||
import { getTriggerColor } from '@/app/workspace/[workspaceId]/logs/components/dashboard/utils'
|
||||
import LogMarkdownRenderer from '@/app/workspace/[workspaceId]/logs/components/sidebar/components/markdown-renderer'
|
||||
import { formatDate } from '@/app/workspace/[workspaceId]/logs/utils'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import '@/components/emcn/components/code/code.css'
|
||||
|
||||
export interface ExecutionLogItem {
|
||||
id: string
|
||||
@@ -31,6 +38,27 @@ export interface ExecutionLogItem {
|
||||
hasPendingPause?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to parse a string as JSON and prettify it
|
||||
*/
|
||||
const tryPrettifyJson = (content: string): { isJson: boolean; formatted: string } => {
|
||||
try {
|
||||
const trimmed = content.trim()
|
||||
if (
|
||||
!(trimmed.startsWith('{') || trimmed.startsWith('[')) ||
|
||||
!(trimmed.endsWith('}') || trimmed.endsWith(']'))
|
||||
) {
|
||||
return { isJson: false, formatted: content }
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(trimmed)
|
||||
const prettified = JSON.stringify(parsed, null, 2)
|
||||
return { isJson: true, formatted: prettified }
|
||||
} catch (_e) {
|
||||
return { isJson: false, formatted: content }
|
||||
}
|
||||
}
|
||||
|
||||
export interface WorkflowDetailsData {
|
||||
errorRates: LineChartPoint[]
|
||||
durations?: LineChartPoint[]
|
||||
@@ -50,6 +78,9 @@ export function WorkflowDetails({
|
||||
details,
|
||||
selectedSegmentIndex,
|
||||
selectedSegment,
|
||||
selectedSegmentTimeRange,
|
||||
selectedWorkflowNames,
|
||||
segmentDurationMs,
|
||||
clearSegmentSelection,
|
||||
formatCost,
|
||||
onLoadMore,
|
||||
@@ -63,6 +94,9 @@ export function WorkflowDetails({
|
||||
details: WorkflowDetailsData | undefined
|
||||
selectedSegmentIndex: number[] | null
|
||||
selectedSegment: { timestamp: string; totalExecutions: number } | null
|
||||
selectedSegmentTimeRange?: { start: Date; end: Date } | null
|
||||
selectedWorkflowNames?: string[]
|
||||
segmentDurationMs?: number
|
||||
clearSegmentSelection: () => void
|
||||
formatCost: (n: number) => string
|
||||
onLoadMore?: () => void
|
||||
@@ -71,6 +105,12 @@ export function WorkflowDetails({
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const { workflows } = useWorkflowRegistry()
|
||||
|
||||
// Check if any logs have pending status to show Resume column
|
||||
const hasPendingExecutions = useMemo(() => {
|
||||
return details?.logs?.some((log) => log.hasPendingPause === true) || false
|
||||
}, [details])
|
||||
|
||||
const workflowColor = useMemo(
|
||||
() => workflows[expandedWorkflowId]?.color || '#3972F6',
|
||||
[workflows, expandedWorkflowId]
|
||||
@@ -122,29 +162,111 @@ export function WorkflowDetails({
|
||||
<div className='border-b bg-muted/30 px-4 py-2.5'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<button
|
||||
onClick={() => router.push(`/workspace/${workspaceId}/w/${expandedWorkflowId}`)}
|
||||
className='group inline-flex items-center gap-2 text-left'
|
||||
>
|
||||
<span
|
||||
className='h-[14px] w-[14px] flex-shrink-0 rounded'
|
||||
style={{ backgroundColor: workflowColor }}
|
||||
/>
|
||||
<span className='font-[480] text-sm tracking-tight group-hover:text-primary dark:font-[560]'>
|
||||
{workflowName}
|
||||
</span>
|
||||
</button>
|
||||
{expandedWorkflowId !== 'all' && expandedWorkflowId !== '__multi__' ? (
|
||||
<button
|
||||
onClick={() => router.push(`/workspace/${workspaceId}/w/${expandedWorkflowId}`)}
|
||||
className='group inline-flex items-center gap-2 text-left transition-opacity hover:opacity-70'
|
||||
>
|
||||
<span
|
||||
className='h-[14px] w-[14px] flex-shrink-0 rounded'
|
||||
style={{ backgroundColor: workflowColor }}
|
||||
/>
|
||||
<span className='font-[480] text-sm tracking-tight dark:font-[560]'>
|
||||
{workflowName}
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
<div className='inline-flex items-center gap-2'>
|
||||
<span
|
||||
className='h-[14px] w-[14px] flex-shrink-0 rounded'
|
||||
style={{ backgroundColor: workflowColor }}
|
||||
/>
|
||||
<span className='font-[480] text-sm tracking-tight dark:font-[560]'>
|
||||
{workflowName}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{Array.isArray(selectedSegmentIndex) &&
|
||||
selectedSegmentIndex.length > 0 &&
|
||||
(selectedSegment || selectedSegmentTimeRange || expandedWorkflowId === '__multi__') &&
|
||||
(() => {
|
||||
let tsLabel = 'Selected segment'
|
||||
if (selectedSegmentTimeRange) {
|
||||
const start = selectedSegmentTimeRange.start
|
||||
const end = selectedSegmentTimeRange.end
|
||||
const startFormatted = start.toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true,
|
||||
})
|
||||
const endFormatted = end.toLocaleString('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true,
|
||||
})
|
||||
tsLabel = `${startFormatted} – ${endFormatted}`
|
||||
} else if (selectedSegment?.timestamp) {
|
||||
const tsObj = new Date(selectedSegment.timestamp)
|
||||
if (!Number.isNaN(tsObj.getTime())) {
|
||||
tsLabel = tsObj.toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const isMultiWorkflow =
|
||||
expandedWorkflowId === '__multi__' &&
|
||||
selectedWorkflowNames &&
|
||||
selectedWorkflowNames.length > 0
|
||||
const workflowLabel = isMultiWorkflow
|
||||
? selectedWorkflowNames.length <= 2
|
||||
? selectedWorkflowNames.join(', ')
|
||||
: `${selectedWorkflowNames.slice(0, 2).join(', ')} +${selectedWorkflowNames.length - 2}`
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className='inline-flex h-7 items-center gap-1.5 rounded-md border bg-muted/50 px-2.5'>
|
||||
{isMultiWorkflow && workflowLabel && (
|
||||
<span className='font-medium text-[11px] text-muted-foreground'>
|
||||
{workflowLabel}
|
||||
</span>
|
||||
)}
|
||||
<span className='font-medium text-[11px] text-foreground'>
|
||||
{tsLabel}
|
||||
{selectedSegmentIndex.length > 1 && !isMultiWorkflow
|
||||
? ` (+${selectedSegmentIndex.length - 1})`
|
||||
: ''}
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
clearSegmentSelection()
|
||||
}}
|
||||
className='ml-0.5 flex h-4 w-4 items-center justify-center rounded text-muted-foreground text-xs transition-colors hover:bg-muted hover:text-foreground focus:outline-none focus:ring-1 focus:ring-primary/40'
|
||||
aria-label='Clear filter'
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<div className='inline-flex h-7 items-center gap-2 rounded-[10px] border px-2.5'>
|
||||
<div className='inline-flex h-7 items-center gap-2 rounded border px-2.5'>
|
||||
<span className='text-[11px] text-muted-foreground'>Executions</span>
|
||||
<span className='font-[500] text-sm leading-none'>{overview.total}</span>
|
||||
</div>
|
||||
<div className='inline-flex h-7 items-center gap-2 rounded-[10px] border px-2.5'>
|
||||
<div className='inline-flex h-7 items-center gap-2 rounded border px-2.5'>
|
||||
<span className='text-[11px] text-muted-foreground'>Success</span>
|
||||
<span className='font-[500] text-sm leading-none'>{overview.rate.toFixed(1)}%</span>
|
||||
</div>
|
||||
<div className='inline-flex h-7 items-center gap-2 rounded-[10px] border px-2.5'>
|
||||
<div className='inline-flex h-7 items-center gap-2 rounded border px-2.5'>
|
||||
<span className='text-[11px] text-muted-foreground'>Failures</span>
|
||||
<span className='font-[500] text-sm leading-none'>{overview.failures}</span>
|
||||
</div>
|
||||
@@ -154,53 +276,14 @@ export function WorkflowDetails({
|
||||
<div className='p-4'>
|
||||
{details ? (
|
||||
<>
|
||||
{Array.isArray(selectedSegmentIndex) &&
|
||||
selectedSegmentIndex.length > 0 &&
|
||||
selectedSegment &&
|
||||
(() => {
|
||||
const tsObj = selectedSegment?.timestamp
|
||||
? new Date(selectedSegment.timestamp)
|
||||
: null
|
||||
const tsLabel =
|
||||
tsObj && !Number.isNaN(tsObj.getTime())
|
||||
? tsObj.toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true,
|
||||
})
|
||||
: 'Selected segment'
|
||||
return (
|
||||
<div className='mb-4 flex items-center justify-between rounded-[10px] border bg-muted/30 px-3 py-2 text-[13px] text-foreground'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<div className='h-1.5 w-1.5 rounded-full bg-primary ring-2 ring-primary/30' />
|
||||
<span className='font-medium'>
|
||||
Filtered to {tsLabel}
|
||||
{selectedSegmentIndex.length > 1
|
||||
? ` (+${selectedSegmentIndex.length - 1} more segment${selectedSegmentIndex.length - 1 > 1 ? 's' : ''})`
|
||||
: ''}
|
||||
— {selectedSegment.totalExecutions} execution
|
||||
{selectedSegment.totalExecutions !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={clearSegmentSelection}
|
||||
className='rounded px-2 py-1 text-foreground text-xs hover:bg-muted focus:outline-none focus:ring-2 focus:ring-primary/40'
|
||||
>
|
||||
Clear filter
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
{(() => {
|
||||
const hasDuration = Array.isArray(details.durations) && details.durations.length > 0
|
||||
const gridCols = hasDuration
|
||||
? 'md:grid-cols-2 xl:grid-cols-4'
|
||||
: 'md:grid-cols-2 xl:grid-cols-3'
|
||||
const gridGap = hasDuration ? 'gap-2 xl:gap-2.5' : 'gap-3'
|
||||
return (
|
||||
<div className={`mb-3 grid grid-cols-1 gap-3 ${gridCols}`}>
|
||||
<div className={`mb-3 grid grid-cols-1 ${gridGap} ${gridCols}`}>
|
||||
<LineChart
|
||||
data={details.errorRates}
|
||||
label='Error Rate'
|
||||
@@ -264,8 +347,15 @@ export function WorkflowDetails({
|
||||
<div className='flex flex-1 flex-col overflow-hidden'>
|
||||
<div className='w-full overflow-x-auto'>
|
||||
<div>
|
||||
<div className='border-border border-b'>
|
||||
<div className='grid min-w-[980px] grid-cols-[140px_90px_90px_90px_180px_1fr_100px_40px] gap-2 px-2 pb-3 md:gap-3 lg:min-w-0 lg:gap-4'>
|
||||
<div className='border-b-0'>
|
||||
<div
|
||||
className={cn(
|
||||
'grid min-w-[980px] gap-2 px-2 pb-3 md:gap-3 lg:min-w-0 lg:gap-4',
|
||||
hasPendingExecutions
|
||||
? 'grid-cols-[140px_90px_90px_90px_180px_1fr_100px_40px]'
|
||||
: 'grid-cols-[140px_90px_90px_90px_180px_1fr_100px]'
|
||||
)}
|
||||
>
|
||||
<div className='font-[460] font-sans text-[13px] text-muted-foreground leading-normal'>
|
||||
Time
|
||||
</div>
|
||||
@@ -287,9 +377,11 @@ export function WorkflowDetails({
|
||||
<div className='text-right font-[480] font-sans text-[13px] text-muted-foreground leading-normal'>
|
||||
Duration
|
||||
</div>
|
||||
<div className='text-right font-[480] font-sans text-[13px] text-muted-foreground leading-normal'>
|
||||
Resume
|
||||
</div>
|
||||
{hasPendingExecutions && (
|
||||
<div className='text-right font-[480] font-sans text-[13px] text-muted-foreground leading-normal'>
|
||||
Resume
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -333,14 +425,21 @@ export function WorkflowDetails({
|
||||
<div
|
||||
key={log.id}
|
||||
className={cn(
|
||||
'cursor-pointer border-border border-b transition-all duration-200',
|
||||
'cursor-pointer transition-all duration-200',
|
||||
isExpanded ? 'bg-accent/30' : 'hover:bg-accent/20'
|
||||
)}
|
||||
onClick={() =>
|
||||
setExpandedRowId((prev) => (prev === log.id ? null : log.id))
|
||||
}
|
||||
>
|
||||
<div className='grid min-w-[980px] grid-cols-[140px_90px_90px_90px_180px_1fr_100px_40px] items-center gap-2 px-2 py-3 md:gap-3 lg:min-w-0 lg:gap-4'>
|
||||
<div
|
||||
className={cn(
|
||||
'grid min-w-[980px] items-center gap-2 px-2 py-3 md:gap-3 lg:min-w-0 lg:gap-4',
|
||||
hasPendingExecutions
|
||||
? 'grid-cols-[140px_90px_90px_90px_180px_1fr_100px_40px]'
|
||||
: 'grid-cols-[140px_90px_90px_90px_180px_1fr_100px]'
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
<div className='text-[13px]'>
|
||||
<span className='font-sm text-muted-foreground'>
|
||||
@@ -356,34 +455,40 @@ export function WorkflowDetails({
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div
|
||||
className={cn(
|
||||
'inline-flex items-center rounded-[8px] px-[6px] py-[2px] font-[400] text-xs transition-all duration-200 lg:px-[8px]',
|
||||
isError
|
||||
? 'bg-red-500 text-white'
|
||||
: isPending
|
||||
? 'bg-amber-300 text-amber-900 dark:bg-amber-500/90 dark:text-black'
|
||||
: 'bg-secondary text-card-foreground'
|
||||
)}
|
||||
>
|
||||
{statusLabel}
|
||||
</div>
|
||||
{isError || !isPending ? (
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-[24px] w-[56px] items-center justify-start rounded-[6px] border pl-[9px]',
|
||||
isError
|
||||
? 'gap-[5px] border-[#883827] bg-[#491515]'
|
||||
: 'gap-[8px] border-[#686868] bg-[#383838]'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className='h-[6px] w-[6px] rounded-[2px]'
|
||||
style={{
|
||||
backgroundColor: isError ? '#EF4444' : '#B7B7B7',
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
className='font-medium text-[11.5px]'
|
||||
style={{ color: isError ? '#EF4444' : '#B7B7B7' }}
|
||||
>
|
||||
{statusLabel}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className='inline-flex items-center bg-amber-300 px-[6px] py-[2px] font-[400] text-amber-900 text-xs dark:bg-amber-500/90 dark:text-black'>
|
||||
{statusLabel}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{log.trigger ? (
|
||||
<div
|
||||
className={cn(
|
||||
'inline-flex items-center rounded-[8px] px-[6px] py-[2px] font-[400] text-xs transition-all duration-200 lg:px-[8px]',
|
||||
log.trigger.toLowerCase() === 'manual'
|
||||
? 'bg-secondary text-card-foreground'
|
||||
: 'text-white'
|
||||
)}
|
||||
style={
|
||||
log.trigger.toLowerCase() === 'manual'
|
||||
? undefined
|
||||
: { backgroundColor: getTriggerColor(log.trigger) }
|
||||
}
|
||||
className='inline-flex items-center rounded-[6px] px-[6px] py-[2px] font-[400] text-white text-xs lg:px-[8px]'
|
||||
style={{ backgroundColor: getTriggerColor(log.trigger) }}
|
||||
>
|
||||
{log.trigger}
|
||||
</div>
|
||||
@@ -403,7 +508,7 @@ export function WorkflowDetails({
|
||||
{log.workflowName ? (
|
||||
<div className='inline-flex items-center gap-2'>
|
||||
<span
|
||||
className='h-3.5 w-3.5 rounded'
|
||||
className='h-3.5 w-3.5 flex-shrink-0 rounded'
|
||||
style={{ backgroundColor: log.workflowColor || '#64748b' }}
|
||||
/>
|
||||
<span
|
||||
@@ -437,26 +542,49 @@ export function WorkflowDetails({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex justify-end'>
|
||||
{isPending && log.executionId ? (
|
||||
<Link
|
||||
href={`/resume/${expandedWorkflowId}/${log.executionId}`}
|
||||
className='inline-flex h-7 w-7 items-center justify-center rounded-md border border-primary/60 border-dashed text-primary hover:bg-primary/10'
|
||||
aria-label='Open resume console'
|
||||
>
|
||||
<ArrowUpRight className='h-4 w-4' />
|
||||
</Link>
|
||||
) : (
|
||||
<span className='h-7 w-7' />
|
||||
)}
|
||||
</div>
|
||||
{hasPendingExecutions && (
|
||||
<div className='flex justify-end'>
|
||||
{isPending && log.executionId ? (
|
||||
<Link
|
||||
href={`/resume/${expandedWorkflowId}/${log.executionId}`}
|
||||
className='inline-flex h-7 w-7 items-center justify-center border border-primary/60 border-dashed text-primary hover:bg-primary/10'
|
||||
aria-label='Open resume console'
|
||||
>
|
||||
<ArrowUpRight className='h-4 w-4' />
|
||||
</Link>
|
||||
) : (
|
||||
<span className='h-7 w-7' />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div className='px-2 pt-0 pb-4'>
|
||||
<div className='rounded-md border bg-muted/30 p-2'>
|
||||
<pre className='max-h-60 overflow-auto whitespace-pre-wrap break-words text-xs'>
|
||||
{log.level === 'error' && errorStr ? errorStr : outputsStr}
|
||||
</pre>
|
||||
<div className='group relative w-full rounded-[4px] border border-[var(--border-strong)] bg-[#1F1F1F] p-3'>
|
||||
<CopyButton
|
||||
text={log.level === 'error' && errorStr ? errorStr : outputsStr}
|
||||
className='z-10 h-7 w-7'
|
||||
/>
|
||||
{(() => {
|
||||
const content =
|
||||
log.level === 'error' && errorStr ? errorStr : outputsStr
|
||||
const { isJson, formatted } = tryPrettifyJson(content)
|
||||
|
||||
return isJson ? (
|
||||
<div className='code-editor-theme'>
|
||||
<pre
|
||||
className='max-h-[300px] w-full overflow-y-auto overflow-x-hidden whitespace-pre-wrap break-all font-mono text-[#eeeeee] text-[11px] leading-[16px]'
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: highlight(formatted, languages.json, 'json'),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className='max-h-[300px] overflow-y-auto'>
|
||||
<LogMarkdownRenderer content={formatted} />
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -59,7 +59,7 @@ export function WorkflowsList({
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className='overflow-hidden rounded-lg border bg-card shadow-sm'
|
||||
className='overflow-hidden rounded-[11px] border bg-card shadow-sm'
|
||||
style={{ height: '380px', display: 'flex', flexDirection: 'column' }}
|
||||
>
|
||||
<div className='flex-shrink-0 border-b bg-muted/30 px-4 py-2'>
|
||||
@@ -89,7 +89,7 @@ export function WorkflowsList({
|
||||
return (
|
||||
<div
|
||||
key={workflow.workflowId}
|
||||
className={`flex cursor-pointer items-center gap-4 rounded-lg px-2 py-1.5 transition-colors ${
|
||||
className={`flex cursor-pointer items-center gap-4 px-2 py-1.5 transition-colors ${
|
||||
isSelected ? 'bg-accent/40' : 'hover:bg-accent/20'
|
||||
}`}
|
||||
onClick={() => onToggleWorkflow(workflow.workflowId)}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Check, ChevronDown } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Button } from '@/components/emcn'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
@@ -22,7 +22,8 @@ import {
|
||||
filterButtonClass,
|
||||
folderDropdownListStyle,
|
||||
} from '@/app/workspace/[workspaceId]/logs/components/filters/components/shared'
|
||||
import { useFolderStore } from '@/stores/folders/store'
|
||||
import { useFolders } from '@/hooks/queries/folders'
|
||||
import { type FolderTreeNode, useFolderStore } from '@/stores/folders/store'
|
||||
import { useFilterStore } from '@/stores/logs/filters/store'
|
||||
|
||||
const logger = createLogger('LogsFolderFilter')
|
||||
@@ -35,58 +36,38 @@ interface FolderOption {
|
||||
}
|
||||
|
||||
export default function FolderFilter() {
|
||||
const triggerRef = useRef<HTMLButtonElement | null>(null)
|
||||
const { folderIds, toggleFolderId, setFolderIds } = useFilterStore()
|
||||
const { getFolderTree, getFolderPath, fetchFolders } = useFolderStore()
|
||||
const { getFolderTree } = useFolderStore()
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
const [folders, setFolders] = useState<FolderOption[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [search, setSearch] = useState('')
|
||||
const { isLoading: foldersLoading } = useFolders(workspaceId)
|
||||
|
||||
// Fetch all available folders from the API
|
||||
useEffect(() => {
|
||||
const fetchFoldersData = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
if (workspaceId) {
|
||||
await fetchFolders(workspaceId)
|
||||
const folderTree = getFolderTree(workspaceId)
|
||||
const folderTree = workspaceId ? getFolderTree(workspaceId) : []
|
||||
|
||||
// Flatten the folder tree and create options with full paths
|
||||
const flattenFolders = (nodes: any[], parentPath = ''): FolderOption[] => {
|
||||
const result: FolderOption[] = []
|
||||
const folders: FolderOption[] = useMemo(() => {
|
||||
const flattenFolders = (nodes: FolderTreeNode[], parentPath = ''): FolderOption[] => {
|
||||
const result: FolderOption[] = []
|
||||
|
||||
for (const node of nodes) {
|
||||
const currentPath = parentPath ? `${parentPath} / ${node.name}` : node.name
|
||||
result.push({
|
||||
id: node.id,
|
||||
name: node.name,
|
||||
color: node.color || '#6B7280',
|
||||
path: currentPath,
|
||||
})
|
||||
for (const node of nodes) {
|
||||
const currentPath = parentPath ? `${parentPath} / ${node.name}` : node.name
|
||||
result.push({
|
||||
id: node.id,
|
||||
name: node.name,
|
||||
color: node.color || '#6B7280',
|
||||
path: currentPath,
|
||||
})
|
||||
|
||||
// Add children recursively
|
||||
if (node.children && node.children.length > 0) {
|
||||
result.push(...flattenFolders(node.children, currentPath))
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
const folderOptions = flattenFolders(folderTree)
|
||||
setFolders(folderOptions)
|
||||
if (node.children && node.children.length > 0) {
|
||||
result.push(...flattenFolders(node.children, currentPath))
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch folders', { error })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
fetchFoldersData()
|
||||
}, [workspaceId, fetchFolders, getFolderTree])
|
||||
return flattenFolders(folderTree)
|
||||
}, [folderTree])
|
||||
|
||||
// Get display text for the dropdown button
|
||||
const getSelectedFoldersText = () => {
|
||||
@@ -111,8 +92,8 @@ export default function FolderFilter() {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button ref={triggerRef} variant='outline' size='sm' className={filterButtonClass}>
|
||||
{loading ? 'Loading folders...' : getSelectedFoldersText()}
|
||||
<Button variant='outline' className={filterButtonClass}>
|
||||
{foldersLoading ? 'Loading folders...' : getSelectedFoldersText()}
|
||||
<ChevronDown className='ml-2 h-4 w-4 text-muted-foreground' />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
@@ -126,7 +107,9 @@ export default function FolderFilter() {
|
||||
<Command>
|
||||
<CommandInput placeholder='Search folders...' onValueChange={(v) => setSearch(v)} />
|
||||
<CommandList className={commandListClass} style={folderDropdownListStyle}>
|
||||
<CommandEmpty>{loading ? 'Loading folders...' : 'No folders found.'}</CommandEmpty>
|
||||
<CommandEmpty>
|
||||
{foldersLoading ? 'Loading folders...' : 'No folders found.'}
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
value='all-folders'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Check, ChevronDown } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Button } from '@/components/emcn'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -28,8 +28,7 @@ export default function Level() {
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='w-full justify-between rounded-[10px] border-[#E5E5E5] bg-[var(--white)] font-normal text-sm dark:border-[#414141] dark:bg-[var(--surface-elevated)]'
|
||||
className='h-8 w-full justify-between border-[#E5E5E5] bg-[var(--white)] font-normal text-sm dark:border-[#414141] dark:bg-[var(--surface-elevated)]'
|
||||
>
|
||||
{getDisplayLabel()}
|
||||
<ChevronDown className='ml-2 h-4 w-4 text-muted-foreground' />
|
||||
@@ -37,7 +36,7 @@ export default function Level() {
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align='start'
|
||||
className='w-[180px] rounded-lg border-[#E5E5E5] bg-[var(--white)] shadow-xs dark:border-[#414141] dark:bg-[var(--surface-elevated)]'
|
||||
className='w-[180px] border-[#E5E5E5] bg-[var(--white)] shadow-xs dark:border-[#414141] dark:bg-[var(--surface-elevated)]'
|
||||
>
|
||||
<DropdownMenuItem
|
||||
key='all'
|
||||
@@ -45,7 +44,7 @@ export default function Level() {
|
||||
e.preventDefault()
|
||||
setLevel('all')
|
||||
}}
|
||||
className='flex cursor-pointer items-center justify-between rounded-md px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50'
|
||||
className='flex cursor-pointer items-center justify-between px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50'
|
||||
>
|
||||
<span>Any status</span>
|
||||
{level === 'all' && <Check className='h-4 w-4 text-muted-foreground' />}
|
||||
@@ -60,7 +59,7 @@ export default function Level() {
|
||||
e.preventDefault()
|
||||
setLevel(levelItem.value)
|
||||
}}
|
||||
className='flex cursor-pointer items-center justify-between rounded-md px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50'
|
||||
className='flex cursor-pointer items-center justify-between px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50'
|
||||
>
|
||||
<div className='flex items-center'>
|
||||
<div className={`mr-2 h-2 w-2 rounded-full ${levelItem.color}`} />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Check, ChevronDown } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Button } from '@/components/emcn'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -37,7 +37,7 @@ export default function Timeline({ variant = 'default' }: TimelineProps = {}) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant='outline' size='sm' className={filterButtonClass}>
|
||||
<Button variant='outline' className={filterButtonClass}>
|
||||
{timeRange}
|
||||
<ChevronDown className='ml-2 h-4 w-4 text-muted-foreground' />
|
||||
</Button>
|
||||
@@ -58,7 +58,7 @@ export default function Timeline({ variant = 'default' }: TimelineProps = {}) {
|
||||
onSelect={() => {
|
||||
setTimeRange('All time')
|
||||
}}
|
||||
className='flex cursor-pointer items-center justify-between rounded-md px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50'
|
||||
className='flex cursor-pointer items-center justify-between px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50'
|
||||
>
|
||||
<span>All time</span>
|
||||
{timeRange === 'All time' && <Check className='h-4 w-4 text-muted-foreground' />}
|
||||
@@ -72,7 +72,7 @@ export default function Timeline({ variant = 'default' }: TimelineProps = {}) {
|
||||
onSelect={() => {
|
||||
setTimeRange(range)
|
||||
}}
|
||||
className='flex cursor-pointer items-center justify-between rounded-md px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50'
|
||||
className='flex cursor-pointer items-center justify-between px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50'
|
||||
>
|
||||
<span>{range}</span>
|
||||
{timeRange === range && <Check className='h-4 w-4 text-muted-foreground' />}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo, useRef, useState } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Check, ChevronDown } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Button } from '@/components/emcn'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
@@ -26,7 +26,6 @@ import type { TriggerType } from '@/stores/logs/filters/types'
|
||||
export default function Trigger() {
|
||||
const { triggers, toggleTrigger, setTriggers } = useFilterStore()
|
||||
const [search, setSearch] = useState('')
|
||||
const triggerRef = useRef<HTMLButtonElement | null>(null)
|
||||
const triggerOptions: { value: TriggerType; label: string; color?: string }[] = [
|
||||
{ value: 'manual', label: 'Manual', color: 'bg-gray-500' },
|
||||
{ value: 'api', label: 'API', color: 'bg-blue-500' },
|
||||
@@ -58,7 +57,7 @@ export default function Trigger() {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button ref={triggerRef} variant='outline' size='sm' className={filterButtonClass}>
|
||||
<Button variant='outline' className={filterButtonClass}>
|
||||
{getSelectedTriggersText()}
|
||||
<ChevronDown className='ml-2 h-4 w-4 text-muted-foreground' />
|
||||
</Button>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Check, ChevronDown } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Button } from '@/components/emcn'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
@@ -33,7 +33,6 @@ interface WorkflowOption {
|
||||
}
|
||||
|
||||
export default function Workflow() {
|
||||
const triggerRef = useRef<HTMLButtonElement | null>(null)
|
||||
const { workflowIds, toggleWorkflowId, setWorkflowIds, folderIds } = useFilterStore()
|
||||
const params = useParams()
|
||||
const workspaceId = params?.workspaceId as string | undefined
|
||||
@@ -91,7 +90,7 @@ export default function Workflow() {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button ref={triggerRef} variant='outline' size='sm' className={filterButtonClass}>
|
||||
<Button variant='outline' className={filterButtonClass}>
|
||||
{loading ? 'Loading workflows...' : getSelectedWorkflowsText()}
|
||||
<ChevronDown className='ml-2 h-4 w-4 text-muted-foreground' />
|
||||
</Button>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
'use client'
|
||||
|
||||
import { TimerOff } from 'lucide-react'
|
||||
import { Button } from '@/components/ui'
|
||||
import { Button } from '@/components/emcn'
|
||||
import { isProd } from '@/lib/environment'
|
||||
import { getSubscriptionStatus } from '@/lib/subscription/helpers'
|
||||
import {
|
||||
FilterSection,
|
||||
FolderFilter,
|
||||
@@ -11,14 +12,14 @@ import {
|
||||
Trigger,
|
||||
Workflow,
|
||||
} from '@/app/workspace/[workspaceId]/logs/components/filters/components'
|
||||
import { useSubscriptionStore } from '@/stores/subscription/store'
|
||||
import { useSubscriptionData } from '@/hooks/queries/subscription'
|
||||
|
||||
/**
|
||||
* Filters component for logs page - includes timeline and other filter options
|
||||
*/
|
||||
export function Filters() {
|
||||
const { getSubscriptionStatus, isLoading } = useSubscriptionStore()
|
||||
const subscription = getSubscriptionStatus()
|
||||
const { data: subscriptionData, isLoading } = useSubscriptionData()
|
||||
const subscription = getSubscriptionStatus(subscriptionData?.data)
|
||||
const isPaid = subscription.isPaid
|
||||
|
||||
const handleUpgradeClick = (e: React.MouseEvent) => {
|
||||
@@ -33,7 +34,7 @@ export function Filters() {
|
||||
<div className='h-full w-60 overflow-auto border-r p-4'>
|
||||
{/* Show retention policy for free users in production only */}
|
||||
{!isLoading && !isPaid && isProd && (
|
||||
<div className='mb-4 overflow-hidden rounded-md border border-border'>
|
||||
<div className='mb-4 overflow-hidden border border-border'>
|
||||
<div className='flex items-center gap-2 border-b bg-background p-3'>
|
||||
<TimerOff className='h-4 w-4 text-muted-foreground' />
|
||||
<span className='font-medium text-sm'>Log Retention Policy</span>
|
||||
@@ -44,9 +45,8 @@ export function Filters() {
|
||||
</p>
|
||||
<div className='mt-2.5'>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='secondary'
|
||||
className='h-8 w-full px-3 py-1.5 text-xs'
|
||||
variant='default'
|
||||
className='h-8 w-full px-3 text-xs'
|
||||
onClick={handleUpgradeClick}
|
||||
>
|
||||
Upgrade Plan
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Maximize2, Minimize2, X } from 'lucide-react'
|
||||
import { Button } from '@/components/emcn'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { FrozenCanvas } from '@/app/workspace/[workspaceId]/logs/components/frozen-canvas/frozen-canvas'
|
||||
@@ -37,7 +37,7 @@ export function FrozenCanvasModal({
|
||||
className={cn(
|
||||
'flex flex-col gap-0 p-0',
|
||||
isFullscreen
|
||||
? 'h-[100vh] max-h-[100vh] w-[100vw] max-w-[100vw] rounded-none'
|
||||
? 'h-[100vh] max-h-[100vh] w-[100vw] max-w-[100vw]'
|
||||
: 'h-[90vh] max-h-[90vh] overflow-hidden sm:max-w-[1100px]'
|
||||
)}
|
||||
hideCloseButton={true}
|
||||
@@ -68,19 +68,14 @@ export function FrozenCanvasModal({
|
||||
</div>
|
||||
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={toggleFullscreen}
|
||||
className='h-[32px] w-[32px] p-0'
|
||||
>
|
||||
<Button variant='ghost' onClick={toggleFullscreen} className='h-[32px] w-[32px] p-0'>
|
||||
{isFullscreen ? (
|
||||
<Minimize2 className='h-[14px] w-[14px]' />
|
||||
) : (
|
||||
<Maximize2 className='h-[14px] w-[14px]' />
|
||||
)}
|
||||
</Button>
|
||||
<Button variant='ghost' size='sm' onClick={onClose} className='h-[32px] w-[32px] p-0'>
|
||||
<Button variant='ghost' onClick={onClose} className='h-[32px] w-[32px] p-0'>
|
||||
<X className='h-[14px] w-[14px]' />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -42,7 +42,7 @@ function ExpandableDataSection({ title, data }: { title: string; data: any }) {
|
||||
{isLargeData && (
|
||||
<button
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
className='rounded-[4px] p-[4px] text-[var(--text-secondary)] transition-colors hover:bg-[var(--border)] hover:text-[var(--text-primary)] dark:text-[var(--text-secondary)] dark:hover:bg-[var(--border)] dark:hover:text-[var(--text-primary)]'
|
||||
className='p-[4px] text-[var(--text-secondary)] transition-colors hover:bg-[var(--border)] hover:text-[var(--text-primary)] dark:text-[var(--text-secondary)] dark:hover:bg-[var(--border)] dark:hover:text-[var(--text-primary)]'
|
||||
title='Expand in modal'
|
||||
>
|
||||
<Maximize2 className='h-[12px] w-[12px]' />
|
||||
@@ -62,7 +62,7 @@ function ExpandableDataSection({ title, data }: { title: string; data: any }) {
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'overflow-y-auto rounded-[8px] bg-[var(--surface-5)] p-[12px] font-mono text-[12px] transition-all duration-200',
|
||||
'overflow-y-auto bg-[var(--surface-5)] p-[12px] font-mono text-[12px] transition-all duration-200',
|
||||
isExpanded ? 'max-h-96' : 'max-h-32'
|
||||
)}
|
||||
>
|
||||
@@ -75,14 +75,14 @@ function ExpandableDataSection({ title, data }: { title: string; data: any }) {
|
||||
{/* Modal for large data */}
|
||||
{isModalOpen && (
|
||||
<div className='fixed inset-0 z-[200] flex items-center justify-center bg-black/50'>
|
||||
<div className='mx-[16px] h-[80vh] w-full max-w-4xl rounded-[14px] border bg-[var(--surface-1)] shadow-lg dark:border-[var(--border)] dark:bg-[var(--surface-1)]'>
|
||||
<div className='mx-[16px] h-[80vh] w-full max-w-4xl border bg-[var(--surface-1)] shadow-lg dark:border-[var(--border)] dark:bg-[var(--surface-1)]'>
|
||||
<div className='flex items-center justify-between border-b p-[16px] dark:border-[var(--border)]'>
|
||||
<h3 className='font-medium text-[15px] text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
|
||||
{title}
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
className='rounded-[4px] p-[4px] text-[var(--text-secondary)] transition-colors hover:bg-[var(--border)] hover:text-[var(--text-primary)] dark:text-[var(--text-secondary)] dark:hover:bg-[var(--border)] dark:hover:text-[var(--text-primary)]'
|
||||
className='p-[4px] text-[var(--text-secondary)] transition-colors hover:bg-[var(--border)] hover:text-[var(--text-primary)] dark:text-[var(--text-secondary)] dark:hover:bg-[var(--border)] dark:hover:text-[var(--text-primary)]'
|
||||
>
|
||||
<X className='h-[14px] w-[14px]' />
|
||||
</button>
|
||||
@@ -194,7 +194,7 @@ function PinnedLogs({
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className='fixed top-[16px] right-[16px] z-[100] max-h-[calc(100vh-8rem)] w-96 overflow-y-auto rounded-[14px] border bg-[var(--surface-1)] shadow-lg dark:border-[var(--border)] dark:bg-[var(--surface-1)]'>
|
||||
<Card className='fixed top-[16px] right-[16px] z-[100] max-h-[calc(100vh-8rem)] w-96 overflow-y-auto border bg-[var(--surface-1)] shadow-lg dark:border-[var(--border)] dark:bg-[var(--surface-1)]'>
|
||||
<CardHeader className='pb-[12px]'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<CardTitle className='flex items-center gap-[8px] text-[15px] text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
|
||||
@@ -217,7 +217,7 @@ function PinnedLogs({
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className='space-y-[16px]'>
|
||||
<div className='rounded-[8px] bg-[var(--surface-5)] p-[16px] text-center'>
|
||||
<div className='bg-[var(--surface-5)] p-[16px] text-center'>
|
||||
<div className='text-[13px] text-[var(--text-secondary)] dark:text-[var(--text-secondary)]'>
|
||||
This block was not executed because the workflow failed before reaching it.
|
||||
</div>
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Loader2, Search, X } from 'lucide-react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { parseQuery } from '@/lib/logs/query-parser'
|
||||
import { SearchSuggestions } from '@/lib/logs/search-suggestions'
|
||||
import { Search, X } from 'lucide-react'
|
||||
import { Button, Popover, PopoverAnchor, PopoverContent } from '@/components/emcn'
|
||||
import { type ParsedFilter, parseQuery } from '@/lib/logs/query-parser'
|
||||
import {
|
||||
type FolderData,
|
||||
SearchSuggestions,
|
||||
type WorkflowData,
|
||||
} from '@/lib/logs/search-suggestions'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useAutocomplete } from '@/app/workspace/[workspaceId]/logs/hooks/use-autocomplete'
|
||||
import { useSearchState } from '@/app/workspace/[workspaceId]/logs/hooks/use-search-state'
|
||||
import { useFolderStore } from '@/stores/folders/store'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
|
||||
interface AutocompleteSearchProps {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
placeholder?: string
|
||||
availableWorkflows?: string[]
|
||||
availableFolders?: string[]
|
||||
className?: string
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}
|
||||
@@ -24,304 +26,307 @@ export function AutocompleteSearch({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = 'Search logs...',
|
||||
availableWorkflows = [],
|
||||
availableFolders = [],
|
||||
className,
|
||||
onOpenChange,
|
||||
}: AutocompleteSearchProps) {
|
||||
const workflows = useWorkflowRegistry((state) => state.workflows)
|
||||
const folders = useFolderStore((state) => state.folders)
|
||||
|
||||
const workflowsData = useMemo<WorkflowData[]>(() => {
|
||||
return Object.values(workflows).map((w) => ({
|
||||
id: w.id,
|
||||
name: w.name,
|
||||
description: w.description,
|
||||
}))
|
||||
}, [workflows])
|
||||
|
||||
const foldersData = useMemo<FolderData[]>(() => {
|
||||
return Object.values(folders).map((f) => ({
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
}))
|
||||
}, [folders])
|
||||
|
||||
const suggestionEngine = useMemo(() => {
|
||||
return new SearchSuggestions(availableWorkflows, availableFolders)
|
||||
}, [availableWorkflows, availableFolders])
|
||||
return new SearchSuggestions(workflowsData, foldersData)
|
||||
}, [workflowsData, foldersData])
|
||||
|
||||
const handleFiltersChange = (filters: ParsedFilter[], textSearch: string) => {
|
||||
const filterStrings = filters.map(
|
||||
(f) => `${f.field}:${f.operator !== '=' ? f.operator : ''}${f.originalValue}`
|
||||
)
|
||||
const fullQuery = [...filterStrings, textSearch].filter(Boolean).join(' ')
|
||||
onChange(fullQuery)
|
||||
}
|
||||
|
||||
const {
|
||||
state,
|
||||
appliedFilters,
|
||||
currentInput,
|
||||
textSearch,
|
||||
isOpen,
|
||||
suggestions,
|
||||
sections,
|
||||
highlightedIndex,
|
||||
highlightedBadgeIndex,
|
||||
inputRef,
|
||||
dropdownRef,
|
||||
handleInputChange,
|
||||
handleCursorChange,
|
||||
handleSuggestionHover,
|
||||
handleSuggestionSelect,
|
||||
handleKeyDown,
|
||||
handleFocus,
|
||||
handleBlur,
|
||||
reset: resetAutocomplete,
|
||||
closeDropdown,
|
||||
} = useAutocomplete({
|
||||
getSuggestions: (inputValue, cursorPos) =>
|
||||
suggestionEngine.getSuggestions(inputValue, cursorPos),
|
||||
generatePreview: (suggestion, inputValue, cursorPos) =>
|
||||
suggestionEngine.generatePreview(suggestion, inputValue, cursorPos),
|
||||
onQueryChange: onChange,
|
||||
validateQuery: (query) => suggestionEngine.validateQuery(query),
|
||||
debounceMs: 100,
|
||||
removeBadge,
|
||||
clearAll,
|
||||
setHighlightedIndex,
|
||||
initializeFromQuery,
|
||||
} = useSearchState({
|
||||
onFiltersChange: handleFiltersChange,
|
||||
getSuggestions: (input) => suggestionEngine.getSuggestions(input),
|
||||
})
|
||||
|
||||
const clearAll = () => {
|
||||
resetAutocomplete()
|
||||
closeDropdown()
|
||||
onChange('')
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus()
|
||||
// Initialize from external value (URL params) - only on mount
|
||||
useEffect(() => {
|
||||
if (value) {
|
||||
const parsed = parseQuery(value)
|
||||
initializeFromQuery(parsed.textSearch, parsed.filters)
|
||||
}
|
||||
}
|
||||
|
||||
const parsedQuery = parseQuery(value)
|
||||
const hasFilters = parsedQuery.filters.length > 0
|
||||
const hasTextSearch = parsedQuery.textSearch.length > 0
|
||||
|
||||
const listboxId = 'logs-search-listbox'
|
||||
const inputId = 'logs-search-input'
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const [dropdownWidth, setDropdownWidth] = useState(500)
|
||||
useEffect(() => {
|
||||
onOpenChange?.(state.isOpen)
|
||||
}, [state.isOpen, onOpenChange])
|
||||
|
||||
useEffect(() => {
|
||||
if (!state.isOpen || state.highlightedIndex < 0) return
|
||||
const container = dropdownRef.current
|
||||
const optionEl = document.getElementById(`${listboxId}-option-${state.highlightedIndex}`)
|
||||
if (container && optionEl) {
|
||||
try {
|
||||
optionEl.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
||||
} catch {
|
||||
optionEl.scrollIntoView({ block: 'nearest' })
|
||||
const measure = () => {
|
||||
if (inputRef.current) {
|
||||
setDropdownWidth(inputRef.current.parentElement?.offsetWidth || 500)
|
||||
}
|
||||
}
|
||||
}, [state.isOpen, state.highlightedIndex])
|
||||
measure()
|
||||
window.addEventListener('resize', measure)
|
||||
return () => window.removeEventListener('resize', measure)
|
||||
}, [])
|
||||
|
||||
const [showSpinner, setShowSpinner] = useState(false)
|
||||
useEffect(() => {
|
||||
if (!state.pendingQuery) {
|
||||
setShowSpinner(false)
|
||||
return
|
||||
onOpenChange?.(isOpen)
|
||||
}, [isOpen, onOpenChange])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || highlightedIndex < 0) return
|
||||
const container = dropdownRef.current
|
||||
const optionEl = container?.querySelector(`[data-index="${highlightedIndex}"]`)
|
||||
if (container && optionEl) {
|
||||
optionEl.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
||||
}
|
||||
const t = setTimeout(() => setShowSpinner(true), 200)
|
||||
return () => clearTimeout(t)
|
||||
}, [state.pendingQuery])
|
||||
}, [isOpen, highlightedIndex])
|
||||
|
||||
const onInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = e.target.value
|
||||
const cursorPos = e.target.selectionStart || 0
|
||||
handleInputChange(newValue, cursorPos)
|
||||
}
|
||||
|
||||
const updateCursorPosition = (element: HTMLInputElement) => {
|
||||
const cursorPos = element.selectionStart || 0
|
||||
handleCursorChange(cursorPos)
|
||||
}
|
||||
|
||||
const removeFilter = (filterToRemove: (typeof parsedQuery.filters)[0]) => {
|
||||
const remainingFilters = parsedQuery.filters.filter(
|
||||
(f) => !(f.field === filterToRemove.field && f.value === filterToRemove.value)
|
||||
)
|
||||
|
||||
const filterStrings = remainingFilters.map(
|
||||
(f) => `${f.field}:${f.operator !== '=' ? f.operator : ''}${f.originalValue}`
|
||||
)
|
||||
|
||||
const newQuery = [...filterStrings, parsedQuery.textSearch].filter(Boolean).join(' ')
|
||||
handleInputChange(newQuery, newQuery.length)
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus()
|
||||
}
|
||||
}
|
||||
const hasFilters = appliedFilters.length > 0
|
||||
const hasTextSearch = textSearch.length > 0
|
||||
const suggestionType =
|
||||
sections.length > 0 ? 'multi-section' : suggestions.length > 0 ? suggestions[0]?.category : null
|
||||
|
||||
return (
|
||||
<div className={cn('relative', className)}>
|
||||
{/* Search Input */}
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex items-center gap-2 rounded-lg border bg-background pr-2 pl-3 transition-all duration-200',
|
||||
'h-9 w-full min-w-[600px] max-w-[800px]',
|
||||
state.isOpen && 'ring-1 ring-ring'
|
||||
)}
|
||||
{/* Search Input with Inline Badges */}
|
||||
<Popover
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setHighlightedIndex(-1)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{showSpinner ? (
|
||||
<Loader2 className='h-4 w-4 flex-shrink-0 animate-spin text-muted-foreground' />
|
||||
) : (
|
||||
<Search className='h-4 w-4 flex-shrink-0 text-muted-foreground' strokeWidth={2} />
|
||||
)}
|
||||
<PopoverAnchor asChild>
|
||||
<div className='relative flex h-9 w-[500px] items-center rounded-[4px] border border-[var(--surface-11)] bg-[var(--surface-6)] transition-colors focus-within:border-[var(--surface-14)] focus-within:ring-1 focus-within:ring-ring hover:border-[var(--surface-14)] dark:bg-[var(--surface-9)] dark:hover:border-[var(--surface-13)]'>
|
||||
{/* Search Icon */}
|
||||
<Search
|
||||
className='ml-2.5 h-4 w-4 flex-shrink-0 text-muted-foreground'
|
||||
strokeWidth={2}
|
||||
/>
|
||||
|
||||
{/* Text display with ghost text */}
|
||||
<div className='relative flex-1 font-[380] font-sans text-base leading-none'>
|
||||
{/* Invisible input for cursor and interactions */}
|
||||
<Input
|
||||
ref={inputRef}
|
||||
id={inputId}
|
||||
placeholder={state.inputValue ? '' : placeholder}
|
||||
value={state.inputValue}
|
||||
onChange={onInputChange}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
onClick={(e) => updateCursorPosition(e.currentTarget)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onSelect={(e) => updateCursorPosition(e.currentTarget)}
|
||||
className='relative z-10 w-full border-0 bg-transparent p-0 font-[380] font-sans text-base text-transparent leading-none placeholder:text-muted-foreground focus-visible:ring-0 focus-visible:ring-offset-0'
|
||||
style={{ background: 'transparent' }}
|
||||
role='combobox'
|
||||
aria-expanded={state.isOpen}
|
||||
aria-controls={state.isOpen ? listboxId : undefined}
|
||||
aria-autocomplete='list'
|
||||
aria-activedescendant={
|
||||
state.isOpen && state.highlightedIndex >= 0
|
||||
? `${listboxId}-option-${state.highlightedIndex}`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Always-visible text overlay */}
|
||||
<div className='pointer-events-none absolute inset-0 flex items-center'>
|
||||
<span className='whitespace-pre font-[380] font-sans text-base leading-none'>
|
||||
<span className='text-foreground'>{state.inputValue}</span>
|
||||
{state.showPreview &&
|
||||
state.previewValue &&
|
||||
state.previewValue !== state.inputValue &&
|
||||
state.inputValue && (
|
||||
<span className='text-muted-foreground/50'>
|
||||
{state.previewValue.slice(state.inputValue.length)}
|
||||
{/* Scrollable container for badges */}
|
||||
<div className='flex flex-1 items-center gap-1.5 overflow-x-auto px-2 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'>
|
||||
{/* Applied Filter Badges */}
|
||||
{appliedFilters.map((filter, index) => (
|
||||
<Button
|
||||
key={`${filter.field}-${filter.value}-${index}`}
|
||||
variant='outline'
|
||||
className={cn(
|
||||
'h-6 flex-shrink-0 gap-1 rounded-[6px] px-2 text-[11px]',
|
||||
highlightedBadgeIndex === index && 'border-white dark:border-white'
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
removeBadge(index)
|
||||
}}
|
||||
>
|
||||
<span className='text-[var(--text-muted)]'>{filter.field}:</span>
|
||||
<span className='text-[var(--text-primary)]'>
|
||||
{filter.operator !== '=' && filter.operator}
|
||||
{filter.originalValue}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<X className='h-3 w-3' />
|
||||
</Button>
|
||||
))}
|
||||
|
||||
{/* Clear all button */}
|
||||
{(hasFilters || hasTextSearch) && (
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-6 w-6 p-0 hover:bg-muted/50'
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault()
|
||||
clearAll()
|
||||
}}
|
||||
>
|
||||
<X className='h-3 w-3' />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{/* Text Search Badge (if present) */}
|
||||
{hasTextSearch && (
|
||||
<Button
|
||||
variant='outline'
|
||||
className='h-6 flex-shrink-0 gap-1 rounded-[6px] px-2 text-[11px]'
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
handleFiltersChange(appliedFilters, '')
|
||||
}}
|
||||
>
|
||||
<span className='text-[var(--text-primary)]'>"{textSearch}"</span>
|
||||
<X className='h-3 w-3' />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Suggestions Dropdown */}
|
||||
{state.isOpen && state.suggestions.length > 0 && (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className='min-w[500px] absolute z-[9999] mt-1 w-full overflow-hidden rounded-md border bg-popover shadow-md'
|
||||
id={listboxId}
|
||||
role='listbox'
|
||||
aria-labelledby={inputId}
|
||||
>
|
||||
<div className='max-h-96 overflow-y-auto py-1'>
|
||||
{state.suggestionType === 'filter-keys' && (
|
||||
<div className='border-border/50 border-b px-3 py-1 font-medium text-muted-foreground/70 text-xs uppercase tracking-wide'>
|
||||
SUGGESTED FILTERS
|
||||
</div>
|
||||
)}
|
||||
{state.suggestionType === 'filter-values' && (
|
||||
<div className='border-border/50 border-b px-3 py-1 font-medium text-muted-foreground/70 text-xs uppercase tracking-wide'>
|
||||
{state.suggestions[0]?.category?.toUpperCase() || 'VALUES'}
|
||||
</div>
|
||||
)}
|
||||
{/* Input - only current typing */}
|
||||
<input
|
||||
ref={inputRef}
|
||||
type='text'
|
||||
placeholder={hasFilters || hasTextSearch ? '' : placeholder}
|
||||
value={currentInput}
|
||||
onChange={(e) => handleInputChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
className='min-w-[100px] flex-1 border-0 bg-transparent font-sans text-foreground text-sm outline-none placeholder:text-[var(--text-muted)]'
|
||||
/>
|
||||
</div>
|
||||
|
||||
{state.suggestions.map((suggestion, index) => (
|
||||
{/* Clear All Button */}
|
||||
{(hasFilters || hasTextSearch) && (
|
||||
<button
|
||||
key={suggestion.id}
|
||||
className={cn(
|
||||
'w-full px-3 py-2 text-left text-sm',
|
||||
'focus:bg-accent focus:text-accent-foreground focus:outline-none',
|
||||
'transition-colors hover:bg-accent hover:text-accent-foreground',
|
||||
index === state.highlightedIndex && 'bg-accent text-accent-foreground'
|
||||
)}
|
||||
onMouseEnter={() => {
|
||||
if (typeof window !== 'undefined' && (window as any).__logsKeyboardNavActive) {
|
||||
return
|
||||
}
|
||||
handleSuggestionHover(index)
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleSuggestionSelect(suggestion)
|
||||
}}
|
||||
id={`${listboxId}-option-${index}`}
|
||||
role='option'
|
||||
aria-selected={index === state.highlightedIndex}
|
||||
>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex-1'>
|
||||
<div className='font-medium text-sm'>{suggestion.label}</div>
|
||||
{suggestion.description && (
|
||||
<div className='mt-0.5 text-muted-foreground text-xs'>
|
||||
{suggestion.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className='ml-4 font-mono text-muted-foreground text-xs'>
|
||||
{suggestion.value}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Active filters as chips */}
|
||||
{hasFilters && (
|
||||
<div className='mt-3 flex flex-wrap items-center gap-2'>
|
||||
<span className='font-medium text-muted-foreground text-xs'>ACTIVE FILTERS:</span>
|
||||
{parsedQuery.filters.map((filter, index) => (
|
||||
<Badge
|
||||
key={`${filter.field}-${filter.value}-${index}`}
|
||||
variant='secondary'
|
||||
className='h-6 border border-border/50 bg-muted/50 font-mono text-muted-foreground text-xs hover:bg-muted'
|
||||
>
|
||||
<span className='mr-1'>{filter.field}:</span>
|
||||
<span>
|
||||
{filter.operator !== '=' && filter.operator}
|
||||
{filter.originalValue}
|
||||
</span>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='ml-1 h-3 w-3 p-0 text-muted-foreground hover:bg-muted/50 hover:text-foreground'
|
||||
onClick={() => removeFilter(filter)}
|
||||
className='mr-2.5 flex h-5 w-5 flex-shrink-0 items-center justify-center text-muted-foreground transition-colors hover:text-foreground'
|
||||
onClick={clearAll}
|
||||
>
|
||||
<X className='h-2.5 w-2.5' />
|
||||
</Button>
|
||||
</Badge>
|
||||
))}
|
||||
{parsedQuery.filters.length > 1 && (
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-6 text-muted-foreground text-xs hover:text-foreground'
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault()
|
||||
const newQuery = parsedQuery.textSearch
|
||||
handleInputChange(newQuery, newQuery.length)
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus()
|
||||
}
|
||||
}}
|
||||
>
|
||||
Clear all
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<X className='h-4 w-4' />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</PopoverAnchor>
|
||||
|
||||
{/* Text search indicator */}
|
||||
{hasTextSearch && (
|
||||
<div className='mt-2 flex items-center gap-2'>
|
||||
<span className='font-medium text-muted-foreground text-xs'>TEXT SEARCH:</span>
|
||||
<Badge variant='outline' className='text-xs'>
|
||||
"{parsedQuery.textSearch}"
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
{/* Dropdown */}
|
||||
<PopoverContent
|
||||
ref={dropdownRef}
|
||||
className='p-0'
|
||||
style={{ width: dropdownWidth }}
|
||||
align='start'
|
||||
sideOffset={4}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className='max-h-96 overflow-y-auto'>
|
||||
{sections.length > 0 ? (
|
||||
// Multi-section layout
|
||||
<div className='py-1'>
|
||||
{/* Show all results (no header) */}
|
||||
{suggestions[0]?.category === 'show-all' && (
|
||||
<button
|
||||
key={suggestions[0].id}
|
||||
data-index={0}
|
||||
className={cn(
|
||||
'w-full px-3 py-1.5 text-left transition-colors focus:outline-none',
|
||||
'hover:bg-[var(--surface-9)] dark:hover:bg-[var(--surface-9)]',
|
||||
highlightedIndex === 0 && 'bg-[var(--surface-9)] dark:bg-[var(--surface-9)]'
|
||||
)}
|
||||
onMouseEnter={() => setHighlightedIndex(0)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault()
|
||||
handleSuggestionSelect(suggestions[0])
|
||||
}}
|
||||
>
|
||||
<div className='text-[13px]'>{suggestions[0].label}</div>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{sections.map((section) => (
|
||||
<div key={section.title}>
|
||||
<div className='border-border/50 border-t px-3 py-1.5 font-medium text-[11px] text-[var(--text-muted)] uppercase tracking-wide'>
|
||||
{section.title}
|
||||
</div>
|
||||
{section.suggestions.map((suggestion) => {
|
||||
if (suggestion.category === 'show-all') return null
|
||||
|
||||
const index = suggestions.indexOf(suggestion)
|
||||
const isHighlighted = index === highlightedIndex
|
||||
|
||||
return (
|
||||
<button
|
||||
key={suggestion.id}
|
||||
data-index={index}
|
||||
className={cn(
|
||||
'w-full px-3 py-1.5 text-left transition-colors focus:outline-none',
|
||||
'hover:bg-[var(--surface-9)] dark:hover:bg-[var(--surface-9)]',
|
||||
isHighlighted && 'bg-[var(--surface-9)] dark:bg-[var(--surface-9)]'
|
||||
)}
|
||||
onMouseEnter={() => setHighlightedIndex(index)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault()
|
||||
handleSuggestionSelect(suggestion)
|
||||
}}
|
||||
>
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<div className='min-w-0 flex-1 truncate text-[13px]'>
|
||||
{suggestion.label}
|
||||
</div>
|
||||
{suggestion.value !== suggestion.label && (
|
||||
<div className='flex-shrink-0 font-mono text-[11px] text-[var(--text-muted)]'>
|
||||
{suggestion.category === 'workflow' ||
|
||||
suggestion.category === 'folder'
|
||||
? `${suggestion.category}:`
|
||||
: ''}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
// Single section layout
|
||||
<div className='py-1'>
|
||||
{suggestionType === 'filters' && (
|
||||
<div className='border-border/50 border-b px-3 py-1.5 font-medium text-[11px] text-[var(--text-muted)] uppercase tracking-wide'>
|
||||
SUGGESTED FILTERS
|
||||
</div>
|
||||
)}
|
||||
|
||||
{suggestions.map((suggestion, index) => (
|
||||
<button
|
||||
key={suggestion.id}
|
||||
data-index={index}
|
||||
className={cn(
|
||||
'w-full px-3 py-1.5 text-left transition-colors focus:outline-none',
|
||||
'hover:bg-[var(--surface-9)] dark:hover:bg-[var(--surface-9)]',
|
||||
index === highlightedIndex &&
|
||||
'bg-[var(--surface-9)] dark:bg-[var(--surface-9)]'
|
||||
)}
|
||||
onMouseEnter={() => setHighlightedIndex(index)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault()
|
||||
handleSuggestionSelect(suggestion)
|
||||
}}
|
||||
>
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<div className='min-w-0 flex-1 text-[13px]'>{suggestion.label}</div>
|
||||
{suggestion.description && (
|
||||
<div className='flex-shrink-0 text-[11px] text-[var(--text-muted)]'>
|
||||
{suggestion.value}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Download, Loader2 } from 'lucide-react'
|
||||
import { ArrowDown, Loader2 } from 'lucide-react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Button } from '@/components/emcn'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { extractWorkspaceIdFromExecutionKey, getViewerUrl } from '@/lib/uploads/utils/file-utils'
|
||||
|
||||
@@ -96,7 +96,6 @@ export function FileDownload({
|
||||
return (
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className={`h-7 px-2 text-xs ${className}`}
|
||||
onClick={handleDownload}
|
||||
disabled={isDownloading}
|
||||
@@ -104,7 +103,7 @@ export function FileDownload({
|
||||
{isDownloading ? (
|
||||
<Loader2 className='h-3 w-3 animate-spin' />
|
||||
) : (
|
||||
<Download className='h-3 w-3' />
|
||||
<ArrowDown className='h-[14px] w-[14px]' />
|
||||
)}
|
||||
{isDownloading ? 'Downloading...' : 'Download'}
|
||||
</Button>
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { ChevronDown, ChevronUp, Eye, Loader2, X } from 'lucide-react'
|
||||
import { Tooltip } from '@/components/emcn'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { highlight, languages } from 'prismjs'
|
||||
import 'prismjs/components/prism-javascript'
|
||||
import 'prismjs/components/prism-python'
|
||||
import 'prismjs/components/prism-json'
|
||||
import { Button, Tooltip } from '@/components/emcn'
|
||||
import { CopyButton } from '@/components/ui/copy-button'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { BASE_EXECUTION_CHARGE } from '@/lib/billing/constants'
|
||||
@@ -15,6 +18,7 @@ import { TraceSpans } from '@/app/workspace/[workspaceId]/logs/components/trace-
|
||||
import { formatDate } from '@/app/workspace/[workspaceId]/logs/utils'
|
||||
import { formatCost } from '@/providers/utils'
|
||||
import type { WorkflowLog } from '@/stores/logs/filters/types'
|
||||
import '@/components/emcn/components/code/code.css'
|
||||
|
||||
interface LogSidebarProps {
|
||||
log: WorkflowLog | null
|
||||
@@ -72,12 +76,17 @@ const formatJsonContent = (content: string, blockInput?: Record<string, any>): R
|
||||
const { isJson, formatted } = tryPrettifyJson(content)
|
||||
|
||||
return (
|
||||
<div className='group relative w-full rounded-md bg-secondary/30 p-3'>
|
||||
<div className='group relative w-full rounded-[4px] border border-[var(--border-strong)] bg-[#1F1F1F] p-3'>
|
||||
<CopyButton text={formatted} className='z-10 h-7 w-7' />
|
||||
{isJson ? (
|
||||
<pre className='max-h-[500px] w-full overflow-y-auto overflow-x-hidden whitespace-pre-wrap break-all text-sm'>
|
||||
{formatted}
|
||||
</pre>
|
||||
<div className='code-editor-theme'>
|
||||
<pre
|
||||
className='max-h-[500px] w-full overflow-y-auto overflow-x-hidden whitespace-pre-wrap break-all font-mono text-[#eeeeee] text-[11px] leading-[16px]'
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: highlight(formatted, languages.json, 'json'),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<LogMarkdownRenderer content={formatted} />
|
||||
)}
|
||||
@@ -123,7 +132,7 @@ const BlockContentDisplay = ({
|
||||
<div className='mb-2 flex space-x-1'>
|
||||
<button
|
||||
onClick={() => setActiveTab('output')}
|
||||
className={`rounded-md px-3 py-1 text-xs transition-colors ${
|
||||
className={`px-3 py-1 text-xs transition-colors ${
|
||||
activeTab === 'output'
|
||||
? 'bg-secondary text-foreground'
|
||||
: 'text-muted-foreground hover:bg-secondary/50'
|
||||
@@ -133,7 +142,7 @@ const BlockContentDisplay = ({
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('input')}
|
||||
className={`rounded-md px-3 py-1 text-xs transition-colors ${
|
||||
className={`px-3 py-1 text-xs transition-colors ${
|
||||
activeTab === 'input'
|
||||
? 'bg-secondary text-foreground'
|
||||
: 'text-muted-foreground hover:bg-secondary/50'
|
||||
@@ -145,14 +154,19 @@ const BlockContentDisplay = ({
|
||||
)}
|
||||
|
||||
{/* Content based on active tab */}
|
||||
<div className='group relative rounded-md bg-secondary/30 p-3'>
|
||||
<div className='group relative rounded-[4px] border border-[var(--border-strong)] bg-[#1F1F1F] p-3'>
|
||||
{activeTab === 'output' ? (
|
||||
<>
|
||||
<CopyButton text={outputString} className='z-10 h-7 w-7' />
|
||||
{isJson ? (
|
||||
<pre className='w-full overflow-y-auto overflow-x-hidden whitespace-pre-wrap break-all text-sm'>
|
||||
{outputString}
|
||||
</pre>
|
||||
<div className='code-editor-theme'>
|
||||
<pre
|
||||
className='w-full overflow-y-auto overflow-x-hidden whitespace-pre-wrap break-all font-mono text-[#eeeeee] text-[11px] leading-[16px]'
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: highlight(outputString, languages.json, 'json'),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<LogMarkdownRenderer content={outputString} />
|
||||
)}
|
||||
@@ -160,9 +174,14 @@ const BlockContentDisplay = ({
|
||||
) : blockInputString ? (
|
||||
<>
|
||||
<CopyButton text={blockInputString} className='z-10 h-7 w-7' />
|
||||
<pre className='w-full overflow-y-auto overflow-x-hidden whitespace-pre-wrap break-all text-sm'>
|
||||
{blockInputString}
|
||||
</pre>
|
||||
<div className='code-editor-theme'>
|
||||
<pre
|
||||
className='w-full overflow-y-auto overflow-x-hidden whitespace-pre-wrap break-all font-mono text-[#eeeeee] text-[11px] leading-[16px]'
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: highlight(blockInputString, languages.json, 'json'),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -323,8 +342,8 @@ export function Sidebar({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`fixed top-[96px] right-[16px] bottom-[16px] z-50 flex transform flex-col rounded-[14px] border bg-[var(--surface-1)] shadow-lg dark:border-[var(--border)] dark:bg-[var(--surface-1)] ${
|
||||
isOpen ? 'translate-x-0' : 'translate-x-[calc(100%+1rem)]'
|
||||
className={`fixed top-[94px] right-0 bottom-0 z-50 flex transform flex-col overflow-hidden border-l bg-[var(--surface-1)] dark:border-[var(--border)] dark:bg-[var(--surface-1)] ${
|
||||
isOpen ? 'translate-x-0' : 'translate-x-full'
|
||||
} ${isDragging ? '' : 'transition-all duration-300 ease-in-out'}`}
|
||||
style={{ width: `${width}px`, minWidth: `${MIN_WIDTH}px` }}
|
||||
aria-label='Log details sidebar'
|
||||
@@ -340,16 +359,15 @@ export function Sidebar({
|
||||
{log && (
|
||||
<>
|
||||
{/* Header */}
|
||||
<div className='flex items-center justify-between px-[12px] pt-[12px] pb-[4px]'>
|
||||
<div className='flex items-center justify-between px-[8px] pt-[14px] pb-[14px]'>
|
||||
<h2 className='font-medium text-[15px] text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
|
||||
Log Details
|
||||
</h2>
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
<div className='flex items-center gap-[4px]'>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-[32px] w-[32px] p-0'
|
||||
onClick={() => hasPrev && handleNavigate(onNavigatePrev!)}
|
||||
disabled={!hasPrev}
|
||||
@@ -364,7 +382,6 @@ export function Sidebar({
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-[32px] w-[32px] p-0'
|
||||
onClick={() => hasNext && handleNavigate(onNavigateNext!)}
|
||||
disabled={!hasNext}
|
||||
@@ -378,7 +395,6 @@ export function Sidebar({
|
||||
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-[32px] w-[32px] p-0'
|
||||
onClick={onClose}
|
||||
aria-label='Close'
|
||||
@@ -389,7 +405,7 @@ export function Sidebar({
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className='flex-1 overflow-hidden px-[12px]'>
|
||||
<div className='flex-1 overflow-hidden px-[8px]'>
|
||||
<ScrollArea className='h-full w-full overflow-y-auto' ref={scrollAreaRef}>
|
||||
<div className='w-full space-y-[16px] pr-[12px] pb-[16px]'>
|
||||
{/* Timestamp */}
|
||||
@@ -409,22 +425,15 @@ export function Sidebar({
|
||||
<h3 className='mb-[4px] font-medium text-[12px] text-[var(--text-tertiary)] dark:text-[var(--text-tertiary)]'>
|
||||
Workflow
|
||||
</h3>
|
||||
<div
|
||||
className='group relative text-[13px]'
|
||||
style={{
|
||||
color: log.workflow.color,
|
||||
}}
|
||||
>
|
||||
<div className='group relative text-[13px]'>
|
||||
<CopyButton text={log.workflow.name} />
|
||||
<div
|
||||
className='inline-flex items-center rounded-[8px] px-[8px] py-[4px] text-[12px]'
|
||||
<span
|
||||
style={{
|
||||
backgroundColor: `${log.workflow.color}20`,
|
||||
color: log.workflow.color,
|
||||
}}
|
||||
>
|
||||
{log.workflow.name}
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -506,7 +515,7 @@ export function Sidebar({
|
||||
{log.files.map((file, index) => (
|
||||
<div
|
||||
key={file.id || index}
|
||||
className='flex items-center justify-between rounded-[8px] border bg-muted/30 p-[8px] dark:border-[var(--border)]'
|
||||
className='flex items-center justify-between border bg-muted/30 p-[8px] dark:border-[var(--border)]'
|
||||
>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<div className='truncate font-medium text-[13px]' title={file.name}>
|
||||
@@ -534,9 +543,8 @@ export function Sidebar({
|
||||
</h3>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => setIsFrozenCanvasOpen(true)}
|
||||
className='w-full justify-start gap-[8px] rounded-[8px] border bg-muted/30 hover:bg-muted/50 dark:border-[var(--border)]'
|
||||
className='h-8 w-full justify-start gap-[8px] border bg-muted/30 hover:bg-muted/50 dark:border-[var(--border)]'
|
||||
>
|
||||
<Eye className='h-[14px] w-[14px]' />
|
||||
View Snapshot
|
||||
@@ -568,7 +576,7 @@ export function Sidebar({
|
||||
<h3 className='mb-[4px] font-medium text-[12px] text-[var(--text-tertiary)] dark:text-[var(--text-tertiary)]'>
|
||||
Tool Calls
|
||||
</h3>
|
||||
<div className='w-full overflow-x-hidden rounded-[8px] bg-secondary/30 p-[12px]'>
|
||||
<div className='w-full overflow-x-hidden bg-secondary/30 p-[12px]'>
|
||||
<ToolCallsDisplay metadata={log.executionData} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -580,7 +588,7 @@ export function Sidebar({
|
||||
<h3 className='mb-[4px] font-medium text-[12px] text-[var(--text-tertiary)] dark:text-[var(--text-tertiary)]'>
|
||||
Cost Breakdown
|
||||
</h3>
|
||||
<div className='overflow-hidden rounded-[8px] border dark:border-[var(--border)]'>
|
||||
<div className='overflow-hidden border dark:border-[var(--border)]'>
|
||||
<div className='space-y-[8px] p-[12px]'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='text-[13px] text-[var(--text-secondary)] dark:text-[var(--text-secondary)]'>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type React from 'react'
|
||||
import { highlight, languages } from 'prismjs'
|
||||
import 'prismjs/components/prism-json'
|
||||
import { transformBlockData } from '@/app/workspace/[workspaceId]/logs/components/trace-spans/utils'
|
||||
import '@/components/emcn/components/code/code.css'
|
||||
|
||||
export function BlockDataDisplay({
|
||||
data,
|
||||
@@ -14,66 +16,11 @@ export function BlockDataDisplay({
|
||||
}) {
|
||||
if (!data) return null
|
||||
|
||||
const renderValue = (value: unknown, key?: string): React.ReactNode => {
|
||||
if (value === null) return <span className='text-muted-foreground italic'>null</span>
|
||||
if (value === undefined) return <span className='text-muted-foreground italic'>undefined</span>
|
||||
|
||||
if (typeof value === 'string') {
|
||||
return <span className='break-all text-emerald-700 dark:text-emerald-400'>"{value}"</span>
|
||||
}
|
||||
|
||||
if (typeof value === 'number') {
|
||||
return <span className='font-mono text-blue-700 dark:text-blue-400'>{value}</span>
|
||||
}
|
||||
|
||||
if (typeof value === 'boolean') {
|
||||
return (
|
||||
<span className='font-mono text-amber-700 dark:text-amber-400'>{value.toString()}</span>
|
||||
)
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) return <span className='text-muted-foreground'>[]</span>
|
||||
return (
|
||||
<div className='space-y-0.5'>
|
||||
<span className='text-muted-foreground'>[</span>
|
||||
<div className='ml-2 space-y-0.5'>
|
||||
{value.map((item, index) => (
|
||||
<div key={index} className='flex min-w-0 gap-1.5'>
|
||||
<span className='flex-shrink-0 font-mono text-slate-600 text-xs dark:text-slate-400'>
|
||||
{index}:
|
||||
</span>
|
||||
<div className='min-w-0 flex-1 overflow-hidden'>{renderValue(item)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<span className='text-muted-foreground'>]</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
const entries = Object.entries(value)
|
||||
if (entries.length === 0) return <span className='text-muted-foreground'>{'{}'}</span>
|
||||
|
||||
return (
|
||||
<div className='space-y-0.5'>
|
||||
{entries.map(([objKey, objValue]) => (
|
||||
<div key={objKey} className='flex min-w-0 gap-1.5'>
|
||||
<span className='flex-shrink-0 font-medium text-indigo-700 dark:text-indigo-400'>
|
||||
{objKey}:
|
||||
</span>
|
||||
<div className='min-w-0 flex-1 overflow-hidden'>{renderValue(objValue, objKey)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return <span>{String(value)}</span>
|
||||
}
|
||||
|
||||
const transformedData = transformBlockData(data, blockType || 'unknown', isInput)
|
||||
const dataToDisplay = transformedData || data
|
||||
|
||||
// Format the data as JSON string
|
||||
const jsonString = JSON.stringify(dataToDisplay, null, 2)
|
||||
|
||||
if (isError && typeof data === 'object' && data !== null && 'error' in data) {
|
||||
const errorData = data as { error: string; [key: string]: unknown }
|
||||
@@ -86,15 +33,25 @@ export function BlockDataDisplay({
|
||||
{transformedData &&
|
||||
Object.keys(transformedData).filter((key) => key !== 'error' && key !== 'success')
|
||||
.length > 0 && (
|
||||
<div className='space-y-0.5'>
|
||||
{Object.entries(transformedData)
|
||||
.filter(([key]) => key !== 'error' && key !== 'success')
|
||||
.map(([key, value]) => (
|
||||
<div key={key} className='flex gap-1.5'>
|
||||
<span className='font-medium text-indigo-700 dark:text-indigo-400'>{key}:</span>
|
||||
{renderValue(value, key)}
|
||||
</div>
|
||||
))}
|
||||
<div className='code-editor-theme'>
|
||||
<pre
|
||||
className='w-full overflow-y-auto overflow-x-hidden whitespace-pre-wrap break-all font-mono text-[#eeeeee] text-[11px] leading-[16px]'
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: highlight(
|
||||
JSON.stringify(
|
||||
Object.fromEntries(
|
||||
Object.entries(transformedData).filter(
|
||||
([key]) => key !== 'error' && key !== 'success'
|
||||
)
|
||||
),
|
||||
null,
|
||||
2
|
||||
),
|
||||
languages.json,
|
||||
'json'
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -102,6 +59,13 @@ export function BlockDataDisplay({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='space-y-1 overflow-hidden text-xs'>{renderValue(transformedData || data)}</div>
|
||||
<div className='code-editor-theme overflow-hidden'>
|
||||
<pre
|
||||
className='w-full overflow-y-auto overflow-x-hidden whitespace-pre-wrap break-all font-mono text-[#eeeeee] text-[11px] leading-[16px]'
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: highlight(jsonString, languages.json, 'json'),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ export function CollapsibleInputOutput({ span, spanId, depth }: CollapsibleInput
|
||||
Input
|
||||
</button>
|
||||
{inputExpanded && (
|
||||
<div className='mb-2 overflow-hidden rounded-md bg-secondary/30 p-3'>
|
||||
<div className='mb-2 overflow-hidden rounded-[4px] border border-[var(--border-strong)] bg-[#1F1F1F] p-3'>
|
||||
<BlockDataDisplay data={span.input} blockType={span.type} isInput={true} />
|
||||
</div>
|
||||
)}
|
||||
@@ -55,7 +55,7 @@ export function CollapsibleInputOutput({ span, spanId, depth }: CollapsibleInput
|
||||
{span.status === 'error' ? 'Error Details' : 'Output'}
|
||||
</button>
|
||||
{outputExpanded && (
|
||||
<div className='mb-2 overflow-hidden rounded-md bg-secondary/30 p-3'>
|
||||
<div className='mb-2 overflow-hidden rounded-[4px] border border-[var(--border-strong)] bg-[#1F1F1F] p-3'>
|
||||
<BlockDataDisplay
|
||||
data={span.output}
|
||||
blockType={span.type}
|
||||
|
||||
@@ -610,7 +610,7 @@ export function TraceSpanItem({
|
||||
})()}
|
||||
{localHoveredPercent != null && (
|
||||
<div
|
||||
className='pointer-events-none absolute inset-y-0 w-px bg-black/30 dark:bg-white/45'
|
||||
className='pointer-events-none absolute inset-y-0 w-px bg-black/30 dark:bg-gray-600'
|
||||
style={{
|
||||
left: `${Math.max(0, Math.min(100, localHoveredPercent))}%`,
|
||||
zIndex: 12,
|
||||
|
||||
@@ -215,10 +215,7 @@ export function TraceSpans({ traceSpans, totalDuration = 0, onExpansionChange }:
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className='relative w-full overflow-hidden rounded-md border shadow-sm'
|
||||
>
|
||||
<div ref={containerRef} className='relative w-full overflow-hidden border shadow-sm'>
|
||||
{filtered.map((span, index) => {
|
||||
const normalizedSpan = normalizeChildWorkflowSpan(span)
|
||||
const hasSubItems = Boolean(
|
||||
|
||||
@@ -606,10 +606,25 @@ export default function Dashboard() {
|
||||
|
||||
setLastAnchorIndices((prev) => ({ ...prev, [workflowId]: segmentIndex }))
|
||||
} else if (mode === 'single') {
|
||||
// Single mode: Clear all selections and select only this segment
|
||||
setExpandedWorkflowId(workflowId)
|
||||
setSelectedSegments({ [workflowId]: [segmentIndex] })
|
||||
setLastAnchorIndices({ [workflowId]: segmentIndex })
|
||||
// Single mode: Select this segment, or deselect if already selected
|
||||
setSelectedSegments((prev) => {
|
||||
const currentSegments = prev[workflowId] || []
|
||||
const isOnlySelectedSegment =
|
||||
currentSegments.length === 1 && currentSegments[0] === segmentIndex
|
||||
const isOnlyWorkflowSelected = Object.keys(prev).length === 1 && prev[workflowId]
|
||||
|
||||
// If this is the only selected segment in the only selected workflow, deselect it
|
||||
if (isOnlySelectedSegment && isOnlyWorkflowSelected) {
|
||||
setExpandedWorkflowId(null)
|
||||
setLastAnchorIndices({})
|
||||
return {}
|
||||
}
|
||||
|
||||
// Otherwise, select only this segment
|
||||
setExpandedWorkflowId(workflowId)
|
||||
setLastAnchorIndices({ [workflowId]: segmentIndex })
|
||||
return { [workflowId]: [segmentIndex] }
|
||||
})
|
||||
} else if (mode === 'range') {
|
||||
// Range mode: Expand selection within the current workflow
|
||||
if (expandedWorkflowId === workflowId) {
|
||||
@@ -810,17 +825,17 @@ export default function Dashboard() {
|
||||
<div className='flex items-center gap-2 text-muted-foreground text-xs'>
|
||||
<span>Filters:</span>
|
||||
{workflowIds.length > 0 && (
|
||||
<span className='inline-flex items-center rounded-md bg-primary/10 px-2 py-0.5 text-primary text-xs'>
|
||||
<span className='inline-flex items-center rounded-[6px] bg-primary/10 px-2 py-0.5 text-primary text-xs'>
|
||||
{workflowIds.length} workflow{workflowIds.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
{folderIds.length > 0 && (
|
||||
<span className='inline-flex items-center rounded-md bg-primary/10 px-2 py-0.5 text-primary text-xs'>
|
||||
<span className='inline-flex items-center rounded-[6px] bg-primary/10 px-2 py-0.5 text-primary text-xs'>
|
||||
{folderIds.length} folder{folderIds.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
{triggers.length > 0 && (
|
||||
<span className='inline-flex items-center rounded-md bg-primary/10 px-2 py-0.5 text-primary text-xs'>
|
||||
<span className='inline-flex items-center rounded-[6px] bg-primary/10 px-2 py-0.5 text-primary text-xs'>
|
||||
{triggers.length} trigger{triggers.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
@@ -987,6 +1002,51 @@ export default function Dashboard() {
|
||||
const totalRate =
|
||||
totalExecutions > 0 ? (totalSuccess / totalExecutions) * 100 : 100
|
||||
|
||||
// Calculate overall time range across all selected workflows
|
||||
let multiWorkflowTimeRange: { start: Date; end: Date } | null = null
|
||||
if (sortedIndices.length > 0) {
|
||||
const firstIdx = sortedIndices[0]
|
||||
const lastIdx = sortedIndices[sortedIndices.length - 1]
|
||||
|
||||
// Find earliest start time
|
||||
let earliestStart: Date | null = null
|
||||
for (const wfId of selectedWorkflowIds) {
|
||||
const wf = executions.find((w) => w.workflowId === wfId)
|
||||
const segment = wf?.segments[firstIdx]
|
||||
if (segment) {
|
||||
const start = new Date(segment.timestamp)
|
||||
if (!earliestStart || start < earliestStart) {
|
||||
earliestStart = start
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Find latest end time
|
||||
let latestEnd: Date | null = null
|
||||
for (const wfId of selectedWorkflowIds) {
|
||||
const wf = executions.find((w) => w.workflowId === wfId)
|
||||
const segment = wf?.segments[lastIdx]
|
||||
if (segment) {
|
||||
const end = new Date(new Date(segment.timestamp).getTime() + segMs)
|
||||
if (!latestEnd || end > latestEnd) {
|
||||
latestEnd = end
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (earliestStart && latestEnd) {
|
||||
multiWorkflowTimeRange = {
|
||||
start: earliestStart,
|
||||
end: latestEnd,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get workflow names
|
||||
const workflowNames = selectedWorkflowIds
|
||||
.map((id) => executions.find((w) => w.workflowId === id)?.workflowName)
|
||||
.filter(Boolean) as string[]
|
||||
|
||||
return (
|
||||
<WorkflowDetails
|
||||
workspaceId={workspaceId}
|
||||
@@ -1007,8 +1067,11 @@ export default function Dashboard() {
|
||||
allLogs: allLogs,
|
||||
} as any
|
||||
}
|
||||
selectedSegmentIndex={[]}
|
||||
selectedSegmentIndex={sortedIndices}
|
||||
selectedSegment={null}
|
||||
selectedSegmentTimeRange={multiWorkflowTimeRange}
|
||||
selectedWorkflowNames={workflowNames}
|
||||
segmentDurationMs={segMs}
|
||||
clearSegmentSelection={() => {
|
||||
setSelectedSegments({})
|
||||
setLastAnchorIndices({})
|
||||
@@ -1121,6 +1184,9 @@ export default function Dashboard() {
|
||||
const idxSet = new Set(workflowSelectedIndices)
|
||||
const selectedSegs = wf.segments.filter((_, i) => idxSet.has(i))
|
||||
;(details as any).__filtered = buildSeriesFromSegments(selectedSegs as any)
|
||||
} else if (details) {
|
||||
// Clear filtered data when no segments are selected
|
||||
;(details as any).__filtered = undefined
|
||||
}
|
||||
|
||||
const detailsWithFilteredLogs = details
|
||||
@@ -1148,6 +1214,28 @@ export default function Dashboard() {
|
||||
? wf.segments[workflowSelectedIndices[0]]
|
||||
: null
|
||||
|
||||
// Calculate time range for selected segments
|
||||
const segMs =
|
||||
(endTime.getTime() - getStartTime().getTime()) / Math.max(1, segmentCount)
|
||||
const selectedSegmentsData = workflowSelectedIndices
|
||||
.map((idx) => wf.segments[idx])
|
||||
.filter(Boolean)
|
||||
const timeRange =
|
||||
selectedSegmentsData.length > 0
|
||||
? (() => {
|
||||
const sortedIndices = [...workflowSelectedIndices].sort((a, b) => a - b)
|
||||
const firstSegment = wf.segments[sortedIndices[0]]
|
||||
const lastSegment = wf.segments[sortedIndices[sortedIndices.length - 1]]
|
||||
if (!firstSegment || !lastSegment) return null
|
||||
const rangeStart = new Date(firstSegment.timestamp)
|
||||
const rangeEnd = new Date(lastSegment.timestamp).getTime() + segMs
|
||||
return {
|
||||
start: rangeStart,
|
||||
end: new Date(rangeEnd),
|
||||
}
|
||||
})()
|
||||
: null
|
||||
|
||||
return (
|
||||
<WorkflowDetails
|
||||
workspaceId={workspaceId}
|
||||
@@ -1164,6 +1252,9 @@ export default function Dashboard() {
|
||||
}
|
||||
: null
|
||||
}
|
||||
selectedSegmentTimeRange={timeRange}
|
||||
selectedWorkflowNames={undefined}
|
||||
segmentDurationMs={segMs}
|
||||
clearSegmentSelection={() => {
|
||||
setSelectedSegments({})
|
||||
setLastAnchorIndices({})
|
||||
@@ -1197,6 +1288,9 @@ export default function Dashboard() {
|
||||
details={globalDetails as any}
|
||||
selectedSegmentIndex={[]}
|
||||
selectedSegment={null}
|
||||
selectedSegmentTimeRange={null}
|
||||
selectedWorkflowNames={undefined}
|
||||
segmentDurationMs={undefined}
|
||||
clearSegmentSelection={() => {
|
||||
setSelectedSegments({})
|
||||
setLastAnchorIndices({})
|
||||
|
||||
@@ -1,423 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react'
|
||||
|
||||
export interface Suggestion {
|
||||
id: string
|
||||
value: string
|
||||
label: string
|
||||
description?: string
|
||||
category?:
|
||||
| 'filters'
|
||||
| 'level'
|
||||
| 'trigger'
|
||||
| 'cost'
|
||||
| 'date'
|
||||
| 'duration'
|
||||
| 'workflow'
|
||||
| 'folder'
|
||||
| 'workflowId'
|
||||
| 'executionId'
|
||||
}
|
||||
|
||||
export interface SuggestionGroup {
|
||||
type: 'filter-keys' | 'filter-values'
|
||||
filterKey?: string
|
||||
suggestions: Suggestion[]
|
||||
}
|
||||
|
||||
interface AutocompleteState {
|
||||
// Input state
|
||||
inputValue: string
|
||||
cursorPosition: number
|
||||
|
||||
// Dropdown state
|
||||
isOpen: boolean
|
||||
suggestions: Suggestion[]
|
||||
suggestionType: 'filter-keys' | 'filter-values' | null
|
||||
highlightedIndex: number
|
||||
|
||||
// Preview state
|
||||
previewValue: string
|
||||
showPreview: boolean
|
||||
|
||||
// Query state
|
||||
isValidQuery: boolean
|
||||
pendingQuery: string | null
|
||||
}
|
||||
|
||||
type AutocompleteAction =
|
||||
| { type: 'SET_INPUT_VALUE'; payload: { value: string; cursorPosition: number } }
|
||||
| { type: 'SET_CURSOR_POSITION'; payload: number }
|
||||
| { type: 'OPEN_DROPDOWN'; payload: SuggestionGroup }
|
||||
| { type: 'CLOSE_DROPDOWN' }
|
||||
| { type: 'HIGHLIGHT_SUGGESTION'; payload: { index: number; preview?: string } }
|
||||
| { type: 'SET_PREVIEW'; payload: { value: string; show: boolean } }
|
||||
| { type: 'CLEAR_PREVIEW' }
|
||||
| { type: 'SET_QUERY_VALIDITY'; payload: boolean }
|
||||
| { type: 'SET_PENDING'; payload: string | null }
|
||||
| { type: 'RESET' }
|
||||
|
||||
const initialState: AutocompleteState = {
|
||||
inputValue: '',
|
||||
cursorPosition: 0,
|
||||
isOpen: false,
|
||||
suggestions: [],
|
||||
suggestionType: null,
|
||||
highlightedIndex: -1,
|
||||
previewValue: '',
|
||||
showPreview: false,
|
||||
isValidQuery: true,
|
||||
pendingQuery: null,
|
||||
}
|
||||
|
||||
function autocompleteReducer(
|
||||
state: AutocompleteState,
|
||||
action: AutocompleteAction
|
||||
): AutocompleteState {
|
||||
switch (action.type) {
|
||||
case 'SET_INPUT_VALUE':
|
||||
return {
|
||||
...state,
|
||||
inputValue: action.payload.value,
|
||||
cursorPosition: action.payload.cursorPosition,
|
||||
previewValue: '',
|
||||
showPreview: false,
|
||||
}
|
||||
|
||||
case 'SET_CURSOR_POSITION':
|
||||
return {
|
||||
...state,
|
||||
cursorPosition: action.payload,
|
||||
}
|
||||
|
||||
case 'OPEN_DROPDOWN':
|
||||
return {
|
||||
...state,
|
||||
isOpen: true,
|
||||
suggestions: action.payload.suggestions,
|
||||
suggestionType: action.payload.type,
|
||||
highlightedIndex: action.payload.suggestions.length > 0 ? 0 : -1,
|
||||
}
|
||||
|
||||
case 'CLOSE_DROPDOWN':
|
||||
return {
|
||||
...state,
|
||||
isOpen: false,
|
||||
suggestions: [],
|
||||
suggestionType: null,
|
||||
highlightedIndex: -1,
|
||||
previewValue: '',
|
||||
showPreview: false,
|
||||
}
|
||||
|
||||
case 'HIGHLIGHT_SUGGESTION':
|
||||
return {
|
||||
...state,
|
||||
highlightedIndex: action.payload.index,
|
||||
previewValue: action.payload.preview || '',
|
||||
showPreview: !!action.payload.preview,
|
||||
}
|
||||
|
||||
case 'SET_PREVIEW':
|
||||
return {
|
||||
...state,
|
||||
previewValue: action.payload.value,
|
||||
showPreview: action.payload.show,
|
||||
}
|
||||
|
||||
case 'CLEAR_PREVIEW':
|
||||
return {
|
||||
...state,
|
||||
previewValue: '',
|
||||
showPreview: false,
|
||||
}
|
||||
|
||||
case 'SET_QUERY_VALIDITY':
|
||||
return {
|
||||
...state,
|
||||
isValidQuery: action.payload,
|
||||
}
|
||||
|
||||
case 'SET_PENDING':
|
||||
return {
|
||||
...state,
|
||||
pendingQuery: action.payload,
|
||||
}
|
||||
|
||||
case 'RESET':
|
||||
return initialState
|
||||
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
export interface AutocompleteOptions {
|
||||
getSuggestions: (value: string, cursorPosition: number) => SuggestionGroup | null
|
||||
generatePreview: (suggestion: Suggestion, currentValue: string, cursorPosition: number) => string
|
||||
onQueryChange: (query: string) => void
|
||||
validateQuery?: (query: string) => boolean
|
||||
debounceMs?: number
|
||||
}
|
||||
|
||||
export function useAutocomplete({
|
||||
getSuggestions,
|
||||
generatePreview,
|
||||
onQueryChange,
|
||||
validateQuery,
|
||||
debounceMs = 150,
|
||||
}: AutocompleteOptions) {
|
||||
const [state, dispatch] = useReducer(autocompleteReducer, initialState)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
const debounceRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const pointerDownInDropdownRef = useRef<boolean>(false)
|
||||
const latestRef = useRef<{ inputValue: string; cursorPosition: number }>({
|
||||
inputValue: '',
|
||||
cursorPosition: 0,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
latestRef.current.inputValue = state.inputValue
|
||||
latestRef.current.cursorPosition = state.cursorPosition
|
||||
}, [state.inputValue, state.cursorPosition])
|
||||
|
||||
const currentSuggestion = useMemo(() => {
|
||||
if (state.highlightedIndex >= 0 && state.suggestions[state.highlightedIndex]) {
|
||||
return state.suggestions[state.highlightedIndex]
|
||||
}
|
||||
return null
|
||||
}, [state.highlightedIndex, state.suggestions])
|
||||
|
||||
const updateSuggestions = useCallback(() => {
|
||||
const { inputValue, cursorPosition } = latestRef.current
|
||||
const suggestionGroup = getSuggestions(inputValue, cursorPosition)
|
||||
|
||||
if (suggestionGroup && suggestionGroup.suggestions.length > 0) {
|
||||
dispatch({ type: 'OPEN_DROPDOWN', payload: suggestionGroup })
|
||||
|
||||
const firstSuggestion = suggestionGroup.suggestions[0]
|
||||
const preview = generatePreview(firstSuggestion, inputValue, cursorPosition)
|
||||
dispatch({
|
||||
type: 'HIGHLIGHT_SUGGESTION',
|
||||
payload: { index: 0, preview },
|
||||
})
|
||||
} else {
|
||||
dispatch({ type: 'CLOSE_DROPDOWN' })
|
||||
}
|
||||
}, [getSuggestions, generatePreview])
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
(value: string, cursorPosition: number) => {
|
||||
dispatch({ type: 'SET_INPUT_VALUE', payload: { value, cursorPosition } })
|
||||
|
||||
const isValid = validateQuery ? validateQuery(value) : true
|
||||
dispatch({ type: 'SET_QUERY_VALIDITY', payload: isValid })
|
||||
|
||||
if (isValid) {
|
||||
onQueryChange(value)
|
||||
}
|
||||
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current)
|
||||
}
|
||||
|
||||
dispatch({ type: 'SET_PENDING', payload: value })
|
||||
debounceRef.current = setTimeout(() => {
|
||||
dispatch({ type: 'SET_PENDING', payload: null })
|
||||
updateSuggestions()
|
||||
}, debounceMs)
|
||||
},
|
||||
[updateSuggestions, onQueryChange, validateQuery, debounceMs]
|
||||
)
|
||||
|
||||
const handleCursorChange = useCallback(
|
||||
(position: number) => {
|
||||
dispatch({ type: 'SET_CURSOR_POSITION', payload: position })
|
||||
updateSuggestions()
|
||||
},
|
||||
[updateSuggestions]
|
||||
)
|
||||
|
||||
const handleSuggestionHover = useCallback(
|
||||
(index: number) => {
|
||||
if (index >= 0 && index < state.suggestions.length) {
|
||||
const suggestion = state.suggestions[index]
|
||||
const preview = generatePreview(suggestion, state.inputValue, state.cursorPosition)
|
||||
dispatch({
|
||||
type: 'HIGHLIGHT_SUGGESTION',
|
||||
payload: { index, preview },
|
||||
})
|
||||
}
|
||||
},
|
||||
[state.suggestions, state.inputValue, state.cursorPosition, generatePreview]
|
||||
)
|
||||
|
||||
const handleSuggestionSelect = useCallback(
|
||||
(suggestion?: Suggestion) => {
|
||||
const selectedSuggestion = suggestion || currentSuggestion
|
||||
if (!selectedSuggestion) return
|
||||
|
||||
let newValue = generatePreview(selectedSuggestion, state.inputValue, state.cursorPosition)
|
||||
|
||||
let newCursorPosition = newValue.length
|
||||
|
||||
if (state.suggestionType === 'filter-keys' && selectedSuggestion.value.endsWith(':')) {
|
||||
newCursorPosition = newValue.lastIndexOf(':') + 1
|
||||
} else if (state.suggestionType === 'filter-values') {
|
||||
newValue = `${newValue} `
|
||||
newCursorPosition = newValue.length
|
||||
}
|
||||
|
||||
dispatch({
|
||||
type: 'SET_INPUT_VALUE',
|
||||
payload: { value: newValue, cursorPosition: newCursorPosition },
|
||||
})
|
||||
|
||||
const isValid = validateQuery ? validateQuery(newValue.trim()) : true
|
||||
dispatch({ type: 'SET_QUERY_VALIDITY', payload: isValid })
|
||||
|
||||
if (isValid) {
|
||||
onQueryChange(newValue.trim())
|
||||
}
|
||||
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus()
|
||||
requestAnimationFrame(() => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.setSelectionRange(newCursorPosition, newCursorPosition)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current)
|
||||
debounceRef.current = null
|
||||
}
|
||||
dispatch({ type: 'SET_PENDING', payload: null })
|
||||
setTimeout(updateSuggestions, 0)
|
||||
},
|
||||
[
|
||||
currentSuggestion,
|
||||
state.inputValue,
|
||||
state.cursorPosition,
|
||||
state.suggestionType,
|
||||
generatePreview,
|
||||
onQueryChange,
|
||||
validateQuery,
|
||||
updateSuggestions,
|
||||
]
|
||||
)
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
if (state.isOpen) {
|
||||
handleSuggestionSelect()
|
||||
} else if (state.isValidQuery) {
|
||||
updateSuggestions()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (!state.isOpen) return
|
||||
|
||||
switch (event.key) {
|
||||
case 'ArrowDown': {
|
||||
event.preventDefault()
|
||||
const nextIndex = Math.min(state.highlightedIndex + 1, state.suggestions.length - 1)
|
||||
handleSuggestionHover(nextIndex)
|
||||
break
|
||||
}
|
||||
|
||||
case 'ArrowUp': {
|
||||
event.preventDefault()
|
||||
const prevIndex = Math.max(state.highlightedIndex - 1, 0)
|
||||
handleSuggestionHover(prevIndex)
|
||||
break
|
||||
}
|
||||
|
||||
case 'Escape':
|
||||
event.preventDefault()
|
||||
dispatch({ type: 'CLOSE_DROPDOWN' })
|
||||
break
|
||||
|
||||
case 'Tab':
|
||||
if (currentSuggestion) {
|
||||
event.preventDefault()
|
||||
handleSuggestionSelect()
|
||||
} else {
|
||||
dispatch({ type: 'CLOSE_DROPDOWN' })
|
||||
}
|
||||
break
|
||||
}
|
||||
},
|
||||
[
|
||||
state.isOpen,
|
||||
state.highlightedIndex,
|
||||
state.suggestions.length,
|
||||
handleSuggestionHover,
|
||||
handleSuggestionSelect,
|
||||
currentSuggestion,
|
||||
]
|
||||
)
|
||||
|
||||
const handleFocus = useCallback(() => {
|
||||
updateSuggestions()
|
||||
}, [updateSuggestions])
|
||||
|
||||
const handleBlur = useCallback((e?: React.FocusEvent) => {
|
||||
const related = (e?.relatedTarget as Node) || document.activeElement
|
||||
const isInsideDropdown = related && dropdownRef.current?.contains(related)
|
||||
const isInsideInput = related && inputRef.current === related
|
||||
if (pointerDownInDropdownRef.current || isInsideDropdown || isInsideInput) {
|
||||
return
|
||||
}
|
||||
setTimeout(() => {
|
||||
dispatch({ type: 'CLOSE_DROPDOWN' })
|
||||
}, 150)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const dropdownEl = dropdownRef.current
|
||||
if (!dropdownEl) return
|
||||
const onPointerDown = () => {
|
||||
pointerDownInDropdownRef.current = true
|
||||
}
|
||||
const onPointerUp = () => {
|
||||
setTimeout(() => {
|
||||
pointerDownInDropdownRef.current = false
|
||||
}, 0)
|
||||
}
|
||||
dropdownEl.addEventListener('pointerdown', onPointerDown)
|
||||
window.addEventListener('pointerup', onPointerUp)
|
||||
return () => {
|
||||
dropdownEl.removeEventListener('pointerdown', onPointerDown)
|
||||
window.removeEventListener('pointerup', onPointerUp)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return {
|
||||
// State
|
||||
state,
|
||||
currentSuggestion,
|
||||
|
||||
// Refs
|
||||
inputRef,
|
||||
dropdownRef,
|
||||
|
||||
// Handlers
|
||||
handleInputChange,
|
||||
handleCursorChange,
|
||||
handleSuggestionHover,
|
||||
handleSuggestionSelect,
|
||||
handleKeyDown,
|
||||
handleFocus,
|
||||
handleBlur,
|
||||
|
||||
// Actions
|
||||
closeDropdown: () => dispatch({ type: 'CLOSE_DROPDOWN' }),
|
||||
clearPreview: () => dispatch({ type: 'CLEAR_PREVIEW' }),
|
||||
reset: () => dispatch({ type: 'RESET' }),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import type { ParsedFilter } from '@/lib/logs/query-parser'
|
||||
import type {
|
||||
Suggestion,
|
||||
SuggestionGroup,
|
||||
SuggestionSection,
|
||||
} from '@/app/workspace/[workspaceId]/logs/types/search'
|
||||
|
||||
interface UseSearchStateOptions {
|
||||
onFiltersChange: (filters: ParsedFilter[], textSearch: string) => void
|
||||
getSuggestions: (input: string) => SuggestionGroup | null
|
||||
debounceMs?: number
|
||||
}
|
||||
|
||||
export function useSearchState({
|
||||
onFiltersChange,
|
||||
getSuggestions,
|
||||
debounceMs = 100,
|
||||
}: UseSearchStateOptions) {
|
||||
const [appliedFilters, setAppliedFilters] = useState<ParsedFilter[]>([])
|
||||
const [currentInput, setCurrentInput] = useState('')
|
||||
const [textSearch, setTextSearch] = useState('')
|
||||
|
||||
// Dropdown state
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [suggestions, setSuggestions] = useState<Suggestion[]>([])
|
||||
const [sections, setSections] = useState<SuggestionSection[]>([])
|
||||
const [highlightedIndex, setHighlightedIndex] = useState(-1)
|
||||
|
||||
// Badge interaction
|
||||
const [highlightedBadgeIndex, setHighlightedBadgeIndex] = useState<number | null>(null)
|
||||
|
||||
// Refs
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
const debounceRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
// Update suggestions when input changes
|
||||
const updateSuggestions = useCallback(
|
||||
(input: string) => {
|
||||
const suggestionGroup = getSuggestions(input)
|
||||
|
||||
if (suggestionGroup && suggestionGroup.suggestions.length > 0) {
|
||||
setSuggestions(suggestionGroup.suggestions)
|
||||
setSections(suggestionGroup.sections || [])
|
||||
setIsOpen(true)
|
||||
setHighlightedIndex(0)
|
||||
} else {
|
||||
setIsOpen(false)
|
||||
setSuggestions([])
|
||||
setSections([])
|
||||
setHighlightedIndex(-1)
|
||||
}
|
||||
},
|
||||
[getSuggestions]
|
||||
)
|
||||
|
||||
// Handle input changes
|
||||
const handleInputChange = useCallback(
|
||||
(value: string) => {
|
||||
setCurrentInput(value)
|
||||
setHighlightedBadgeIndex(null) // Clear badge highlight on any input
|
||||
|
||||
// Debounce suggestion updates
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current)
|
||||
}
|
||||
|
||||
debounceRef.current = setTimeout(() => {
|
||||
updateSuggestions(value)
|
||||
}, debounceMs)
|
||||
},
|
||||
[updateSuggestions, debounceMs]
|
||||
)
|
||||
|
||||
// Handle suggestion selection
|
||||
const handleSuggestionSelect = useCallback(
|
||||
(suggestion: Suggestion) => {
|
||||
if (suggestion.category === 'show-all') {
|
||||
// Treat as text search
|
||||
setTextSearch(suggestion.value)
|
||||
setCurrentInput('')
|
||||
setIsOpen(false)
|
||||
onFiltersChange(appliedFilters, suggestion.value)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if this is a filter-key suggestion (ends with ':')
|
||||
if (suggestion.category === 'filters' && suggestion.value.endsWith(':')) {
|
||||
// Set input to the filter key and keep dropdown open for values
|
||||
setCurrentInput(suggestion.value)
|
||||
updateSuggestions(suggestion.value)
|
||||
return
|
||||
}
|
||||
|
||||
// For filter values, workflows, folders - add as a filter
|
||||
const newFilter: ParsedFilter = {
|
||||
field: suggestion.value.split(':')[0] as any,
|
||||
operator: '=',
|
||||
value: suggestion.value.includes(':')
|
||||
? suggestion.value.split(':').slice(1).join(':').replace(/"/g, '')
|
||||
: suggestion.value.replace(/"/g, ''),
|
||||
originalValue: suggestion.value.includes(':')
|
||||
? suggestion.value.split(':').slice(1).join(':')
|
||||
: suggestion.value,
|
||||
}
|
||||
|
||||
const updatedFilters = [...appliedFilters, newFilter]
|
||||
setAppliedFilters(updatedFilters)
|
||||
setCurrentInput('')
|
||||
setTextSearch('')
|
||||
|
||||
// Notify parent
|
||||
onFiltersChange(updatedFilters, '')
|
||||
|
||||
// Focus back on input and reopen dropdown with empty suggestions
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus()
|
||||
}
|
||||
|
||||
// Show filter keys dropdown again after selection
|
||||
setTimeout(() => {
|
||||
updateSuggestions('')
|
||||
}, 50)
|
||||
},
|
||||
[appliedFilters, onFiltersChange, updateSuggestions]
|
||||
)
|
||||
|
||||
// Remove a badge
|
||||
const removeBadge = useCallback(
|
||||
(index: number) => {
|
||||
const updatedFilters = appliedFilters.filter((_, i) => i !== index)
|
||||
setAppliedFilters(updatedFilters)
|
||||
setHighlightedBadgeIndex(null)
|
||||
onFiltersChange(updatedFilters, textSearch)
|
||||
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus()
|
||||
}
|
||||
},
|
||||
[appliedFilters, textSearch, onFiltersChange]
|
||||
)
|
||||
|
||||
// Handle keyboard navigation
|
||||
const handleKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent) => {
|
||||
// Backspace on empty input - badge deletion
|
||||
if (event.key === 'Backspace' && currentInput === '') {
|
||||
event.preventDefault()
|
||||
|
||||
if (highlightedBadgeIndex !== null) {
|
||||
// Delete highlighted badge
|
||||
removeBadge(highlightedBadgeIndex)
|
||||
} else if (appliedFilters.length > 0) {
|
||||
// Highlight last badge
|
||||
setHighlightedBadgeIndex(appliedFilters.length - 1)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Clear badge highlight on any other key when not in dropdown navigation
|
||||
if (
|
||||
highlightedBadgeIndex !== null &&
|
||||
!['ArrowDown', 'ArrowUp', 'Enter'].includes(event.key)
|
||||
) {
|
||||
setHighlightedBadgeIndex(null)
|
||||
}
|
||||
|
||||
// Enter key
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
|
||||
if (isOpen && highlightedIndex >= 0 && suggestions[highlightedIndex]) {
|
||||
handleSuggestionSelect(suggestions[highlightedIndex])
|
||||
} else if (currentInput.trim()) {
|
||||
// Submit current input as text search
|
||||
setTextSearch(currentInput.trim())
|
||||
setCurrentInput('')
|
||||
setIsOpen(false)
|
||||
onFiltersChange(appliedFilters, currentInput.trim())
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Dropdown navigation
|
||||
if (!isOpen) return
|
||||
|
||||
switch (event.key) {
|
||||
case 'ArrowDown': {
|
||||
event.preventDefault()
|
||||
setHighlightedIndex((prev) => Math.min(prev + 1, suggestions.length - 1))
|
||||
break
|
||||
}
|
||||
|
||||
case 'ArrowUp': {
|
||||
event.preventDefault()
|
||||
setHighlightedIndex((prev) => Math.max(prev - 1, 0))
|
||||
break
|
||||
}
|
||||
|
||||
case 'Escape': {
|
||||
event.preventDefault()
|
||||
setIsOpen(false)
|
||||
setHighlightedIndex(-1)
|
||||
break
|
||||
}
|
||||
|
||||
case 'Tab': {
|
||||
if (highlightedIndex >= 0 && suggestions[highlightedIndex]) {
|
||||
event.preventDefault()
|
||||
handleSuggestionSelect(suggestions[highlightedIndex])
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
currentInput,
|
||||
highlightedBadgeIndex,
|
||||
appliedFilters,
|
||||
isOpen,
|
||||
highlightedIndex,
|
||||
suggestions,
|
||||
handleSuggestionSelect,
|
||||
removeBadge,
|
||||
onFiltersChange,
|
||||
]
|
||||
)
|
||||
|
||||
// Handle focus
|
||||
const handleFocus = useCallback(() => {
|
||||
updateSuggestions(currentInput)
|
||||
}, [currentInput, updateSuggestions])
|
||||
|
||||
// Handle blur
|
||||
const handleBlur = useCallback(() => {
|
||||
setTimeout(() => {
|
||||
setIsOpen(false)
|
||||
setHighlightedIndex(-1)
|
||||
}, 150)
|
||||
}, [])
|
||||
|
||||
// Clear all filters
|
||||
const clearAll = useCallback(() => {
|
||||
setAppliedFilters([])
|
||||
setCurrentInput('')
|
||||
setTextSearch('')
|
||||
setIsOpen(false)
|
||||
onFiltersChange([], '')
|
||||
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus()
|
||||
}
|
||||
}, [onFiltersChange])
|
||||
|
||||
// Initialize from external value (URL params, etc.)
|
||||
const initializeFromQuery = useCallback((query: string, filters: ParsedFilter[]) => {
|
||||
setAppliedFilters(filters)
|
||||
setTextSearch(query)
|
||||
setCurrentInput('')
|
||||
}, [])
|
||||
|
||||
return {
|
||||
// State
|
||||
appliedFilters,
|
||||
currentInput,
|
||||
textSearch,
|
||||
isOpen,
|
||||
suggestions,
|
||||
sections,
|
||||
highlightedIndex,
|
||||
highlightedBadgeIndex,
|
||||
|
||||
// Refs
|
||||
inputRef,
|
||||
dropdownRef,
|
||||
|
||||
// Handlers
|
||||
handleInputChange,
|
||||
handleSuggestionSelect,
|
||||
handleKeyDown,
|
||||
handleFocus,
|
||||
handleBlur,
|
||||
removeBadge,
|
||||
clearAll,
|
||||
initializeFromQuery,
|
||||
|
||||
// Setters for external control
|
||||
setHighlightedIndex,
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { AutocompleteSearch } from '@/app/workspace/[workspaceId]/logs/component
|
||||
import { Sidebar } from '@/app/workspace/[workspaceId]/logs/components/sidebar/sidebar'
|
||||
import Dashboard from '@/app/workspace/[workspaceId]/logs/dashboard'
|
||||
import { formatDate } from '@/app/workspace/[workspaceId]/logs/utils'
|
||||
import { useFolders } from '@/hooks/queries/folders'
|
||||
import { useDebounce } from '@/hooks/use-debounce'
|
||||
import { useFolderStore } from '@/stores/folders/store'
|
||||
import { useFilterStore } from '@/stores/logs/filters/store'
|
||||
@@ -120,7 +121,8 @@ export default function Logs() {
|
||||
setSearchQuery(storeSearchQuery)
|
||||
}, [storeSearchQuery])
|
||||
|
||||
const { fetchFolders, getFolderTree } = useFolderStore()
|
||||
const foldersQuery = useFolders(workspaceId)
|
||||
const { getFolderTree } = useFolderStore()
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
@@ -138,7 +140,6 @@ export default function Logs() {
|
||||
if (!cancelled) setAvailableWorkflows([])
|
||||
}
|
||||
|
||||
await fetchFolders(workspaceId)
|
||||
const tree = getFolderTree(workspaceId)
|
||||
|
||||
const flatten = (nodes: any[], parentPath = ''): string[] => {
|
||||
@@ -168,7 +169,7 @@ export default function Logs() {
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [workspaceId, fetchFolders, getFolderTree])
|
||||
}, [workspaceId, getFolderTree, foldersQuery.data])
|
||||
|
||||
useEffect(() => {
|
||||
if (isInitialized.current && debouncedSearchQuery !== storeSearchQuery) {
|
||||
@@ -711,9 +712,7 @@ export default function Logs() {
|
||||
value={searchQuery}
|
||||
onChange={setSearchQuery}
|
||||
placeholder='Search logs...'
|
||||
availableWorkflows={availableWorkflows}
|
||||
availableFolders={availableFolders}
|
||||
onOpenChange={(open) => {
|
||||
onOpenChange={(open: boolean) => {
|
||||
isSearchOpenRef.current = open
|
||||
}}
|
||||
/>
|
||||
@@ -809,24 +808,47 @@ export default function Logs() {
|
||||
|
||||
{/* Status */}
|
||||
<div>
|
||||
<div
|
||||
className={cn(
|
||||
'inline-flex items-center rounded-[8px] px-[8px] py-[2px] font-medium text-[12px] transition-all duration-200',
|
||||
isError
|
||||
? 'bg-red-500 text-white'
|
||||
: isPending
|
||||
? 'bg-amber-300 text-amber-900 dark:bg-amber-500/90 dark:text-black'
|
||||
: 'bg-secondary text-card-foreground'
|
||||
)}
|
||||
>
|
||||
{statusLabel}
|
||||
</div>
|
||||
{isError || !isPending ? (
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-[24px] w-[56px] items-center justify-start rounded-[6px] border pl-[9px]',
|
||||
isError
|
||||
? 'gap-[5px] border-[#883827] bg-[#491515]'
|
||||
: 'gap-[8px] border-[#686868] bg-[#383838]'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className='h-[6px] w-[6px] rounded-[2px]'
|
||||
style={{
|
||||
backgroundColor: isError ? '#EF4444' : '#B7B7B7',
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
className='font-medium text-[11.5px]'
|
||||
style={{ color: isError ? '#EF4444' : '#B7B7B7' }}
|
||||
>
|
||||
{statusLabel}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className='inline-flex items-center bg-amber-300 px-[8px] py-[2px] font-medium text-[12px] text-amber-900 dark:bg-amber-500/90 dark:text-black'>
|
||||
{statusLabel}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Workflow */}
|
||||
<div className='min-w-0'>
|
||||
<div className='truncate font-medium text-[13px] text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
|
||||
{log.workflow?.name || 'Unknown Workflow'}
|
||||
<div className='flex items-center gap-2 truncate'>
|
||||
<div
|
||||
className='h-[12px] w-[12px] flex-shrink-0 rounded'
|
||||
style={{
|
||||
backgroundColor: log.workflow?.color || '#64748b',
|
||||
}}
|
||||
/>
|
||||
<span className='truncate font-medium text-[13px] text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
|
||||
{log.workflow?.name || 'Unknown Workflow'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -843,17 +865,8 @@ export default function Logs() {
|
||||
<div className='hidden xl:block'>
|
||||
{log.trigger ? (
|
||||
<div
|
||||
className={cn(
|
||||
'inline-flex items-center rounded-[8px] px-[8px] py-[2px] font-medium text-[12px] transition-all duration-200',
|
||||
log.trigger.toLowerCase() === 'manual'
|
||||
? 'bg-secondary text-card-foreground'
|
||||
: 'text-white'
|
||||
)}
|
||||
style={
|
||||
log.trigger.toLowerCase() === 'manual'
|
||||
? undefined
|
||||
: { backgroundColor: getTriggerColor(log.trigger) }
|
||||
}
|
||||
className='inline-flex items-center rounded-[6px] px-[8px] py-[2px] font-medium text-[12px] text-white'
|
||||
style={{ backgroundColor: getTriggerColor(log.trigger) }}
|
||||
>
|
||||
{log.trigger}
|
||||
</div>
|
||||
|
||||
30
apps/sim/app/workspace/[workspaceId]/logs/types/search.ts
Normal file
30
apps/sim/app/workspace/[workspaceId]/logs/types/search.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
export interface Suggestion {
|
||||
id: string
|
||||
value: string
|
||||
label: string
|
||||
description?: string
|
||||
category?:
|
||||
| 'filters'
|
||||
| 'level'
|
||||
| 'trigger'
|
||||
| 'cost'
|
||||
| 'date'
|
||||
| 'duration'
|
||||
| 'workflow'
|
||||
| 'folder'
|
||||
| 'workflowId'
|
||||
| 'executionId'
|
||||
| 'show-all'
|
||||
}
|
||||
|
||||
export interface SuggestionSection {
|
||||
title: string
|
||||
suggestions: Suggestion[]
|
||||
}
|
||||
|
||||
export interface SuggestionGroup {
|
||||
type: 'filter-keys' | 'filter-values' | 'multi-section'
|
||||
filterKey?: string
|
||||
suggestions: Suggestion[]
|
||||
sections?: SuggestionSection[]
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { useProviderModels } from '@/hooks/queries/providers'
|
||||
import { updateOllamaProviderModels, updateOpenRouterProviderModels } from '@/providers/utils'
|
||||
import { useProvidersStore } from '@/stores/providers/store'
|
||||
import type { ProviderName } from '@/stores/providers/types'
|
||||
|
||||
const logger = createLogger('ProviderModelsLoader')
|
||||
|
||||
function useSyncProvider(provider: ProviderName) {
|
||||
const setProviderModels = useProvidersStore((state) => state.setProviderModels)
|
||||
const setProviderLoading = useProvidersStore((state) => state.setProviderLoading)
|
||||
const { data, isLoading, isFetching, error } = useProviderModels(provider)
|
||||
|
||||
useEffect(() => {
|
||||
setProviderLoading(provider, isLoading || isFetching)
|
||||
}, [provider, isLoading, isFetching, setProviderLoading])
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) return
|
||||
|
||||
try {
|
||||
if (provider === 'ollama') {
|
||||
updateOllamaProviderModels(data)
|
||||
} else if (provider === 'openrouter') {
|
||||
void updateOpenRouterProviderModels(data)
|
||||
}
|
||||
} catch (syncError) {
|
||||
logger.warn(`Failed to sync provider definitions for ${provider}`, syncError as Error)
|
||||
}
|
||||
|
||||
setProviderModels(provider, data)
|
||||
}, [provider, data, setProviderModels])
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
logger.error(`Failed to load ${provider} models`, error)
|
||||
}
|
||||
}, [provider, error])
|
||||
}
|
||||
|
||||
export function ProviderModelsLoader() {
|
||||
useSyncProvider('base')
|
||||
useSyncProvider('ollama')
|
||||
useSyncProvider('openrouter')
|
||||
return null
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import React from 'react'
|
||||
import { Tooltip } from '@/components/emcn'
|
||||
import { GlobalCommandsProvider } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
|
||||
import { WorkspacePermissionsProvider } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import { ProviderModelsLoader } from './provider-models-loader'
|
||||
import { SettingsLoader } from './settings-loader'
|
||||
|
||||
interface ProvidersProps {
|
||||
@@ -14,6 +15,7 @@ const Providers = React.memo<ProvidersProps>(({ children }) => {
|
||||
return (
|
||||
<>
|
||||
<SettingsLoader />
|
||||
<ProviderModelsLoader />
|
||||
<GlobalCommandsProvider>
|
||||
<Tooltip.Provider delayDuration={600} skipDelayDuration={0}>
|
||||
<WorkspacePermissionsProvider>{children}</WorkspacePermissionsProvider>
|
||||
|
||||
@@ -2,26 +2,29 @@
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useSession } from '@/lib/auth-client'
|
||||
import { useGeneralStore } from '@/stores/settings/general/store'
|
||||
import { useGeneralSettings } from '@/hooks/queries/general-settings'
|
||||
|
||||
/**
|
||||
* Loads user settings from database once per workspace session.
|
||||
* This ensures settings are synced from DB on initial load but uses
|
||||
* localStorage cache for subsequent navigation within the app.
|
||||
* React Query handles the fetching and automatically syncs to Zustand store.
|
||||
* This ensures settings are available throughout the app.
|
||||
*/
|
||||
export function SettingsLoader() {
|
||||
const { data: session, isPending: isSessionPending } = useSession()
|
||||
const loadSettings = useGeneralStore((state) => state.loadSettings)
|
||||
const hasLoadedRef = useRef(false)
|
||||
|
||||
// Use React Query hook which automatically syncs to Zustand
|
||||
// This replaces the old Zustand loadSettings() call
|
||||
const { refetch } = useGeneralSettings()
|
||||
|
||||
useEffect(() => {
|
||||
// Only load settings once per session for authenticated users
|
||||
if (!isSessionPending && session?.user && !hasLoadedRef.current) {
|
||||
hasLoadedRef.current = true
|
||||
// Force load from DB on initial workspace entry
|
||||
loadSettings(true)
|
||||
// Force refetch from DB on initial workspace entry
|
||||
refetch()
|
||||
}
|
||||
}, [isSessionPending, session?.user, loadSettings])
|
||||
}, [isSessionPending, session?.user, refetch])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import { db } from '@sim/db'
|
||||
import { templateCreators, templateStars, templates } from '@sim/db/schema'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { notFound } from 'next/navigation'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import TemplateDetails from '@/app/workspace/[workspaceId]/templates/[id]/template'
|
||||
|
||||
const logger = createLogger('TemplatePage')
|
||||
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
|
||||
import TemplateDetails from '@/app/templates/[id]/template'
|
||||
|
||||
interface TemplatePageProps {
|
||||
params: Promise<{
|
||||
@@ -15,91 +10,25 @@ interface TemplatePageProps {
|
||||
}>
|
||||
}
|
||||
|
||||
/**
|
||||
* Workspace-scoped template detail page.
|
||||
* Requires authentication and workspace membership to access.
|
||||
* Uses the shared TemplateDetails component with workspace context.
|
||||
*/
|
||||
export default async function TemplatePage({ params }: TemplatePageProps) {
|
||||
const { workspaceId, id } = await params
|
||||
const session = await getSession()
|
||||
|
||||
try {
|
||||
if (!id || typeof id !== 'string' || id.length !== 36) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
const session = await getSession()
|
||||
|
||||
const templateData = await db
|
||||
.select({
|
||||
template: templates,
|
||||
creator: templateCreators,
|
||||
})
|
||||
.from(templates)
|
||||
.leftJoin(templateCreators, eq(templates.creatorId, templateCreators.id))
|
||||
.where(eq(templates.id, id))
|
||||
.limit(1)
|
||||
|
||||
if (templateData.length === 0) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
const { template, creator } = templateData[0]
|
||||
|
||||
if (!session?.user?.id && template.status !== 'approved') {
|
||||
notFound()
|
||||
}
|
||||
|
||||
if (!template.id || !template.name) {
|
||||
logger.error('Template missing required fields:', {
|
||||
id: template.id,
|
||||
name: template.name,
|
||||
})
|
||||
notFound()
|
||||
}
|
||||
|
||||
let isStarred = false
|
||||
if (session?.user?.id) {
|
||||
try {
|
||||
const starData = await db
|
||||
.select({ id: templateStars.id })
|
||||
.from(templateStars)
|
||||
.where(
|
||||
and(
|
||||
eq(templateStars.templateId, template.id),
|
||||
eq(templateStars.userId, session.user.id)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
isStarred = starData.length > 0
|
||||
} catch {
|
||||
isStarred = false
|
||||
}
|
||||
}
|
||||
|
||||
const serializedTemplate = {
|
||||
...template,
|
||||
creator: creator || null,
|
||||
createdAt: template.createdAt.toISOString(),
|
||||
updatedAt: template.updatedAt.toISOString(),
|
||||
isStarred,
|
||||
}
|
||||
|
||||
return (
|
||||
<TemplateDetails
|
||||
template={JSON.parse(JSON.stringify(serializedTemplate))}
|
||||
workspaceId={workspaceId}
|
||||
currentUserId={session?.user?.id || null}
|
||||
/>
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error('Error loading template:', error)
|
||||
return (
|
||||
<div className='flex h-[100vh] items-center justify-center pl-64'>
|
||||
<div className='text-center'>
|
||||
<h1 className='mb-[14px] font-medium text-[18px]'>Error Loading Template</h1>
|
||||
<p className='text-[#888888] text-[14px]'>There was an error loading this template.</p>
|
||||
<p className='mt-[10px] text-[#888888] text-[12px]'>Template ID: {id}</p>
|
||||
<p className='mt-[10px] text-[12px] text-red-500'>
|
||||
{error instanceof Error ? error.message : 'Unknown error'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
// Redirect unauthenticated users to public template detail page
|
||||
if (!session?.user?.id) {
|
||||
redirect(`/templates/${id}`)
|
||||
}
|
||||
|
||||
// Verify workspace membership
|
||||
const hasPermission = await verifyWorkspaceMembership(session.user.id, workspaceId)
|
||||
if (!hasPermission) {
|
||||
redirect('/')
|
||||
}
|
||||
|
||||
return <TemplateDetails isWorkspaceContext={true} />
|
||||
}
|
||||
|
||||
@@ -1,423 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Award,
|
||||
BarChart3,
|
||||
Bell,
|
||||
BookOpen,
|
||||
Bot,
|
||||
Brain,
|
||||
Briefcase,
|
||||
Calculator,
|
||||
Cloud,
|
||||
Code,
|
||||
Cpu,
|
||||
CreditCard,
|
||||
Database,
|
||||
DollarSign,
|
||||
Edit,
|
||||
Eye,
|
||||
FileText,
|
||||
Folder,
|
||||
Globe,
|
||||
HeadphonesIcon,
|
||||
Layers,
|
||||
Lightbulb,
|
||||
LineChart,
|
||||
Mail,
|
||||
Megaphone,
|
||||
MessageSquare,
|
||||
NotebookPen,
|
||||
Phone,
|
||||
Play,
|
||||
Search,
|
||||
Server,
|
||||
Settings,
|
||||
ShoppingCart,
|
||||
Star,
|
||||
Target,
|
||||
TrendingUp,
|
||||
User,
|
||||
Users,
|
||||
Workflow,
|
||||
Wrench,
|
||||
Zap,
|
||||
} from 'lucide-react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { Button } from '@/components/emcn'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { Template } from '@/app/workspace/[workspaceId]/templates/templates'
|
||||
import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/workflow-preview/workflow-preview'
|
||||
import type { WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
const logger = createLogger('TemplateDetails')
|
||||
|
||||
interface TemplateDetailsProps {
|
||||
template: Template
|
||||
workspaceId: string
|
||||
currentUserId: string | null
|
||||
}
|
||||
|
||||
// Icon mapping - reuse from template-card
|
||||
const iconMap = {
|
||||
FileText,
|
||||
NotebookPen,
|
||||
BookOpen,
|
||||
Edit,
|
||||
BarChart3,
|
||||
LineChart,
|
||||
TrendingUp,
|
||||
Target,
|
||||
Database,
|
||||
Server,
|
||||
Cloud,
|
||||
Folder,
|
||||
Megaphone,
|
||||
Mail,
|
||||
MessageSquare,
|
||||
Phone,
|
||||
Bell,
|
||||
DollarSign,
|
||||
CreditCard,
|
||||
Calculator,
|
||||
ShoppingCart,
|
||||
Briefcase,
|
||||
HeadphonesIcon,
|
||||
Users,
|
||||
Settings,
|
||||
Wrench,
|
||||
Bot,
|
||||
Brain,
|
||||
Cpu,
|
||||
Code,
|
||||
Zap,
|
||||
Workflow,
|
||||
Search,
|
||||
Play,
|
||||
Layers,
|
||||
Lightbulb,
|
||||
Globe,
|
||||
Award,
|
||||
}
|
||||
|
||||
/**
|
||||
* Get icon component from template icon name
|
||||
*/
|
||||
const getIconComponent = (icon: string): React.ReactNode => {
|
||||
const IconComponent = iconMap[icon as keyof typeof iconMap]
|
||||
return IconComponent ? (
|
||||
<IconComponent className='h-[14px] w-[14px]' />
|
||||
) : (
|
||||
<FileText className='h-[14px] w-[14px]' />
|
||||
)
|
||||
}
|
||||
|
||||
export default function TemplateDetails({
|
||||
template,
|
||||
workspaceId,
|
||||
currentUserId,
|
||||
}: TemplateDetailsProps) {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
|
||||
// Initialize all state hooks first (hooks must be called unconditionally)
|
||||
const [isStarred, setIsStarred] = useState(template?.isStarred || false)
|
||||
const [starCount, setStarCount] = useState(template?.stars || 0)
|
||||
const [isStarring, setIsStarring] = useState(false)
|
||||
const [isUsing, setIsUsing] = useState(false)
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
|
||||
const isOwner = currentUserId && template?.userId === currentUserId
|
||||
|
||||
// Auto-use template after login if use=true query param is present
|
||||
useEffect(() => {
|
||||
if (!template?.id) return
|
||||
const shouldAutoUse = searchParams?.get('use') === 'true'
|
||||
if (shouldAutoUse && currentUserId && !isUsing) {
|
||||
handleUseTemplate()
|
||||
// Clean up URL
|
||||
router.replace(`/workspace/${workspaceId}/templates/${template.id}`)
|
||||
}
|
||||
}, [searchParams, currentUserId, template?.id])
|
||||
|
||||
// Defensive check for template AFTER initializing hooks
|
||||
if (!template) {
|
||||
logger.error('Template prop is undefined or null in TemplateDetails component', {
|
||||
template,
|
||||
workspaceId,
|
||||
currentUserId,
|
||||
})
|
||||
return (
|
||||
<div className='flex h-[100vh] items-center justify-center pl-64'>
|
||||
<div className='text-center'>
|
||||
<h1 className='mb-[14px] font-medium text-[18px]'>Template Not Found</h1>
|
||||
<p className='text-[#888888] text-[14px]'>
|
||||
The template you're looking for doesn't exist.
|
||||
</p>
|
||||
<p className='mt-[10px] text-[#888888] text-[12px]'>Template data failed to load</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
logger.info('Template loaded in TemplateDetails', {
|
||||
id: template.id,
|
||||
name: template.name,
|
||||
hasState: !!template.state,
|
||||
})
|
||||
|
||||
/**
|
||||
* Render workflow preview with consistent error handling
|
||||
*/
|
||||
const renderWorkflowPreview = () => {
|
||||
// Follow the same pattern as deployed-workflow-card.tsx
|
||||
if (!template?.state) {
|
||||
logger.info('Template has no state:', template)
|
||||
return (
|
||||
<div className='flex h-full items-center justify-center text-center'>
|
||||
<div className='text-[#888888]'>
|
||||
<div className='mb-[10px] font-medium text-[14px]'>⚠️ No Workflow Data</div>
|
||||
<div className='text-[12px]'>This template doesn't contain workflow state data.</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
logger.info('Template state:', template.state)
|
||||
logger.info('Template state type:', typeof template.state)
|
||||
logger.info('Template state blocks:', template.state.blocks)
|
||||
logger.info('Template state edges:', template.state.edges)
|
||||
|
||||
try {
|
||||
return (
|
||||
<WorkflowPreview
|
||||
workflowState={template.state as WorkflowState}
|
||||
showSubBlocks={true}
|
||||
height='100%'
|
||||
width='100%'
|
||||
isPannable={true}
|
||||
defaultPosition={{ x: 0, y: 0 }}
|
||||
defaultZoom={1}
|
||||
/>
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error('Error rendering workflow preview:', error)
|
||||
return (
|
||||
<div className='flex h-full items-center justify-center text-center'>
|
||||
<div className='text-[#888888]'>
|
||||
<div className='mb-[10px] font-medium text-[14px]'>⚠️ Preview Error</div>
|
||||
<div className='text-[12px]'>Unable to render workflow preview</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const handleBack = () => {
|
||||
router.back()
|
||||
}
|
||||
|
||||
const handleStarToggle = async () => {
|
||||
if (isStarring || !currentUserId) return
|
||||
|
||||
setIsStarring(true)
|
||||
try {
|
||||
const method = isStarred ? 'DELETE' : 'POST'
|
||||
const response = await fetch(`/api/templates/${template.id}/star`, { method })
|
||||
|
||||
if (response.ok) {
|
||||
setIsStarred(!isStarred)
|
||||
setStarCount((prev) => (isStarred ? prev - 1 : prev + 1))
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error toggling star:', error)
|
||||
} finally {
|
||||
setIsStarring(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUseTemplate = async () => {
|
||||
if (isUsing) return
|
||||
|
||||
// Check if user is logged in
|
||||
if (!currentUserId) {
|
||||
// Redirect to login with callback URL to use template after login
|
||||
const callbackUrl = encodeURIComponent(
|
||||
`/workspace/${workspaceId}/templates/${template.id}?use=true`
|
||||
)
|
||||
router.push(`/login?callbackUrl=${callbackUrl}`)
|
||||
return
|
||||
}
|
||||
|
||||
setIsUsing(true)
|
||||
try {
|
||||
const response = await fetch(`/api/templates/${template.id}/use`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workspaceId }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to use template')
|
||||
}
|
||||
|
||||
const { workflowId } = await response.json()
|
||||
|
||||
// Navigate to the new workflow
|
||||
router.push(`/workspace/${workspaceId}/w/${workflowId}`)
|
||||
} catch (error) {
|
||||
logger.error('Error using template:', error)
|
||||
} finally {
|
||||
setIsUsing(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleEditTemplate = async () => {
|
||||
if (isEditing || !currentUserId) return
|
||||
|
||||
setIsEditing(true)
|
||||
try {
|
||||
// If template already has a connected workflowId, check if it exists in user's workspace
|
||||
if (template.workflowId) {
|
||||
// Try to fetch the workflow to see if it still exists
|
||||
const checkResponse = await fetch(`/api/workflows/${template.workflowId}`)
|
||||
|
||||
if (checkResponse.ok) {
|
||||
// Workflow exists, redirect to it
|
||||
router.push(`/workspace/${workspaceId}/w/${template.workflowId}`)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// No connected workflow or it was deleted - create a new one
|
||||
const response = await fetch(`/api/templates/${template.id}/edit`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workspaceId }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to edit template')
|
||||
}
|
||||
|
||||
const { workflowId } = await response.json()
|
||||
|
||||
// Navigate to the workflow
|
||||
router.push(`/workspace/${workspaceId}/w/${workflowId}`)
|
||||
} catch (error) {
|
||||
logger.error('Error editing template:', error)
|
||||
} finally {
|
||||
setIsEditing(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex h-[100vh] flex-col pl-64'>
|
||||
<div className='flex flex-1 flex-col overflow-auto px-[24px] pt-[24px] pb-[24px]'>
|
||||
{/* Back button */}
|
||||
<button
|
||||
onClick={handleBack}
|
||||
className='mb-[14px] flex items-center gap-[8px] text-[#888888] transition-colors hover:text-white'
|
||||
>
|
||||
<ArrowLeft className='h-[14px] w-[14px]' />
|
||||
<span className='font-medium text-[12px]'>Go back</span>
|
||||
</button>
|
||||
|
||||
{/* Header */}
|
||||
<div>
|
||||
<div className='flex items-start gap-[12px]'>
|
||||
{/* Icon */}
|
||||
<div
|
||||
className='flex h-[26px] w-[26px] items-center justify-center rounded-[6px]'
|
||||
style={{ backgroundColor: template.color }}
|
||||
>
|
||||
{getIconComponent(template.icon)}
|
||||
</div>
|
||||
<h1 className='font-medium text-[18px]'>{template.name}</h1>
|
||||
</div>
|
||||
<p className='mt-[10px] font-base text-[#888888] text-[14px]'>{template.description}</p>
|
||||
</div>
|
||||
|
||||
{/* Stats and Actions */}
|
||||
<div className='mt-[14px] flex items-center justify-between'>
|
||||
{/* Stats */}
|
||||
<div className='flex items-center gap-[12px] font-medium text-[#888888] text-[12px]'>
|
||||
<div className='flex items-center gap-[6px]'>
|
||||
<Eye className='h-[12px] w-[12px]' />
|
||||
<span>{template.views} views</span>
|
||||
</div>
|
||||
<div className='flex items-center gap-[6px]'>
|
||||
<Star className='h-[12px] w-[12px]' />
|
||||
<span>{starCount} stars</span>
|
||||
</div>
|
||||
<div className='flex items-center gap-[6px]'>
|
||||
<User className='h-[12px] w-[12px]' />
|
||||
<span>by {template.author}</span>
|
||||
</div>
|
||||
{template.authorType === 'organization' && (
|
||||
<div className='flex items-center gap-[6px]'>
|
||||
<Users className='h-[12px] w-[12px]' />
|
||||
<span>Organization</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
{/* Star button - only for logged-in users */}
|
||||
{currentUserId && (
|
||||
<Button
|
||||
variant={isStarred ? 'active' : 'default'}
|
||||
className='h-[32px] rounded-[6px]'
|
||||
onClick={handleStarToggle}
|
||||
disabled={isStarring}
|
||||
>
|
||||
<Star className={cn('mr-[6px] h-[14px] w-[14px]', isStarred && 'fill-current')} />
|
||||
<span className='font-medium text-[12px]'>{starCount}</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Edit button - only for template owner when logged in */}
|
||||
{isOwner && currentUserId && (
|
||||
<Button
|
||||
variant='default'
|
||||
className='h-[32px] rounded-[6px]'
|
||||
onClick={handleEditTemplate}
|
||||
disabled={isEditing}
|
||||
>
|
||||
<Edit className='mr-[6px] h-[14px] w-[14px]' />
|
||||
<span className='font-medium text-[12px]'>{isEditing ? 'Opening...' : 'Edit'}</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Use template button */}
|
||||
<Button
|
||||
variant='active'
|
||||
className='h-[32px] rounded-[6px]'
|
||||
onClick={handleUseTemplate}
|
||||
disabled={isUsing}
|
||||
>
|
||||
<span className='font-medium text-[12px]'>
|
||||
{isUsing ? 'Creating...' : currentUserId ? 'Use this template' : 'Sign in to use'}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className='mt-[24px] h-[1px] w-full border-[var(--border)] border-t' />
|
||||
|
||||
{/* Workflow preview */}
|
||||
<div className='mt-[24px] flex-1'>
|
||||
<h2 className='mb-[14px] font-medium text-[14px]'>Workflow Preview</h2>
|
||||
<div className='h-[calc(100vh-280px)] w-full overflow-hidden rounded-[8px] bg-[#202020]'>
|
||||
{renderWorkflowPreview()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react'
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Star, User } from 'lucide-react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
@@ -14,6 +14,7 @@ interface TemplateCardProps {
|
||||
title: string
|
||||
description: string
|
||||
author: string
|
||||
authorImageUrl?: string | null
|
||||
usageCount: string
|
||||
stars?: number
|
||||
icon?: React.ReactNode | string
|
||||
@@ -138,11 +139,12 @@ function normalizeWorkflowState(input?: any): WorkflowState | null {
|
||||
return normalized
|
||||
}
|
||||
|
||||
export function TemplateCard({
|
||||
function TemplateCardInner({
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
author,
|
||||
authorImageUrl,
|
||||
usageCount,
|
||||
stars = 0,
|
||||
icon,
|
||||
@@ -164,11 +166,38 @@ export function TemplateCard({
|
||||
const [localStarCount, setLocalStarCount] = useState(stars)
|
||||
const [isStarLoading, setIsStarLoading] = useState(false)
|
||||
|
||||
// Memoize normalized workflow state to avoid recalculation on every render
|
||||
const normalizedState = useMemo(() => normalizeWorkflowState(state), [state])
|
||||
|
||||
// Use IntersectionObserver to defer rendering the heavy WorkflowPreview until in viewport
|
||||
const previewRef = useRef<HTMLDivElement | null>(null)
|
||||
const [isInView, setIsInView] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!previewRef.current) return
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
setIsInView(true)
|
||||
observer.disconnect()
|
||||
}
|
||||
},
|
||||
{ root: null, rootMargin: '200px', threshold: 0 }
|
||||
)
|
||||
observer.observe(previewRef.current)
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
// Extract block types from state if provided, otherwise use the blocks prop
|
||||
// Filter out starter blocks in both cases and sort for consistent rendering
|
||||
const blockTypes = state
|
||||
? extractBlockTypesFromState(state)
|
||||
: blocks.filter((blockType) => blockType !== 'starter').sort()
|
||||
// Memoized to prevent recalculation on every render
|
||||
const blockTypes = useMemo(
|
||||
() =>
|
||||
state
|
||||
? extractBlockTypesFromState(state)
|
||||
: blocks.filter((blockType) => blockType !== 'starter').sort(),
|
||||
[state, blocks]
|
||||
)
|
||||
|
||||
// Handle star toggle with optimistic updates
|
||||
const handleStarClick = async (e: React.MouseEvent) => {
|
||||
@@ -227,35 +256,42 @@ export function TemplateCard({
|
||||
* Get the appropriate template detail page URL based on context.
|
||||
* If we're in a workspace context, navigate to the workspace template page.
|
||||
* Otherwise, navigate to the global template page.
|
||||
* Memoized to avoid recalculation on every render.
|
||||
*/
|
||||
const getTemplateUrl = () => {
|
||||
const templateUrl = useMemo(() => {
|
||||
const workspaceId = params?.workspaceId as string | undefined
|
||||
if (workspaceId) {
|
||||
return `/workspace/${workspaceId}/templates/${id}`
|
||||
}
|
||||
return `/templates/${id}`
|
||||
}
|
||||
}, [params?.workspaceId, id])
|
||||
|
||||
/**
|
||||
* Handle use button click - navigate to template detail page
|
||||
*/
|
||||
const handleUseClick = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
router.push(getTemplateUrl())
|
||||
}
|
||||
const handleUseClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
router.push(templateUrl)
|
||||
},
|
||||
[router, templateUrl]
|
||||
)
|
||||
|
||||
/**
|
||||
* Handle card click - navigate to template detail page
|
||||
*/
|
||||
const handleCardClick = (e: React.MouseEvent) => {
|
||||
// Don't navigate if clicking on action buttons
|
||||
const target = e.target as HTMLElement
|
||||
if (target.closest('button') || target.closest('[data-action]')) {
|
||||
return
|
||||
}
|
||||
const handleCardClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
// Don't navigate if clicking on action buttons
|
||||
const target = e.target as HTMLElement
|
||||
if (target.closest('button') || target.closest('[data-action]')) {
|
||||
return
|
||||
}
|
||||
|
||||
router.push(getTemplateUrl())
|
||||
}
|
||||
router.push(templateUrl)
|
||||
},
|
||||
[router, templateUrl]
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -263,10 +299,13 @@ export function TemplateCard({
|
||||
className={cn('w-full cursor-pointer rounded-[8px] bg-[#202020] p-[8px]', className)}
|
||||
>
|
||||
{/* Workflow Preview */}
|
||||
<div className='h-[180px] w-full overflow-hidden rounded-[6px]'>
|
||||
{normalizeWorkflowState(state) ? (
|
||||
<div
|
||||
ref={previewRef}
|
||||
className='pointer-events-none h-[180px] w-full overflow-hidden rounded-[6px]'
|
||||
>
|
||||
{normalizedState && isInView ? (
|
||||
<WorkflowPreview
|
||||
workflowState={normalizeWorkflowState(state)!}
|
||||
workflowState={normalizedState}
|
||||
showSubBlocks={false}
|
||||
height={180}
|
||||
width='100%'
|
||||
@@ -341,7 +380,15 @@ export function TemplateCard({
|
||||
<div className='mt-[10px] flex items-center justify-between'>
|
||||
{/* Creator Info */}
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
<div className='h-[14px] w-[14px] flex-shrink-0 rounded-full bg-[#4A4A4A]' />
|
||||
{authorImageUrl ? (
|
||||
<div className='h-[26px] w-[26px] flex-shrink-0 overflow-hidden rounded-full'>
|
||||
<img src={authorImageUrl} alt={author} className='h-full w-full object-cover' />
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex h-[26px] w-[26px] flex-shrink-0 items-center justify-center rounded-full bg-[#4A4A4A]'>
|
||||
<User className='h-[18px] w-[18px] text-[#888888]' />
|
||||
</div>
|
||||
)}
|
||||
<span className='truncate font-medium text-[#888888] text-[12px]'>{author}</span>
|
||||
</div>
|
||||
|
||||
@@ -353,7 +400,7 @@ export function TemplateCard({
|
||||
onClick={handleStarClick}
|
||||
className={cn(
|
||||
'h-[12px] w-[12px] cursor-pointer transition-colors',
|
||||
localIsStarred ? 'fill-yellow-400 text-yellow-400' : 'text-[#888888]',
|
||||
localIsStarred ? 'fill-yellow-500 text-yellow-500' : 'text-[#888888]',
|
||||
isStarLoading && 'opacity-50'
|
||||
)}
|
||||
/>
|
||||
@@ -363,3 +410,5 @@ export function TemplateCard({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const TemplateCard = memo(TemplateCardInner)
|
||||
|
||||
@@ -1,46 +1,60 @@
|
||||
import { db } from '@sim/db'
|
||||
import { settings, templateCreators, templateStars, templates, user } from '@sim/db/schema'
|
||||
import { and, desc, eq, sql } from 'drizzle-orm'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
|
||||
import type { Template as WorkspaceTemplate } from '@/app/workspace/[workspaceId]/templates/templates'
|
||||
import Templates from '@/app/workspace/[workspaceId]/templates/templates'
|
||||
|
||||
interface TemplatesPageProps {
|
||||
params: Promise<{
|
||||
workspaceId: string
|
||||
}>
|
||||
}
|
||||
|
||||
/**
|
||||
* Workspace-scoped Templates page.
|
||||
*
|
||||
* Mirrors the global templates data loading while rendering the workspace
|
||||
* templates UI (which accounts for the sidebar layout). This avoids redirecting
|
||||
* to the global /templates route and keeps users within their workspace context.
|
||||
* Requires authentication and workspace membership to access.
|
||||
*/
|
||||
export default async function TemplatesPage() {
|
||||
export default async function TemplatesPage({ params }: TemplatesPageProps) {
|
||||
const { workspaceId } = await params
|
||||
const session = await getSession()
|
||||
|
||||
// Determine effective super user (DB flag AND UI mode enabled)
|
||||
let effectiveSuperUser = false
|
||||
if (session?.user?.id) {
|
||||
const currentUser = await db
|
||||
.select({ isSuperUser: user.isSuperUser })
|
||||
.from(user)
|
||||
.where(eq(user.id, session.user.id))
|
||||
.limit(1)
|
||||
const userSettings = await db
|
||||
.select({ superUserModeEnabled: settings.superUserModeEnabled })
|
||||
.from(settings)
|
||||
.where(eq(settings.userId, session.user.id))
|
||||
.limit(1)
|
||||
|
||||
const isSuperUser = currentUser[0]?.isSuperUser || false
|
||||
const superUserModeEnabled = userSettings[0]?.superUserModeEnabled ?? true
|
||||
effectiveSuperUser = isSuperUser && superUserModeEnabled
|
||||
// Redirect unauthenticated users to public templates page
|
||||
if (!session?.user?.id) {
|
||||
redirect('/templates')
|
||||
}
|
||||
|
||||
// Load templates (same logic as global page)
|
||||
// Verify workspace membership
|
||||
const hasPermission = await verifyWorkspaceMembership(session.user.id, workspaceId)
|
||||
if (!hasPermission) {
|
||||
redirect('/')
|
||||
}
|
||||
|
||||
// Determine effective super user (DB flag AND UI mode enabled)
|
||||
const currentUser = await db
|
||||
.select({ isSuperUser: user.isSuperUser })
|
||||
.from(user)
|
||||
.where(eq(user.id, session.user.id))
|
||||
.limit(1)
|
||||
const userSettings = await db
|
||||
.select({ superUserModeEnabled: settings.superUserModeEnabled })
|
||||
.from(settings)
|
||||
.where(eq(settings.userId, session.user.id))
|
||||
.limit(1)
|
||||
|
||||
const isSuperUser = currentUser[0]?.isSuperUser || false
|
||||
const superUserModeEnabled = userSettings[0]?.superUserModeEnabled ?? true
|
||||
const effectiveSuperUser = isSuperUser && superUserModeEnabled
|
||||
|
||||
// Load templates from database
|
||||
let rows:
|
||||
| Array<{
|
||||
id: string
|
||||
workflowId: string | null
|
||||
name: string
|
||||
details?: any
|
||||
details?: unknown
|
||||
creatorId: string | null
|
||||
creator: {
|
||||
id: string
|
||||
@@ -124,24 +138,46 @@ export default async function TemplatesPage() {
|
||||
row.creator?.referenceType === 'user' ? row.creator.referenceId : '' /* no owner context */
|
||||
|
||||
return {
|
||||
// New structure fields
|
||||
id: row.id,
|
||||
workflowId: row.workflowId,
|
||||
userId,
|
||||
name: row.name,
|
||||
description: row.details?.tagline ?? null,
|
||||
author: row.creator?.name ?? 'Unknown',
|
||||
authorType,
|
||||
organizationId,
|
||||
details: row.details as { tagline?: string; about?: string } | null,
|
||||
creatorId: row.creatorId,
|
||||
creator: row.creator
|
||||
? {
|
||||
id: row.creator.id,
|
||||
name: row.creator.name,
|
||||
profileImageUrl: row.creator.profileImageUrl,
|
||||
details: row.creator.details as {
|
||||
about?: string
|
||||
xUrl?: string
|
||||
linkedinUrl?: string
|
||||
websiteUrl?: string
|
||||
contactEmail?: string
|
||||
} | null,
|
||||
referenceType: row.creator.referenceType,
|
||||
referenceId: row.creator.referenceId,
|
||||
}
|
||||
: null,
|
||||
views: row.views,
|
||||
stars: row.stars,
|
||||
color: '#3972F6', // default color for workspace cards
|
||||
icon: 'Workflow', // default icon for workspace cards
|
||||
status: row.status,
|
||||
tags: row.tags,
|
||||
requiredCredentials: row.requiredCredentials,
|
||||
state: row.state as WorkspaceTemplate['state'],
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
isStarred: row.isStarred ?? false,
|
||||
isSuperUser: effectiveSuperUser,
|
||||
// Legacy fields for backward compatibility
|
||||
userId,
|
||||
description: (row.details as any)?.tagline ?? null,
|
||||
author: row.creator?.name ?? 'Unknown',
|
||||
authorType,
|
||||
organizationId,
|
||||
color: '#3972F6', // default color for workspace cards
|
||||
icon: 'Workflow', // default icon for workspace cards
|
||||
}
|
||||
}) ?? []
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Layout, Search } from 'lucide-react'
|
||||
import { Button } from '@/components/emcn'
|
||||
import { Input } from '@/components/ui/input'
|
||||
@@ -10,29 +10,48 @@ import {
|
||||
TemplateCardSkeleton,
|
||||
} from '@/app/workspace/[workspaceId]/templates/components/template-card'
|
||||
import type { WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
import type { CreatorProfileDetails } from '@/types/creator-profile'
|
||||
|
||||
const logger = createLogger('TemplatesPage')
|
||||
|
||||
// Template data structure
|
||||
/**
|
||||
* Template data structure with support for both new and legacy fields
|
||||
*/
|
||||
export interface Template {
|
||||
id: string
|
||||
workflowId: string | null
|
||||
userId: string
|
||||
name: string
|
||||
description: string | null
|
||||
author: string
|
||||
authorType: 'user' | 'organization'
|
||||
organizationId: string | null
|
||||
details?: {
|
||||
tagline?: string
|
||||
about?: string
|
||||
} | null
|
||||
creatorId: string | null
|
||||
creator?: {
|
||||
id: string
|
||||
name: string
|
||||
profileImageUrl?: string | null
|
||||
details?: CreatorProfileDetails | null
|
||||
referenceType: 'user' | 'organization'
|
||||
referenceId: string
|
||||
} | null
|
||||
views: number
|
||||
stars: number
|
||||
color: string
|
||||
icon: string
|
||||
status: 'pending' | 'approved' | 'rejected'
|
||||
tags: string[]
|
||||
requiredCredentials: unknown
|
||||
state: WorkflowState
|
||||
createdAt: Date | string
|
||||
updatedAt: Date | string
|
||||
isStarred: boolean
|
||||
isSuperUser?: boolean
|
||||
// Legacy fields for backward compatibility with existing UI
|
||||
userId?: string
|
||||
description?: string | null
|
||||
author?: string
|
||||
authorType?: 'user' | 'organization'
|
||||
organizationId?: string | null
|
||||
color?: string
|
||||
icon?: string
|
||||
}
|
||||
|
||||
interface TemplatesProps {
|
||||
@@ -41,21 +60,23 @@ interface TemplatesProps {
|
||||
isSuperUser: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Templates list component displaying workflow templates
|
||||
* Supports filtering by tab (gallery/your/pending) and search
|
||||
*/
|
||||
export default function Templates({
|
||||
initialTemplates,
|
||||
currentUserId,
|
||||
isSuperUser,
|
||||
}: TemplatesProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [activeTab, setActiveTab] = useState('your')
|
||||
const [activeTab, setActiveTab] = useState('gallery')
|
||||
const [templates, setTemplates] = useState<Template[]>(initialTemplates)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleTabClick = (tabId: string) => {
|
||||
setActiveTab(tabId)
|
||||
}
|
||||
|
||||
// Handle star change callback from template card
|
||||
/**
|
||||
* Update star status for a template
|
||||
*/
|
||||
const handleStarChange = (templateId: string, isStarred: boolean, newStarCount: number) => {
|
||||
setTemplates((prevTemplates) =>
|
||||
prevTemplates.map((template) =>
|
||||
@@ -64,98 +85,76 @@ export default function Templates({
|
||||
)
|
||||
}
|
||||
|
||||
// Get templates for the active tab with search filtering
|
||||
const getActiveTabTemplates = () => {
|
||||
let filtered = templates
|
||||
/**
|
||||
* Filter templates based on active tab and search query
|
||||
* Memoized to prevent unnecessary recalculations on render
|
||||
*/
|
||||
const filteredTemplates = useMemo(() => {
|
||||
const query = searchQuery.toLowerCase()
|
||||
|
||||
// Filter by active tab
|
||||
if (activeTab === 'your') {
|
||||
filtered = filtered.filter(
|
||||
(template) => template.userId === currentUserId || template.isStarred === true
|
||||
)
|
||||
} else if (activeTab === 'gallery') {
|
||||
// Show all approved templates
|
||||
filtered = filtered.filter((template) => template.status === 'approved')
|
||||
} else if (activeTab === 'pending') {
|
||||
// Show pending templates for super users
|
||||
filtered = filtered.filter((template) => template.status === 'pending')
|
||||
}
|
||||
return templates.filter((template) => {
|
||||
// Filter by tab
|
||||
const tabMatch =
|
||||
activeTab === 'your'
|
||||
? template.userId === currentUserId || template.isStarred
|
||||
: activeTab === 'gallery'
|
||||
? template.status === 'approved'
|
||||
: template.status === 'pending'
|
||||
|
||||
// Apply search filter
|
||||
if (!tabMatch) return false
|
||||
|
||||
// Filter by search query
|
||||
if (!query) return true
|
||||
|
||||
const searchableText = [
|
||||
template.name,
|
||||
template.description,
|
||||
template.details?.tagline,
|
||||
template.author,
|
||||
template.creator?.name,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
|
||||
return searchableText.includes(query)
|
||||
})
|
||||
}, [templates, activeTab, searchQuery, currentUserId])
|
||||
|
||||
/**
|
||||
* Get empty state message based on current filters
|
||||
* Memoized to prevent unnecessary recalculations on render
|
||||
*/
|
||||
const emptyState = useMemo(() => {
|
||||
if (searchQuery) {
|
||||
filtered = filtered.filter(
|
||||
(template) =>
|
||||
template.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
template.description?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
template.author.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
return {
|
||||
title: 'No templates found',
|
||||
description: 'Try a different search term',
|
||||
}
|
||||
}
|
||||
|
||||
return filtered
|
||||
}
|
||||
const messages = {
|
||||
pending: {
|
||||
title: 'No pending templates',
|
||||
description: 'New submissions will appear here',
|
||||
},
|
||||
your: {
|
||||
title: 'No templates yet',
|
||||
description: 'Create or star templates to see them here',
|
||||
},
|
||||
gallery: {
|
||||
title: 'No templates available',
|
||||
description: 'Templates will appear once approved',
|
||||
},
|
||||
}
|
||||
|
||||
const activeTemplates = getActiveTabTemplates()
|
||||
|
||||
// Helper function to render template cards
|
||||
const renderTemplateCard = (template: Template) => (
|
||||
<TemplateCard
|
||||
key={template.id}
|
||||
id={template.id}
|
||||
title={template.name}
|
||||
description={template.description || ''}
|
||||
author={template.author}
|
||||
usageCount={template.views.toString()}
|
||||
stars={template.stars}
|
||||
icon={template.icon}
|
||||
iconColor={template.color}
|
||||
state={template.state}
|
||||
isStarred={template.isStarred}
|
||||
onStarChange={handleStarChange}
|
||||
isAuthenticated={true}
|
||||
/>
|
||||
)
|
||||
|
||||
// Render skeleton cards for loading state
|
||||
const renderSkeletonCards = () => {
|
||||
return Array.from({ length: 8 }).map((_, index) => (
|
||||
<TemplateCardSkeleton key={`skeleton-${index}`} />
|
||||
))
|
||||
}
|
||||
|
||||
// Calculate counts for tabs
|
||||
const yourTemplatesCount = templates.filter(
|
||||
(template) => template.userId === currentUserId || template.isStarred === true
|
||||
).length
|
||||
const galleryCount = templates.filter((template) => template.status === 'approved').length
|
||||
const pendingCount = templates.filter((template) => template.status === 'pending').length
|
||||
|
||||
const navigationTabs = [
|
||||
{
|
||||
id: 'gallery',
|
||||
label: 'Gallery',
|
||||
count: galleryCount,
|
||||
},
|
||||
{
|
||||
id: 'your',
|
||||
label: 'Your Templates',
|
||||
count: yourTemplatesCount,
|
||||
},
|
||||
...(isSuperUser
|
||||
? [
|
||||
{
|
||||
id: 'pending',
|
||||
label: 'Pending',
|
||||
count: pendingCount,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]
|
||||
return messages[activeTab as keyof typeof messages] || messages.gallery
|
||||
}, [searchQuery, activeTab])
|
||||
|
||||
return (
|
||||
<div className='flex h-[100vh] flex-col pl-64'>
|
||||
<div className='flex flex-1 overflow-hidden'>
|
||||
<div className='flex flex-1 flex-col overflow-auto px-[24px] pt-[24px] pb-[24px]'>
|
||||
{/* Header */}
|
||||
<div>
|
||||
<div className='flex items-start gap-[12px]'>
|
||||
<div className='flex h-[26px] w-[26px] items-center justify-center rounded-[6px] border border-[#7A5F11] bg-[#514215]'>
|
||||
@@ -168,7 +167,6 @@ export default function Templates({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Search and Badges */}
|
||||
<div className='mt-[14px] flex items-center justify-between'>
|
||||
<div className='flex h-[32px] w-[400px] items-center gap-[6px] rounded-[8px] bg-[var(--surface-5)] px-[8px]'>
|
||||
<Search className='h-[14px] w-[14px] text-[var(--text-subtle)]' />
|
||||
@@ -183,52 +181,67 @@ export default function Templates({
|
||||
<Button
|
||||
variant={activeTab === 'gallery' ? 'active' : 'default'}
|
||||
className='h-[32px] rounded-[6px]'
|
||||
onClick={() => handleTabClick('gallery')}
|
||||
onClick={() => setActiveTab('gallery')}
|
||||
>
|
||||
Gallery
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeTab === 'your' ? 'active' : 'default'}
|
||||
className='h-[32px] rounded-[6px]'
|
||||
onClick={() => handleTabClick('your')}
|
||||
onClick={() => setActiveTab('your')}
|
||||
>
|
||||
Your Templates
|
||||
</Button>
|
||||
{isSuperUser && (
|
||||
<Button
|
||||
variant={activeTab === 'pending' ? 'active' : 'default'}
|
||||
className='h-[32px] rounded-[6px]'
|
||||
onClick={() => setActiveTab('pending')}
|
||||
>
|
||||
Pending
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className='mt-[24px] h-[1px] w-full border-[var(--border)] border-t' />
|
||||
|
||||
{/* Templates Grid - Based on Active Tab */}
|
||||
<div className='mt-[24px] grid grid-cols-1 gap-x-[20px] gap-y-[40px] md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'>
|
||||
{loading ? (
|
||||
renderSkeletonCards()
|
||||
) : activeTemplates.length === 0 ? (
|
||||
Array.from({ length: 8 }).map((_, index) => (
|
||||
<TemplateCardSkeleton key={`skeleton-${index}`} />
|
||||
))
|
||||
) : filteredTemplates.length === 0 ? (
|
||||
<div className='col-span-full flex h-64 items-center justify-center rounded-lg border border-muted-foreground/25 border-dashed bg-muted/20'>
|
||||
<div className='text-center'>
|
||||
<p className='font-medium text-muted-foreground text-sm'>
|
||||
{searchQuery
|
||||
? 'No templates found'
|
||||
: activeTab === 'pending'
|
||||
? 'No pending templates'
|
||||
: activeTab === 'your'
|
||||
? 'No templates yet'
|
||||
: 'No templates available'}
|
||||
</p>
|
||||
<p className='mt-1 text-muted-foreground/70 text-xs'>
|
||||
{searchQuery
|
||||
? 'Try a different search term'
|
||||
: activeTab === 'pending'
|
||||
? 'New submissions will appear here'
|
||||
: activeTab === 'your'
|
||||
? 'Create or star templates to see them here'
|
||||
: 'Templates will appear once approved'}
|
||||
</p>
|
||||
<p className='font-medium text-muted-foreground text-sm'>{emptyState.title}</p>
|
||||
<p className='mt-1 text-muted-foreground/70 text-xs'>{emptyState.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
activeTemplates.map((template) => renderTemplateCard(template))
|
||||
filteredTemplates.map((template) => {
|
||||
const author = template.author || template.creator?.name || 'Unknown'
|
||||
const authorImageUrl = template.creator?.profileImageUrl || null
|
||||
|
||||
return (
|
||||
<TemplateCard
|
||||
key={template.id}
|
||||
id={template.id}
|
||||
title={template.name}
|
||||
description={template.description || template.details?.tagline || ''}
|
||||
author={author}
|
||||
authorImageUrl={authorImageUrl}
|
||||
usageCount={template.views.toString()}
|
||||
stars={template.stars}
|
||||
icon={template.icon}
|
||||
iconColor={template.color}
|
||||
state={template.state}
|
||||
isStarred={template.isStarred}
|
||||
onStarChange={handleStarChange}
|
||||
isAuthenticated={true}
|
||||
/>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -569,7 +569,7 @@ export function Chat() {
|
||||
|
||||
return (
|
||||
<div
|
||||
className='fixed z-30 flex flex-col overflow-hidden rounded-[6px] bg-[var(--surface-1)] px-[10px] pt-[2px] pb-[8px]'
|
||||
className='fixed z-30 flex flex-col overflow-hidden rounded-[6px] border border-[var(--border)] bg-[var(--surface-1)] px-[10px] pt-[2px] pb-[8px]'
|
||||
style={{
|
||||
left: `${actualPosition.x}px`,
|
||||
top: `${actualPosition.y}px`,
|
||||
@@ -619,6 +619,7 @@ export function Chat() {
|
||||
side='bottom'
|
||||
align='end'
|
||||
sideOffset={8}
|
||||
maxHeight={100}
|
||||
style={{ width: '110px', minWidth: '110px' }}
|
||||
>
|
||||
<PopoverScrollArea>
|
||||
|
||||
@@ -290,7 +290,9 @@ export function OutputSelect({
|
||||
side='bottom'
|
||||
align='start'
|
||||
sideOffset={4}
|
||||
maxHeight={280}
|
||||
maxHeight={140}
|
||||
maxWidth={140}
|
||||
minWidth={140}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
@@ -298,26 +300,29 @@ export function OutputSelect({
|
||||
{Object.entries(groupedOutputs).map(([blockName, outputs]) => (
|
||||
<div key={blockName}>
|
||||
<PopoverSection>{blockName}</PopoverSection>
|
||||
{outputs.map((output) => (
|
||||
<PopoverItem
|
||||
key={output.id}
|
||||
active={isSelectedValue(output)}
|
||||
onClick={() => handleOutputSelection(output.label)}
|
||||
>
|
||||
<div
|
||||
className='flex h-[14px] w-[14px] flex-shrink-0 items-center justify-center rounded'
|
||||
style={{
|
||||
backgroundColor: getOutputColor(output.blockId, output.blockType),
|
||||
}}
|
||||
|
||||
<div className='flex flex-col gap-[2px]'>
|
||||
{outputs.map((output) => (
|
||||
<PopoverItem
|
||||
key={output.id}
|
||||
active={isSelectedValue(output)}
|
||||
onClick={() => handleOutputSelection(output.label)}
|
||||
>
|
||||
<span className='font-bold text-[10px] text-white'>
|
||||
{blockName.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<span className='min-w-0 flex-1 truncate'>{output.path}</span>
|
||||
{isSelectedValue(output) && <Check className='h-3 w-3 flex-shrink-0' />}
|
||||
</PopoverItem>
|
||||
))}
|
||||
<div
|
||||
className='flex h-[14px] w-[14px] flex-shrink-0 items-center justify-center rounded'
|
||||
style={{
|
||||
backgroundColor: getOutputColor(output.blockId, output.blockType),
|
||||
}}
|
||||
>
|
||||
<span className='font-bold text-[10px] text-white'>
|
||||
{blockName.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<span className='min-w-0 flex-1 truncate'>{output.path}</span>
|
||||
{isSelectedValue(output) && <Check className='h-3 w-3 flex-shrink-0' />}
|
||||
</PopoverItem>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</PopoverScrollArea>
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback } from 'react'
|
||||
import { Layout, LibraryBig, Search } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { Button } from '@/components/emcn'
|
||||
import { AgentIcon } from '@/components/icons'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useSearchModalStore } from '@/stores/search-modal/store'
|
||||
|
||||
const logger = createLogger('WorkflowCommandList')
|
||||
|
||||
/**
|
||||
* Command item data structure
|
||||
@@ -49,13 +55,131 @@ const commands: CommandItem[] = [
|
||||
* Centered on the screen for empty workflows
|
||||
*/
|
||||
export function CommandList() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const { open: openSearchModal } = useSearchModalStore()
|
||||
|
||||
const workspaceId = params.workspaceId as string | undefined
|
||||
|
||||
/**
|
||||
* Handle click on a command row.
|
||||
*
|
||||
* Mirrors the behavior of the corresponding global keyboard shortcuts:
|
||||
* - Templates: navigate to workspace templates
|
||||
* - New Agent: add an agent block to the canvas
|
||||
* - Logs: navigate to workspace logs
|
||||
* - Search Blocks: open the universal search modal
|
||||
*
|
||||
* @param label - Command label that was clicked.
|
||||
*/
|
||||
const handleCommandClick = useCallback(
|
||||
(label: string) => {
|
||||
try {
|
||||
switch (label) {
|
||||
case 'Templates': {
|
||||
if (!workspaceId) {
|
||||
logger.warn('No workspace ID found, cannot navigate to templates from command list')
|
||||
return
|
||||
}
|
||||
router.push(`/workspace/${workspaceId}/templates`)
|
||||
return
|
||||
}
|
||||
case 'New Agent': {
|
||||
const event = new CustomEvent('add-block-from-toolbar', {
|
||||
detail: { type: 'agent', enableTriggerMode: false },
|
||||
})
|
||||
window.dispatchEvent(event)
|
||||
return
|
||||
}
|
||||
case 'Logs': {
|
||||
if (!workspaceId) {
|
||||
logger.warn('No workspace ID found, cannot navigate to logs from command list')
|
||||
return
|
||||
}
|
||||
router.push(`/workspace/${workspaceId}/logs`)
|
||||
return
|
||||
}
|
||||
case 'Search Blocks': {
|
||||
openSearchModal()
|
||||
return
|
||||
}
|
||||
default:
|
||||
logger.warn('Unknown command label clicked in command list', { label })
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to handle command click in command list', { error, label })
|
||||
}
|
||||
},
|
||||
[router, workspaceId, openSearchModal]
|
||||
)
|
||||
|
||||
/**
|
||||
* Handle drag-over events from the toolbar.
|
||||
*
|
||||
* When a toolbar item is dragged over the command list, mark the drop as valid
|
||||
* so the browser shows the appropriate drop cursor. Only reacts to toolbar
|
||||
* drags that carry the expected JSON payload.
|
||||
*
|
||||
* @param event - Drag event from the browser.
|
||||
*/
|
||||
const handleDragOver = useCallback((event: React.DragEvent<HTMLDivElement>) => {
|
||||
if (!event.dataTransfer?.types.includes('application/json')) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
event.dataTransfer.dropEffect = 'move'
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Handle drops of toolbar items onto the command list.
|
||||
*
|
||||
* This forwards the drop information (block type and cursor position)
|
||||
* to the workflow canvas via a custom event. The workflow component
|
||||
* then reuses its existing drop logic to place the block precisely
|
||||
* under the cursor, including container/subflow handling.
|
||||
*
|
||||
* @param event - Drop event from the browser.
|
||||
*/
|
||||
const handleDrop = useCallback((event: React.DragEvent<HTMLDivElement>) => {
|
||||
if (!event.dataTransfer?.types.includes('application/json')) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
|
||||
try {
|
||||
const raw = event.dataTransfer.getData('application/json')
|
||||
if (!raw) return
|
||||
|
||||
const data = JSON.parse(raw) as { type?: string; enableTriggerMode?: boolean }
|
||||
if (!data?.type || data.type === 'connectionBlock') return
|
||||
|
||||
const overlayDropEvent = new CustomEvent('toolbar-drop-on-empty-workflow-overlay', {
|
||||
detail: {
|
||||
type: data.type,
|
||||
enableTriggerMode: data.enableTriggerMode ?? false,
|
||||
clientX: event.clientX,
|
||||
clientY: event.clientY,
|
||||
},
|
||||
})
|
||||
|
||||
window.dispatchEvent(overlayDropEvent)
|
||||
} catch (error) {
|
||||
logger.error('Failed to handle drop on command list', { error })
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-none absolute inset-0 mb-[50px] flex items-center justify-center'
|
||||
)}
|
||||
>
|
||||
<div className='pointer-events-none flex flex-col gap-[8px]'>
|
||||
<div
|
||||
className='pointer-events-auto flex flex-col gap-[8px]'
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{/* Logo */}
|
||||
<div className='mb-[20px] flex justify-center'>
|
||||
<Image
|
||||
@@ -79,6 +203,7 @@ export function CommandList() {
|
||||
<div
|
||||
key={command.label}
|
||||
className='group flex cursor-pointer items-center justify-between gap-[60px]'
|
||||
onClick={() => handleCommandClick(command.label)}
|
||||
>
|
||||
{/* Left side: Icon and Label */}
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
@@ -91,7 +216,7 @@ export function CommandList() {
|
||||
{/* Right side: Keyboard Shortcut */}
|
||||
<div className='flex items-center gap-[4px]'>
|
||||
<Button
|
||||
className='group-hover:-translate-y-0.5 w-[26px] py-[3px] text-[12px] hover:translate-y-0 hover:text-[var(--text-tertiary)] hover:shadow-[0_2px_0_0] group-hover:text-[var(--text-primary)] group-hover:shadow-[0_4px_0_0]'
|
||||
className='group-hover:-translate-y-0.5 w-[26px] py-[3px] text-[12px] hover:translate-y-0 hover:text-[var(--text-tertiary)] hover:shadow-[0_2px_0_0_rgba(48,48,48,1)] group-hover:text-[var(--text-primary)] group-hover:shadow-[0_4px_0_0_rgba(48,48,48,1)]'
|
||||
variant='3d'
|
||||
>
|
||||
<span>⌘</span>
|
||||
@@ -99,7 +224,7 @@ export function CommandList() {
|
||||
{shortcuts.map((key, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
className='group-hover:-translate-y-0.5 w-[26px] py-[3px] text-[12px] hover:translate-y-0 hover:text-[var(--text-tertiary)] hover:shadow-[0_2px_0_0] group-hover:text-[var(--text-primary)] group-hover:shadow-[0_4px_0_0]'
|
||||
className='group-hover:-translate-y-0.5 w-[26px] py-[3px] text-[12px] hover:translate-y-0 hover:text-[var(--text-tertiary)] hover:shadow-[0_2px_0_0_rgba(48,48,48,1)] group-hover:text-[var(--text-primary)] group-hover:shadow-[0_4px_0_0_rgba(48,48,48,1)]'
|
||||
variant='3d'
|
||||
>
|
||||
{key}
|
||||
|
||||
@@ -196,7 +196,7 @@ export function ExampleCommand({
|
||||
<ChevronDown className='ml-1 h-3 w-3 flex-shrink-0' />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end'>
|
||||
<DropdownMenuContent align='end' className='z-[10000050]'>
|
||||
<DropdownMenuItem
|
||||
className='cursor-pointer'
|
||||
onClick={() => setExampleType('execute')}
|
||||
|
||||
@@ -45,6 +45,7 @@ interface DeploymentInfoProps {
|
||||
getInputFormatExample?: (includeStreaming?: boolean) => string
|
||||
selectedStreamingOutputs: string[]
|
||||
onSelectedStreamingOutputsChange: (outputs: string[]) => void
|
||||
onLoadDeploymentComplete: () => void
|
||||
}
|
||||
|
||||
export function DeploymentInfo({
|
||||
@@ -60,6 +61,7 @@ export function DeploymentInfo({
|
||||
getInputFormatExample,
|
||||
selectedStreamingOutputs,
|
||||
onSelectedStreamingOutputsChange,
|
||||
onLoadDeploymentComplete,
|
||||
}: DeploymentInfoProps) {
|
||||
const [isViewingDeployed, setIsViewingDeployed] = useState(false)
|
||||
|
||||
@@ -174,6 +176,7 @@ export function DeploymentInfo({
|
||||
needsRedeployment={deploymentInfo.needsRedeployment}
|
||||
activeDeployedState={deployedState}
|
||||
workflowId={workflowId}
|
||||
onLoadDeploymentComplete={onLoadDeploymentComplete}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { CheckCircle2, Loader2, Plus, Trash2 } from 'lucide-react'
|
||||
import { CheckCircle2, Loader2, Plus } from 'lucide-react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { z } from 'zod'
|
||||
import { Badge, Button, Input, Textarea, Trash } from '@/components/emcn'
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
@@ -17,13 +17,11 @@ import {
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Input,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Textarea,
|
||||
} from '@/components/ui'
|
||||
import { TagInput } from '@/components/ui/tag-input'
|
||||
import { useSession } from '@/lib/auth-client'
|
||||
@@ -78,33 +76,66 @@ export function TemplateDeploy({ workflowId, onDeploymentComplete }: TemplateDep
|
||||
})
|
||||
|
||||
// Fetch creator profiles
|
||||
useEffect(() => {
|
||||
const fetchCreatorOptions = async () => {
|
||||
if (!session?.user?.id) return
|
||||
const fetchCreatorOptions = async () => {
|
||||
if (!session?.user?.id) return
|
||||
|
||||
setLoadingCreators(true)
|
||||
try {
|
||||
const response = await fetch('/api/creator-profiles')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
const profiles = (data.profiles || []).map((profile: any) => ({
|
||||
id: profile.id,
|
||||
name: profile.name,
|
||||
referenceType: profile.referenceType,
|
||||
referenceId: profile.referenceId,
|
||||
}))
|
||||
setCreatorOptions(profiles)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching creator profiles:', error)
|
||||
} finally {
|
||||
setLoadingCreators(false)
|
||||
setLoadingCreators(true)
|
||||
try {
|
||||
const response = await fetch('/api/creator-profiles')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
const profiles = (data.profiles || []).map((profile: any) => ({
|
||||
id: profile.id,
|
||||
name: profile.name,
|
||||
referenceType: profile.referenceType,
|
||||
referenceId: profile.referenceId,
|
||||
}))
|
||||
setCreatorOptions(profiles)
|
||||
return profiles
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching creator profiles:', error)
|
||||
} finally {
|
||||
setLoadingCreators(false)
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchCreatorOptions()
|
||||
}, [session?.user?.id])
|
||||
|
||||
// Auto-select creator profile when there's only one option and no selection yet
|
||||
useEffect(() => {
|
||||
const currentCreatorId = form.getValues('creatorId')
|
||||
if (creatorOptions.length === 1 && !currentCreatorId) {
|
||||
form.setValue('creatorId', creatorOptions[0].id)
|
||||
logger.info('Auto-selected single creator profile:', creatorOptions[0].name)
|
||||
}
|
||||
}, [creatorOptions, form])
|
||||
|
||||
// Listen for creator profile saved event
|
||||
useEffect(() => {
|
||||
const handleCreatorProfileSaved = async () => {
|
||||
logger.info('Creator profile saved, refreshing profiles...')
|
||||
|
||||
// Refetch creator profiles (autoselection will happen via the effect above)
|
||||
await fetchCreatorOptions()
|
||||
|
||||
// Close settings modal and reopen deploy modal to template tab
|
||||
window.dispatchEvent(new CustomEvent('close-settings'))
|
||||
setTimeout(() => {
|
||||
window.dispatchEvent(new CustomEvent('open-deploy-modal', { detail: { tab: 'template' } }))
|
||||
}, 100)
|
||||
}
|
||||
|
||||
window.addEventListener('creator-profile-saved', handleCreatorProfileSaved)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('creator-profile-saved', handleCreatorProfileSaved)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Check for existing template
|
||||
useEffect(() => {
|
||||
const checkExistingTemplate = async () => {
|
||||
@@ -240,18 +271,23 @@ export function TemplateDeploy({ workflowId, onDeploymentComplete }: TemplateDep
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
{existingTemplate && (
|
||||
<div className='flex items-center justify-between rounded-lg border border-border/50 bg-muted/30 px-4 py-3'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<CheckCircle2 className='h-4 w-4 text-green-600 dark:text-green-400' />
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='font-medium text-sm'>Template Connected</span>
|
||||
<div className='flex items-center justify-between rounded-[8px] border border-[var(--border)] bg-[var(--surface-3)] px-[16px] py-[12px]'>
|
||||
<div className='flex items-center gap-[12px]'>
|
||||
<CheckCircle2 className='h-[16px] w-[16px] text-green-600 dark:text-green-400' />
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
<span className='font-medium text-[14px] text-[var(--text-primary)]'>
|
||||
Template Connected
|
||||
</span>
|
||||
{existingTemplate.status === 'pending' && (
|
||||
<span className='rounded-md bg-yellow-100 px-2 py-0.5 font-medium text-xs text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300'>
|
||||
<Badge
|
||||
variant='outline'
|
||||
className='border-yellow-300 bg-yellow-100 text-yellow-700 dark:border-yellow-700 dark:bg-yellow-900 dark:text-yellow-300'
|
||||
>
|
||||
Under Review
|
||||
</span>
|
||||
</Badge>
|
||||
)}
|
||||
{existingTemplate.status === 'approved' && existingTemplate.views > 0 && (
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||
• {existingTemplate.views} views
|
||||
{existingTemplate.stars > 0 && ` • ${existingTemplate.stars} stars`}
|
||||
</span>
|
||||
@@ -261,11 +297,10 @@ export function TemplateDeploy({ workflowId, onDeploymentComplete }: TemplateDep
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => setShowDeleteDialog(true)}
|
||||
className='h-8 px-2 text-muted-foreground hover:text-red-600 dark:hover:text-red-400'
|
||||
className='h-[32px] px-[8px] text-[var(--text-muted)] hover:text-red-600 dark:hover:text-red-400'
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
<Trash className='h-[14px] w-[14px]' />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -329,8 +364,6 @@ export function TemplateDeploy({ workflowId, onDeploymentComplete }: TemplateDep
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='gap-2'
|
||||
onClick={() => {
|
||||
try {
|
||||
const event = new CustomEvent('open-settings', {
|
||||
@@ -344,9 +377,10 @@ export function TemplateDeploy({ workflowId, onDeploymentComplete }: TemplateDep
|
||||
})
|
||||
}
|
||||
}}
|
||||
className='gap-[8px]'
|
||||
>
|
||||
<Plus className='h-4 w-4 text-muted-foreground' />
|
||||
<span className='text-muted-foreground'>Create a Creator Profile</span>
|
||||
<Plus className='h-[14px] w-[14px] text-[var(--text-muted)]' />
|
||||
<span className='text-[var(--text-muted)]'>Create a Creator Profile</span>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
@@ -399,7 +433,7 @@ export function TemplateDeploy({ workflowId, onDeploymentComplete }: TemplateDep
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className='flex justify-end gap-2 border-t pt-4'>
|
||||
<div className='flex justify-end gap-[8px] border-[var(--border)] border-t pt-[16px]'>
|
||||
{existingTemplate && (
|
||||
<Button
|
||||
type='button'
|
||||
@@ -412,12 +446,12 @@ export function TemplateDeploy({ workflowId, onDeploymentComplete }: TemplateDep
|
||||
)}
|
||||
<Button
|
||||
type='submit'
|
||||
variant='primary'
|
||||
disabled={isSubmitting || !form.formState.isValid}
|
||||
className='bg-purple-600 hover:bg-purple-700'
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
||||
<Loader2 className='mr-[8px] h-[14px] w-[14px] animate-spin' />
|
||||
{existingTemplate ? 'Updating...' : 'Publishing...'}
|
||||
</>
|
||||
) : existingTemplate ? (
|
||||
@@ -432,19 +466,21 @@ export function TemplateDeploy({ workflowId, onDeploymentComplete }: TemplateDep
|
||||
|
||||
{showDeleteDialog && (
|
||||
<div className='fixed inset-0 z-50 flex items-center justify-center bg-black/50'>
|
||||
<div className='w-full max-w-md rounded-lg bg-background p-6 shadow-lg'>
|
||||
<h3 className='mb-4 font-semibold text-lg'>Delete Template?</h3>
|
||||
<p className='mb-6 text-muted-foreground text-sm'>
|
||||
<div className='w-full max-w-md rounded-[8px] bg-[var(--surface-3)] p-[24px] shadow-lg'>
|
||||
<h3 className='mb-[16px] font-semibold text-[18px] text-[var(--text-primary)]'>
|
||||
Delete Template?
|
||||
</h3>
|
||||
<p className='mb-[24px] text-[14px] text-[var(--text-secondary)]'>
|
||||
This will permanently delete your template. This action cannot be undone.
|
||||
</p>
|
||||
<div className='flex justify-end gap-2'>
|
||||
<div className='flex justify-end gap-[8px]'>
|
||||
<Button variant='outline' onClick={() => setShowDeleteDialog(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
className='bg-red-600 hover:bg-red-700'
|
||||
className='bg-red-600 text-white hover:bg-red-700'
|
||||
>
|
||||
{isDeleting ? 'Deleting...' : 'Delete'}
|
||||
</Button>
|
||||
@@ -454,12 +490,12 @@ export function TemplateDeploy({ workflowId, onDeploymentComplete }: TemplateDep
|
||||
)}
|
||||
|
||||
{/* Template State Preview Dialog */}
|
||||
{showPreviewDialog && (
|
||||
<Dialog open={showPreviewDialog} onOpenChange={setShowPreviewDialog}>
|
||||
<DialogContent className='max-h-[80vh] max-w-5xl overflow-auto'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Template State Preview</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Dialog open={showPreviewDialog} onOpenChange={setShowPreviewDialog}>
|
||||
<DialogContent className='max-h-[80vh] max-w-5xl overflow-auto'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Published Template Preview</DialogTitle>
|
||||
</DialogHeader>
|
||||
{showPreviewDialog && (
|
||||
<div className='mt-4'>
|
||||
{(() => {
|
||||
if (!existingTemplate?.state || !existingTemplate.state.blocks) {
|
||||
@@ -487,7 +523,7 @@ export function TemplateDeploy({ workflowId, onDeploymentComplete }: TemplateDep
|
||||
return (
|
||||
<div className='h-[500px] w-full'>
|
||||
<WorkflowPreview
|
||||
key={`template-preview-${existingTemplate.id}-${Date.now()}`}
|
||||
key={`template-preview-${existingTemplate.id}`}
|
||||
workflowState={workflowState}
|
||||
showSubBlocks={true}
|
||||
height='100%'
|
||||
@@ -497,9 +533,9 @@ export function TemplateDeploy({ workflowId, onDeploymentComplete }: TemplateDep
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -445,6 +445,23 @@ export function DeployModal({
|
||||
}
|
||||
}, [open, selectedStreamingOutputs, setSelectedStreamingOutputs])
|
||||
|
||||
// Listen for event to reopen deploy modal
|
||||
useEffect(() => {
|
||||
const handleOpenDeployModal = (event: Event) => {
|
||||
const customEvent = event as CustomEvent<{ tab?: TabView }>
|
||||
onOpenChange(true)
|
||||
if (customEvent.detail?.tab) {
|
||||
setActiveTab(customEvent.detail.tab)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('open-deploy-modal', handleOpenDeployModal)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('open-deploy-modal', handleOpenDeployModal)
|
||||
}
|
||||
}, [onOpenChange])
|
||||
|
||||
const handleActivateVersion = (version: number) => {
|
||||
setVersionToActivate(version)
|
||||
setActiveTab('api')
|
||||
@@ -767,6 +784,7 @@ export function DeployModal({
|
||||
getInputFormatExample={getInputFormatExample}
|
||||
selectedStreamingOutputs={selectedStreamingOutputs}
|
||||
onSelectedStreamingOutputsChange={setSelectedStreamingOutputs}
|
||||
onLoadDeploymentComplete={handleCloseModal}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -1045,6 +1063,7 @@ export function DeployModal({
|
||||
}
|
||||
workflowId={workflowId}
|
||||
isSelectedVersionActive={versions.find((v) => v.version === previewVersion)?.isActive}
|
||||
onLoadDeploymentComplete={handleCloseModal}
|
||||
/>
|
||||
)}
|
||||
</Dialog>
|
||||
|
||||
@@ -35,6 +35,7 @@ interface DeployedWorkflowModalProps {
|
||||
selectedVersionLabel?: string
|
||||
workflowId: string
|
||||
isSelectedVersionActive?: boolean
|
||||
onLoadDeploymentComplete?: () => void
|
||||
}
|
||||
|
||||
export function DeployedWorkflowModal({
|
||||
@@ -49,6 +50,7 @@ export function DeployedWorkflowModal({
|
||||
selectedVersionLabel,
|
||||
workflowId,
|
||||
isSelectedVersionActive,
|
||||
onLoadDeploymentComplete,
|
||||
}: DeployedWorkflowModalProps) {
|
||||
const [showRevertDialog, setShowRevertDialog] = useState(false)
|
||||
const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId)
|
||||
@@ -82,6 +84,7 @@ export function DeployedWorkflowModal({
|
||||
|
||||
setShowRevertDialog(false)
|
||||
onClose()
|
||||
onLoadDeploymentComplete?.()
|
||||
} catch (error) {
|
||||
logger.error('Failed to revert workflow:', error)
|
||||
}
|
||||
@@ -91,7 +94,7 @@ export function DeployedWorkflowModal({
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent
|
||||
className='max-h-[100vh] overflow-y-auto sm:max-w-[1100px]'
|
||||
style={{ zIndex: 1000 }}
|
||||
style={{ zIndex: 10000020 }}
|
||||
hideCloseButton={true}
|
||||
>
|
||||
<div className='sr-only'>
|
||||
@@ -136,7 +139,7 @@ export function DeployedWorkflowModal({
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant='outline'>Load Deployment</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent style={{ zIndex: 1001 }} className='sm:max-w-[425px]'>
|
||||
<AlertDialogContent className='sm:max-w-[425px]'>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Load this Deployment?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
|
||||
@@ -2,5 +2,4 @@ export { DeployModal } from './deploy-modal/deploy-modal'
|
||||
export { DeploymentControls } from './deployment-controls/deployment-controls'
|
||||
export { ExportControls } from './export-controls/export-controls'
|
||||
export { TemplateModal } from './template-modal/template-modal'
|
||||
export { UserAvatarStack } from './user-avatar-stack/user-avatar-stack'
|
||||
export { WebhookSettings } from './webhook-settings/webhook-settings'
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { type CSSProperties, useMemo } from 'react'
|
||||
import Image from 'next/image'
|
||||
import { Tooltip } from '@/components/emcn'
|
||||
import { getPresenceColors } from '@/lib/collaboration/presence-colors'
|
||||
|
||||
interface AvatarProps {
|
||||
connectionId: string | number
|
||||
name?: string
|
||||
color?: string
|
||||
avatarUrl?: string | null
|
||||
tooltipContent?: React.ReactNode | null
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
index?: number // Position in stack for z-index
|
||||
}
|
||||
|
||||
export function UserAvatar({
|
||||
connectionId,
|
||||
name,
|
||||
color,
|
||||
avatarUrl,
|
||||
tooltipContent,
|
||||
size = 'md',
|
||||
index = 0,
|
||||
}: AvatarProps) {
|
||||
const { gradient } = useMemo(() => getPresenceColors(connectionId, color), [connectionId, color])
|
||||
|
||||
const sizeClass = {
|
||||
sm: 'h-5 w-5 text-[10px]',
|
||||
md: 'h-7 w-7 text-xs',
|
||||
lg: 'h-9 w-9 text-sm',
|
||||
}[size]
|
||||
|
||||
const pixelSize = {
|
||||
sm: 20,
|
||||
md: 28,
|
||||
lg: 36,
|
||||
}[size]
|
||||
|
||||
const initials = name ? name.charAt(0).toUpperCase() : '?'
|
||||
const hasAvatar = Boolean(avatarUrl)
|
||||
|
||||
const avatarElement = (
|
||||
<div
|
||||
className={`
|
||||
${sizeClass} relative flex flex-shrink-0 cursor-default items-center justify-center overflow-hidden rounded-full border-2 border-white font-semibold text-white shadow-sm `}
|
||||
style={
|
||||
{
|
||||
background: hasAvatar ? undefined : gradient,
|
||||
zIndex: 10 - index,
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
{hasAvatar && avatarUrl ? (
|
||||
<Image
|
||||
src={avatarUrl}
|
||||
alt={name ? `${name}'s avatar` : 'User avatar'}
|
||||
fill
|
||||
sizes={`${pixelSize}px`}
|
||||
className='object-cover'
|
||||
referrerPolicy='no-referrer'
|
||||
unoptimized={avatarUrl.startsWith('http')}
|
||||
/>
|
||||
) : (
|
||||
initials
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
if (tooltipContent) {
|
||||
return (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>{avatarElement}</Tooltip.Trigger>
|
||||
<Tooltip.Content side='bottom' className='max-w-xs'>
|
||||
{tooltipContent}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)
|
||||
}
|
||||
|
||||
return avatarElement
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { UserAvatar } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/user-avatar-stack/components/user-avatar/user-avatar'
|
||||
import { usePresence } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-presence'
|
||||
|
||||
interface User {
|
||||
connectionId: string | number
|
||||
name?: string
|
||||
color?: string
|
||||
info?: string
|
||||
avatarUrl?: string | null
|
||||
}
|
||||
|
||||
interface UserAvatarStackProps {
|
||||
users?: User[]
|
||||
maxVisible?: number
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function UserAvatarStack({
|
||||
users: propUsers,
|
||||
maxVisible = 3,
|
||||
size = 'md',
|
||||
className = '',
|
||||
}: UserAvatarStackProps) {
|
||||
// Use presence data if no users are provided via props
|
||||
const { users: presenceUsers } = usePresence()
|
||||
const users = propUsers || presenceUsers
|
||||
|
||||
// Get operation error state from collaborative workflow
|
||||
// Memoize the processed users to avoid unnecessary re-renders
|
||||
const { visibleUsers, overflowCount } = useMemo(() => {
|
||||
if (users.length === 0) {
|
||||
return { visibleUsers: [], overflowCount: 0 }
|
||||
}
|
||||
|
||||
const visible = users.slice(0, maxVisible)
|
||||
const overflow = Math.max(0, users.length - maxVisible)
|
||||
|
||||
return {
|
||||
visibleUsers: visible,
|
||||
overflowCount: overflow,
|
||||
}
|
||||
}, [users, maxVisible])
|
||||
|
||||
// Determine spacing based on size
|
||||
const spacingClass = {
|
||||
sm: '-space-x-1',
|
||||
md: '-space-x-1.5',
|
||||
lg: '-space-x-2',
|
||||
}[size]
|
||||
|
||||
const shouldShowAvatars = visibleUsers.length > 0
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col items-start ${className}`}>
|
||||
{shouldShowAvatars && (
|
||||
<div className={cn('flex items-center px-2 py-1', spacingClass)}>
|
||||
{visibleUsers.map((user, index) => (
|
||||
<UserAvatar
|
||||
key={user.connectionId}
|
||||
connectionId={user.connectionId}
|
||||
name={user.name}
|
||||
color={user.color}
|
||||
avatarUrl={user.avatarUrl}
|
||||
size={size}
|
||||
index={index}
|
||||
tooltipContent={
|
||||
user.name ? (
|
||||
<div className='text-center'>
|
||||
<div className='font-medium'>{user.name}</div>
|
||||
{user.info && (
|
||||
<div className='mt-1 text-muted-foreground text-xs'>{user.info}</div>
|
||||
)}
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
))}
|
||||
|
||||
{overflowCount > 0 && (
|
||||
<UserAvatar
|
||||
connectionId='overflow-indicator'
|
||||
name={`+${overflowCount}`}
|
||||
size={size}
|
||||
index={visibleUsers.length}
|
||||
tooltipContent={
|
||||
<div className='text-center'>
|
||||
<div className='font-medium'>
|
||||
{overflowCount} more user{overflowCount > 1 ? 's' : ''}
|
||||
</div>
|
||||
<div className='mt-1 text-muted-foreground text-xs'>
|
||||
{users.length} total online
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -43,7 +43,6 @@ import { useDebounce } from '@/hooks/use-debounce'
|
||||
import { useFolderStore } from '@/stores/folders/store'
|
||||
import { useOperationQueueStore } from '@/stores/operation-queue/store'
|
||||
import { usePanelStore } from '@/stores/panel/store'
|
||||
import { useSubscriptionStore } from '@/stores/subscription/store'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
@@ -81,11 +80,8 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
|
||||
const { lastSaved, setNeedsRedeploymentFlag, blocks } = useWorkflowStore()
|
||||
const {
|
||||
workflows,
|
||||
updateWorkflow,
|
||||
activeWorkflowId,
|
||||
removeWorkflow,
|
||||
duplicateWorkflow,
|
||||
setDeploymentStatus,
|
||||
isLoading: isRegistryLoading,
|
||||
} = useWorkflowRegistry()
|
||||
const { isExecuting, handleRunWorkflow, handleCancelExecution } = useWorkflowExecution()
|
||||
@@ -100,7 +96,7 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
|
||||
useWorkflowExecution()
|
||||
|
||||
// Local state
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const [, setMounted] = useState(false)
|
||||
const [, forceUpdate] = useState({})
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
const [isWebhookSettingsOpen, setIsWebhookSettingsOpen] = useState(false)
|
||||
@@ -332,7 +328,7 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
|
||||
/**
|
||||
* Check user usage limits and cache results
|
||||
*/
|
||||
async function checkUserUsage(userId: string, forceRefresh = false): Promise<any | null> {
|
||||
async function checkUserUsage(_userId: string, forceRefresh = false): Promise<any | null> {
|
||||
const now = Date.now()
|
||||
const cacheAge = now - usageDataCache.timestamp
|
||||
|
||||
@@ -355,14 +351,8 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
|
||||
return usage
|
||||
}
|
||||
|
||||
// Fallback: use store if API not available
|
||||
const { getUsage, refresh } = useSubscriptionStore.getState()
|
||||
if (forceRefresh) await refresh()
|
||||
const usage = getUsage()
|
||||
|
||||
// Update cache
|
||||
usageDataCache = { data: usage, timestamp: now, expirationMs: usageDataCache.expirationMs }
|
||||
return usage
|
||||
// No fallback needed anymore - React Query handles this
|
||||
return null
|
||||
} catch (error) {
|
||||
logger.error('Error checking usage limits:', { error })
|
||||
return null
|
||||
@@ -1113,6 +1103,7 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
|
||||
<Play className={cn('h-3.5 w-3.5', 'fill-current stroke-current')} />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>{getTooltipContent()}</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -18,11 +18,6 @@ interface CursorRenderData {
|
||||
color: string
|
||||
}
|
||||
|
||||
const POINTER_OFFSET = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
}
|
||||
|
||||
const CursorsComponent = () => {
|
||||
const { presenceUsers } = useSocket()
|
||||
const viewport = useViewport()
|
||||
@@ -60,23 +55,15 @@ const CursorsComponent = () => {
|
||||
transition: 'transform 0.12s ease-out',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className='relative'
|
||||
style={{ transform: `translate(${-POINTER_OFFSET.x}px, ${-POINTER_OFFSET.y}px)` }}
|
||||
>
|
||||
{/* Simple cursor pointer */}
|
||||
<svg width={16} height={18} viewBox='0 0 16 18' fill='none'>
|
||||
<path
|
||||
d='M0.5 0.5L0.5 12L4 9L6.5 15L8.5 14L6 8L12 8L0.5 0.5Z'
|
||||
fill={color}
|
||||
stroke='rgba(0,0,0,0.3)'
|
||||
strokeWidth={1}
|
||||
/>
|
||||
<div className='relative flex items-start'>
|
||||
{/* Filled mouse pointer cursor */}
|
||||
<svg className='-mt-[18px]' width={24} height={24} viewBox='0 0 24 24' fill={color}>
|
||||
<path d='M4.037 4.688a.495.495 0 0 1 .651-.651l16 6.5a.5.5 0 0 1-.063.947l-6.124 1.58a2 2 0 0 0-1.438 1.435l-1.579 6.126a.5.5 0 0 1-.947.063z' />
|
||||
</svg>
|
||||
|
||||
{/* Name tag underneath and to the right */}
|
||||
{/* Name tag to the right, background tightly wrapping text */}
|
||||
<div
|
||||
className='absolute top-[18px] left-[4px] h-[21px] w-[140px] truncate whitespace-nowrap rounded-[2px] p-[6px] font-medium text-[11px] text-[var(--surface-1)]'
|
||||
className='ml-[-4px] inline-flex max-w-[160px] truncate whitespace-nowrap rounded-[2px] px-1.5 py-[2px] font-medium text-[11px] text-[var(--surface-1)]'
|
||||
style={{ backgroundColor: color }}
|
||||
>
|
||||
{name}
|
||||
|
||||
@@ -25,7 +25,12 @@ import type { UserInputRef } from '@/app/workspace/[workspaceId]/w/[workflowId]/
|
||||
import { useScrollManagement } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
|
||||
import { useCopilotStore } from '@/stores/panel-new/copilot/store'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useChatHistory, useCopilotInitialization, useTodoManagement } from './hooks'
|
||||
import {
|
||||
useChatHistory,
|
||||
useCopilotInitialization,
|
||||
useLandingPrompt,
|
||||
useTodoManagement,
|
||||
} from './hooks'
|
||||
|
||||
const logger = createLogger('Copilot')
|
||||
|
||||
@@ -125,6 +130,22 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
||||
setPlanTodos,
|
||||
})
|
||||
|
||||
/**
|
||||
* Helper function to focus the copilot input
|
||||
*/
|
||||
const focusInput = useCallback(() => {
|
||||
userInputRef.current?.focus()
|
||||
}, [])
|
||||
|
||||
// Handle landing page prompt retrieval and population
|
||||
useLandingPrompt({
|
||||
isInitialized,
|
||||
setInputValue,
|
||||
focusInput,
|
||||
isSendingMessage,
|
||||
currentInputValue: inputValue,
|
||||
})
|
||||
|
||||
/**
|
||||
* Auto-scroll to bottom when chat loads in
|
||||
*/
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export { useChatHistory } from './use-chat-history'
|
||||
export { useCopilotInitialization } from './use-copilot-initialization'
|
||||
export { useLandingPrompt } from './use-landing-prompt'
|
||||
export { useTodoManagement } from './use-todo-management'
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { LandingPromptStorage } from '@/lib/browser-storage'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('useLandingPrompt')
|
||||
|
||||
interface UseLandingPromptProps {
|
||||
/**
|
||||
* Whether the copilot is fully initialized and ready to receive input
|
||||
*/
|
||||
isInitialized: boolean
|
||||
|
||||
/**
|
||||
* Callback to set the input value in the copilot
|
||||
*/
|
||||
setInputValue: (value: string) => void
|
||||
|
||||
/**
|
||||
* Callback to focus the copilot input
|
||||
*/
|
||||
focusInput: () => void
|
||||
|
||||
/**
|
||||
* Whether a message is currently being sent (prevents overwriting during active chat)
|
||||
*/
|
||||
isSendingMessage: boolean
|
||||
|
||||
/**
|
||||
* Current input value (to avoid overwriting if user has already typed)
|
||||
*/
|
||||
currentInputValue: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook to handle landing page prompt retrieval and population
|
||||
*
|
||||
* When a user enters a prompt on the landing page and signs up/logs in,
|
||||
* this hook retrieves that prompt from localStorage and populates it
|
||||
* in the copilot input once the copilot is initialized.
|
||||
*
|
||||
* @param props - Configuration for landing prompt handling
|
||||
*/
|
||||
export function useLandingPrompt(props: UseLandingPromptProps) {
|
||||
const { isInitialized, setInputValue, focusInput, isSendingMessage, currentInputValue } = props
|
||||
|
||||
const hasCheckedRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
// Only check once when copilot is first initialized
|
||||
if (!isInitialized || hasCheckedRef.current || isSendingMessage) {
|
||||
return
|
||||
}
|
||||
|
||||
// If user has already started typing, don't override
|
||||
if (currentInputValue && currentInputValue.trim().length > 0) {
|
||||
hasCheckedRef.current = true
|
||||
return
|
||||
}
|
||||
|
||||
// Try to retrieve the stored prompt (max age: 24 hours)
|
||||
const prompt = LandingPromptStorage.consume()
|
||||
|
||||
if (prompt) {
|
||||
logger.info('Retrieved landing page prompt, populating copilot input')
|
||||
setInputValue(prompt)
|
||||
|
||||
// Focus the input after a brief delay to ensure DOM is ready
|
||||
setTimeout(() => {
|
||||
focusInput()
|
||||
}, 150)
|
||||
}
|
||||
|
||||
hasCheckedRef.current = true
|
||||
}, [isInitialized, setInputValue, focusInput, isSendingMessage, currentInputValue])
|
||||
}
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
import { useCallback, useState } from 'react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { Button, Rocket } from '@/components/emcn'
|
||||
import { Button, Rocket, Tooltip } from '@/components/emcn'
|
||||
import { DeployModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components'
|
||||
import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-current-workflow'
|
||||
import type { WorkspaceUserPermissions } from '@/hooks/use-user-permissions'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useChangeDetection, useDeployedState, useDeployment } from './hooks'
|
||||
@@ -21,6 +22,7 @@ interface DeployProps {
|
||||
export function Deploy({ activeWorkflowId, userPermissions, className }: DeployProps) {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||
const { isLoading: isRegistryLoading } = useWorkflowRegistry()
|
||||
const { hasBlocks } = useCurrentWorkflow()
|
||||
|
||||
// Get deployment status from registry
|
||||
const deploymentStatus = useWorkflowRegistry((state) =>
|
||||
@@ -49,8 +51,9 @@ export function Deploy({ activeWorkflowId, userPermissions, className }: DeployP
|
||||
refetchDeployedState,
|
||||
})
|
||||
|
||||
const isEmpty = !hasBlocks()
|
||||
const canDeploy = userPermissions.canAdmin
|
||||
const isDisabled = isDeploying || !canDeploy
|
||||
const isDisabled = isDeploying || !canDeploy || isEmpty
|
||||
const isPreviousVersionActive = isDeployed && changeDetected
|
||||
|
||||
/**
|
||||
@@ -75,21 +78,50 @@ export function Deploy({ activeWorkflowId, userPermissions, className }: DeployP
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tooltip text based on current state
|
||||
*/
|
||||
const getTooltipText = () => {
|
||||
if (isEmpty) {
|
||||
return 'Cannot deploy an empty workflow'
|
||||
}
|
||||
if (!canDeploy) {
|
||||
return 'Admin permissions required'
|
||||
}
|
||||
if (isDeploying) {
|
||||
return 'Deploying...'
|
||||
}
|
||||
if (changeDetected) {
|
||||
return 'Update deployment'
|
||||
}
|
||||
if (isDeployed) {
|
||||
return 'Active deployment'
|
||||
}
|
||||
return 'Deploy workflow'
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
className='h-[32px] gap-[8px] px-[10px]'
|
||||
variant='active'
|
||||
onClick={onDeployClick}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
{isDeploying ? (
|
||||
<Loader2 className='h-[13px] w-[13px] animate-spin' />
|
||||
) : (
|
||||
<Rocket className='h-[13px] w-[13px]' />
|
||||
)}
|
||||
{changeDetected ? 'Update' : isDeployed ? 'Active' : 'Deploy'}
|
||||
</Button>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<span>
|
||||
<Button
|
||||
className='h-[32px] gap-[8px] px-[10px]'
|
||||
variant='active'
|
||||
onClick={onDeployClick}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
{isDeploying ? (
|
||||
<Loader2 className='h-[13px] w-[13px] animate-spin' />
|
||||
) : (
|
||||
<Rocket className='h-[13px] w-[13px]' />
|
||||
)}
|
||||
{changeDetected ? 'Update' : isDeployed ? 'Active' : 'Deploy'}
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>{getTooltipText()}</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
|
||||
<DeployModal
|
||||
open={isModalOpen}
|
||||
|
||||
@@ -221,17 +221,26 @@ export function Code({
|
||||
// Derived state
|
||||
const effectiveLanguage = (languageValue as 'javascript' | 'python' | 'json') || language
|
||||
|
||||
const trimmedCode = code.trim()
|
||||
const containsReferencePlaceholders =
|
||||
trimmedCode.includes('{{') ||
|
||||
trimmedCode.includes('}}') ||
|
||||
trimmedCode.includes('<') ||
|
||||
trimmedCode.includes('>')
|
||||
|
||||
const shouldValidateJson = effectiveLanguage === 'json' && !containsReferencePlaceholders
|
||||
|
||||
const isValidJson = useMemo(() => {
|
||||
if (subBlockId !== 'responseFormat' || !code.trim()) {
|
||||
if (!shouldValidateJson || !trimmedCode) {
|
||||
return true
|
||||
}
|
||||
try {
|
||||
JSON.parse(code)
|
||||
JSON.parse(trimmedCode)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}, [subBlockId, code])
|
||||
}, [shouldValidateJson, trimmedCode])
|
||||
|
||||
const gutterWidthPx = useMemo(() => {
|
||||
const lineCount = code.split('\n').length
|
||||
@@ -309,14 +318,29 @@ export function Code({
|
||||
: storeValue
|
||||
|
||||
// Effects: JSON validation
|
||||
const lastValidationStatus = useRef<boolean>(true)
|
||||
|
||||
useEffect(() => {
|
||||
if (onValidationChange && subBlockId === 'responseFormat') {
|
||||
const timeoutId = setTimeout(() => {
|
||||
onValidationChange(isValidJson)
|
||||
}, 150)
|
||||
return () => clearTimeout(timeoutId)
|
||||
if (!onValidationChange) return
|
||||
|
||||
const nextStatus = shouldValidateJson ? isValidJson : true
|
||||
if (lastValidationStatus.current === nextStatus) {
|
||||
return
|
||||
}
|
||||
}, [isValidJson, onValidationChange, subBlockId])
|
||||
|
||||
lastValidationStatus.current = nextStatus
|
||||
|
||||
if (!shouldValidateJson) {
|
||||
onValidationChange(nextStatus)
|
||||
return
|
||||
}
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
onValidationChange(nextStatus)
|
||||
}, 150)
|
||||
|
||||
return () => clearTimeout(timeoutId)
|
||||
}, [isValidJson, onValidationChange, shouldValidateJson])
|
||||
|
||||
// Effects: AI stream handlers setup
|
||||
useEffect(() => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user