mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-16 18:25:17 +02:00
Feature/copilot trigger creation (#274)
* feat: Add Copilot trigger creation support
- Add support for One-Time and Recurring triggers in Copilot
- Extend CopilotAssistantMessageActionPart schema with trigger config types
- Update Copilot instructions with trigger creation examples and guidelines
- Implement trigger action handling in messages.tsx component
- Add trigger icons (⏰ for one-time, 🔄 for recurring) in action cards
- Update workflow reducer to handle trigger creation via existing APIs
- Fix action parser to recognize trigger config types in comment format
- Add async trigger processing using createScheduledJobRule and createRecurringJobRule APIs
Users can now ask Copilot to create triggers with natural language requests like:
'Create a daily report trigger at 9 AM' or 'Set up a one-time reminder for next Friday'
* feat: Enhance Copilot message handling and trigger actions
- Pass projectId to Messages and AssistantMessage components for better context
- Refactor applyAction to handle one-time and recurring triggers with improved error handling
- Update handleApplyAll and handleSingleApply to support async action processing
- Remove deprecated pending trigger logic from workflow editor
This update improves the Copilot's ability to manage triggers and enhances the overall message processing flow.
* refactor: route trigger actions via copilot helper
Keep workflow reducer synchronous by removing trigger jobs from the switch and moving job rule API calls into a dedicated helper in messages.tsx. Cache dynamic imports and guard types so Copilot Apply/Apply All handle trigger creation without touching reducer state.
* feat: Add current time to the copilot context
* added conext of triggers to the copilot along with being able to edit and delete triggers
* bug fix for deleting composio triggers
* Add the edit function that allows editing triggers and lets copilot edit triggers too without losing previous jobs
feat: Add update functionality for recurring and scheduled job rules
- Implemented update actions for recurring job rules and scheduled job rules, allowing users to modify existing rules with new input and scheduling configurations.
- Enhanced the UI components to support editing of job rules, including forms for both creating and updating rules.
- Updated the repository interfaces and MongoDB implementations to handle the new update operations for job rules.
This update improves the flexibility of managing job rules within the application.
* Add trigger context to copilot
feat: Enhance trigger management in Copilot
- Added functionality to search for relevant external triggers using the new `search_relevant_triggers` tool, allowing users to discover available triggers based on toolkit slugs and optional query keywords.
- Updated the Copilot context to include detailed descriptions of various external trigger toolkits, enhancing user guidance for trigger creation.
- Improved the overall trigger handling process, ensuring that users can effectively integrate external triggers into their workflows.
This update significantly enhances the Copilot's capabilities in managing and utilizing external triggers.
* Let copilot add external triggers
feat: Enhance external trigger handling in Copilot
- Added support for flexible schemas in external triggers, allowing configuration changes without stripping any data.
- Introduced a new `onRequestTriggerSetup` callback in the Action component to facilitate trigger setup requests.
- Implemented a modal for trigger configuration, improving user experience when setting up external triggers.
- Updated the ComposioTriggerTypesPanel to auto-select trigger types based on initial configuration.
This update significantly improves the management and setup of external triggers within the Copilot interface.
* External trigger cant be edited so we delete and recreate for this
feat: Improve external trigger handling in Copilot
- Added validation for editing external triggers, ensuring users are informed that existing triggers must be deleted and recreated for changes.
- Updated documentation to clarify the limitations of external trigger modifications.
This update enhances user experience by providing clear guidance on managing external triggers within the Copilot interface.
* preventing message.tsx from ballooning up in size
feat: Refactor Copilot message handling and trigger actions
- Removed deprecated logic for loading scheduled and recurring job actions, streamlining the trigger action process.
- Integrated `useCopilotTriggerActions` hook to manage trigger setup and actions more efficiently.
- Enhanced parsing of action parts to improve handling of triggers and their configurations.
- Updated the UI to reflect changes in action handling, ensuring a smoother user experience.
This update optimizes the Copilot's ability to manage triggers and enhances the overall message processing flow.
* refactor: Simplify trigger filtering in Copilot
- Removed unnecessary filtering logic for triggers based on user queries, streamlining the search process.
- Updated response messages to clarify the context of displayed triggers, enhancing user understanding.
This update improves the efficiency of the trigger search functionality within the Copilot interface.
* Revert "refactor: Simplify trigger filtering in Copilot"
This reverts commit b3d041677c.
* simplify the filtering logic for triggers in copilot
feat: Enhance external trigger creation and search functionality in Copilot
- Introduced a critical flow for adding external triggers, emphasizing minimal user input and UI configuration.
- Updated documentation to clarify the external trigger creation process and provided examples for better guidance.
- Simplified the trigger search logic, ensuring users receive relevant results while maintaining clarity in response messages.
This update improves the user experience by streamlining external trigger management and enhancing the search capabilities within the Copilot interface.
---------
Co-authored-by: tusharmagar <tushmag@gmail.com>
This commit is contained in:
parent
96fd8b10ca
commit
476654af80
39 changed files with 2787 additions and 495 deletions
|
|
@ -2,7 +2,7 @@ import z from "zod";
|
|||
import { createOpenAI } from "@ai-sdk/openai";
|
||||
import { generateObject, streamText, tool } from "ai";
|
||||
import { Workflow, WorkflowTool } from "@/app/lib/types/workflow_types";
|
||||
import { CopilotChatContext, CopilotMessage, DataSourceSchemaForCopilot } from "../../../entities/models/copilot";
|
||||
import { CopilotChatContext, CopilotMessage, DataSourceSchemaForCopilot, TriggerSchemaForCopilot } from "../../../entities/models/copilot";
|
||||
import { PrefixLogger } from "@/app/lib/utils";
|
||||
import zodToJsonSchema from "zod-to-json-schema";
|
||||
import { COPILOT_INSTRUCTIONS_EDIT_AGENT } from "./copilot_edit_agent";
|
||||
|
|
@ -10,7 +10,7 @@ import { COPILOT_INSTRUCTIONS_MULTI_AGENT_WITH_DOCS as COPILOT_INSTRUCTIONS_MULT
|
|||
import { COPILOT_MULTI_AGENT_EXAMPLE_1 } from "./example_multi_agent_1";
|
||||
import { CURRENT_WORKFLOW_PROMPT } from "./current_workflow";
|
||||
import { USE_COMPOSIO_TOOLS } from "@/app/lib/feature_flags";
|
||||
import { composio, getTool } from "../composio/composio";
|
||||
import { composio, getTool, listTriggersTypes } from "../composio/composio";
|
||||
import { UsageTracker } from "@/app/lib/billing";
|
||||
import { CopilotStreamEvent } from "@/src/entities/models/copilot";
|
||||
|
||||
|
|
@ -98,6 +98,55 @@ ${JSON.stringify(simplifiedDataSources)}
|
|||
return prompt;
|
||||
}
|
||||
|
||||
function getCurrentTimePrompt(): string {
|
||||
return `**CURRENT TIME**: ${new Date().toISOString()}`;
|
||||
}
|
||||
|
||||
function getTriggersPrompt(triggers: z.infer<typeof TriggerSchemaForCopilot>[]): string {
|
||||
if (!triggers || triggers.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const simplifiedTriggers = triggers.map(trigger => {
|
||||
if (trigger.type === 'one_time') {
|
||||
return {
|
||||
id: trigger.id,
|
||||
type: 'one_time',
|
||||
name: trigger.name,
|
||||
scheduledTime: trigger.nextRunAt,
|
||||
input: trigger.input,
|
||||
status: trigger.status,
|
||||
};
|
||||
} else if (trigger.type === 'recurring') {
|
||||
return {
|
||||
id: trigger.id,
|
||||
type: 'recurring',
|
||||
name: trigger.name,
|
||||
cron: trigger.cron,
|
||||
nextRunAt: trigger.nextRunAt,
|
||||
disabled: trigger.disabled,
|
||||
input: trigger.input,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
id: trigger.id,
|
||||
type: 'external',
|
||||
name: trigger.triggerTypeName,
|
||||
toolkit: trigger.toolkitSlug,
|
||||
triggerType: trigger.triggerTypeSlug,
|
||||
config: trigger.triggerConfig,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
return `**NOTE**:
|
||||
The following triggers are currently configured:
|
||||
\`\`\`json
|
||||
${JSON.stringify(simplifiedTriggers)}
|
||||
\`\`\`
|
||||
`;
|
||||
}
|
||||
|
||||
async function searchRelevantTools(usageTracker: UsageTracker, query: string): Promise<string> {
|
||||
const logger = new PrefixLogger("copilot-search-tools");
|
||||
console.log("🔧 TOOL CALL: searchRelevantTools", { query });
|
||||
|
|
@ -185,15 +234,107 @@ async function searchRelevantTools(usageTracker: UsageTracker, query: string): P
|
|||
return response;
|
||||
}
|
||||
|
||||
async function searchRelevantTriggers(
|
||||
usageTracker: UsageTracker,
|
||||
toolkitSlug: string,
|
||||
query?: string,
|
||||
): Promise<string> {
|
||||
const logger = new PrefixLogger("copilot-search-triggers");
|
||||
const trimmedSlug = toolkitSlug.trim();
|
||||
const trimmedQuery = query?.trim() || '';
|
||||
console.log("🔧 TOOL CALL: searchRelevantTriggers", { toolkitSlug: trimmedSlug, query: trimmedQuery });
|
||||
|
||||
if (!trimmedSlug) {
|
||||
logger.log('no toolkit slug provided');
|
||||
return 'Please provide a toolkit slug (for example "gmail" or "slack") when searching for triggers.';
|
||||
}
|
||||
|
||||
if (!USE_COMPOSIO_TOOLS) {
|
||||
logger.log('dynamic trigger search is disabled');
|
||||
console.log("❌ TOOL CALL SKIPPED: searchRelevantTriggers - Composio tools disabled");
|
||||
return 'Trigger search is currently unavailable.';
|
||||
}
|
||||
|
||||
const MAX_PAGES = 5;
|
||||
type TriggerListResponse = Awaited<ReturnType<typeof listTriggersTypes>>;
|
||||
type TriggerType = TriggerListResponse['items'][number];
|
||||
|
||||
const triggers: TriggerType[] = [];
|
||||
let cursor: string | undefined;
|
||||
|
||||
try {
|
||||
for (let page = 0; page < MAX_PAGES; page++) {
|
||||
logger.log(`fetching trigger page ${page + 1} for toolkit ${trimmedSlug}`);
|
||||
console.log("🔍 TOOL CALL: COMPOSIO_LIST_TRIGGERS", { toolkitSlug: trimmedSlug, cursor });
|
||||
const response = await listTriggersTypes(trimmedSlug, cursor);
|
||||
triggers.push(...response.items);
|
||||
console.log("✅ TOOL CALL SUCCESS: COMPOSIO_LIST_TRIGGERS", {
|
||||
toolkitSlug: trimmedSlug,
|
||||
fetchedCount: response.items.length,
|
||||
totalCollected: triggers.length,
|
||||
hasNext: Boolean(response.next_cursor),
|
||||
});
|
||||
if (!response.next_cursor) {
|
||||
break;
|
||||
}
|
||||
cursor = response.next_cursor || undefined;
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.log(`trigger search failed: ${error?.message || error}`);
|
||||
console.log("❌ TOOL CALL FAILED: COMPOSIO_LIST_TRIGGERS", {
|
||||
toolkitSlug: trimmedSlug,
|
||||
error: error?.message || error,
|
||||
});
|
||||
return `Trigger search failed for toolkit "${trimmedSlug}".`;
|
||||
}
|
||||
|
||||
usageTracker.track({
|
||||
type: "COMPOSIO_TOOL_USAGE",
|
||||
toolSlug: `COMPOSIO_LIST_TRIGGER_TYPES:${trimmedSlug}`,
|
||||
context: "copilot.search_relevant_triggers",
|
||||
});
|
||||
|
||||
if (!triggers.length) {
|
||||
logger.log('no triggers found for toolkit');
|
||||
return `No triggers are currently available for toolkit "${trimmedSlug}".`;
|
||||
}
|
||||
|
||||
const MAX_RESULTS = 8;
|
||||
const limitedTriggers = triggers.slice(0, MAX_RESULTS);
|
||||
const truncated = triggers.length > limitedTriggers.length;
|
||||
|
||||
const formattedTriggers = limitedTriggers.map(trigger => {
|
||||
const requiredFields = trigger.config.required && trigger.config.required.length
|
||||
? trigger.config.required.join(', ')
|
||||
: 'None';
|
||||
const configJson = JSON.stringify(trigger.config, null, 2);
|
||||
return `**${trigger.name}** (slug: ${trigger.slug})\nToolkit: ${trigger.toolkit.name} (${trigger.toolkit.slug})\nDescription: ${trigger.description}\nRequired config fields: ${requiredFields}\n\`\`\`json\n${configJson}\n\`\`\``;
|
||||
}).join('\n\n');
|
||||
|
||||
const header = trimmedQuery
|
||||
? `Available triggers for toolkit "${trimmedSlug}" (user query: "${trimmedQuery}"):`
|
||||
: `Available triggers for toolkit "${trimmedSlug}":`;
|
||||
|
||||
const note = truncated
|
||||
? `\n\nOnly showing the first ${MAX_RESULTS} results out of ${triggers.length}. The toolkit has more triggers available.`
|
||||
: '';
|
||||
|
||||
const response = `${header}\n\n${formattedTriggers}${note}`;
|
||||
logger.log('returning trigger search response');
|
||||
return response;
|
||||
}
|
||||
|
||||
function updateLastUserMessage(
|
||||
messages: z.infer<typeof CopilotMessage>[],
|
||||
currentWorkflowPrompt: string,
|
||||
contextPrompt: string,
|
||||
dataSourcesPrompt: string = '',
|
||||
timePrompt: string = '',
|
||||
triggersPrompt: string = '',
|
||||
): void {
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
if (lastMessage.role === 'user') {
|
||||
lastMessage.content = `${currentWorkflowPrompt}\n\n${contextPrompt}\n\n${dataSourcesPrompt}\n\nUser: ${JSON.stringify(lastMessage.content)}`;
|
||||
lastMessage.content = `${currentWorkflowPrompt}\n\n${contextPrompt}\n\n${dataSourcesPrompt}\n\n${timePrompt}\n\n${triggersPrompt}\n\nUser: ${JSON.stringify(lastMessage.content)}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -203,6 +344,7 @@ export async function getEditAgentInstructionsResponse(
|
|||
context: z.infer<typeof CopilotChatContext> | null,
|
||||
messages: z.infer<typeof CopilotMessage>[],
|
||||
workflow: z.infer<typeof Workflow>,
|
||||
triggers: z.infer<typeof TriggerSchemaForCopilot>[] = [],
|
||||
): Promise<string> {
|
||||
const logger = new PrefixLogger('copilot /getUpdatedAgentInstructions');
|
||||
logger.log('context', context);
|
||||
|
|
@ -214,8 +356,14 @@ export async function getEditAgentInstructionsResponse(
|
|||
// set context prompt
|
||||
let contextPrompt = getContextPrompt(context);
|
||||
|
||||
// set time prompt
|
||||
let timePrompt = getCurrentTimePrompt();
|
||||
|
||||
// set triggers prompt
|
||||
let triggersPrompt = getTriggersPrompt(triggers);
|
||||
|
||||
// add the above prompts to the last user message
|
||||
updateLastUserMessage(messages, currentWorkflowPrompt, contextPrompt);
|
||||
updateLastUserMessage(messages, currentWorkflowPrompt, contextPrompt, '', timePrompt, triggersPrompt);
|
||||
|
||||
// call model
|
||||
console.log("calling model", JSON.stringify({
|
||||
|
|
@ -255,7 +403,8 @@ export async function* streamMultiAgentResponse(
|
|||
context: z.infer<typeof CopilotChatContext> | null,
|
||||
messages: z.infer<typeof CopilotMessage>[],
|
||||
workflow: z.infer<typeof Workflow>,
|
||||
dataSources: z.infer<typeof DataSourceSchemaForCopilot>[]
|
||||
dataSources: z.infer<typeof DataSourceSchemaForCopilot>[],
|
||||
triggers: z.infer<typeof TriggerSchemaForCopilot>[] = []
|
||||
): AsyncIterable<z.infer<typeof CopilotStreamEvent>> {
|
||||
const logger = new PrefixLogger('copilot /stream');
|
||||
logger.log('context', context);
|
||||
|
|
@ -277,14 +426,20 @@ export async function* streamMultiAgentResponse(
|
|||
// set data sources prompt
|
||||
let dataSourcesPrompt = getDataSourcesPrompt(dataSources);
|
||||
|
||||
// set time prompt
|
||||
let timePrompt = getCurrentTimePrompt();
|
||||
|
||||
// set triggers prompt
|
||||
let triggersPrompt = getTriggersPrompt(triggers);
|
||||
|
||||
// add the above prompts to the last user message
|
||||
updateLastUserMessage(messages, currentWorkflowPrompt, contextPrompt, dataSourcesPrompt);
|
||||
updateLastUserMessage(messages, currentWorkflowPrompt, contextPrompt, dataSourcesPrompt, timePrompt, triggersPrompt);
|
||||
|
||||
// call model
|
||||
console.log("🤖 AI MODEL CALL STARTED", {
|
||||
model: COPILOT_MODEL,
|
||||
maxSteps: 20,
|
||||
availableTools: ["search_relevant_tools"]
|
||||
availableTools: ["search_relevant_tools", "search_relevant_triggers"]
|
||||
});
|
||||
|
||||
const { fullStream } = streamText({
|
||||
|
|
@ -306,6 +461,23 @@ export async function* streamMultiAgentResponse(
|
|||
return result;
|
||||
},
|
||||
}),
|
||||
"search_relevant_triggers": tool({
|
||||
description: "Use this tool to discover external triggers provided by Composio toolkits. Supply the toolkit slug (for example 'gmail', 'slack', or 'salesforce') and optionally keywords from the user's request to narrow down results. Always call this before adding an external trigger to ensure the trigger exists and to understand its configuration schema.",
|
||||
parameters: z.object({
|
||||
toolkitSlug: z.string().describe("Slug of the Composio toolkit to search, such as 'gmail', 'slack', 'salesforce', 'googlecalendar'."),
|
||||
query: z.string().min(1).describe("Optional keywords pulled from the user's request to filter trigger names, descriptions, or config fields.").optional(),
|
||||
}),
|
||||
execute: async ({ toolkitSlug, query }: { toolkitSlug: string; query?: string }) => {
|
||||
console.log("🎯 AI TOOL CALL: search_relevant_triggers", { toolkitSlug, query });
|
||||
const result = await searchRelevantTriggers(usageTracker, toolkitSlug, query);
|
||||
console.log("✅ AI TOOL CALL COMPLETED: search_relevant_triggers", {
|
||||
toolkitSlug,
|
||||
query,
|
||||
resultLength: result.length,
|
||||
});
|
||||
return result;
|
||||
},
|
||||
}),
|
||||
},
|
||||
messages: [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ You can perform the following tasks:
|
|||
5. Add, edit, or remove tools
|
||||
6. Adding RAG data sources to agents
|
||||
7. Create and manage pipelines (sequential agent workflows)
|
||||
8. Create One-Time Triggers (scheduled to run once at a specific time)
|
||||
9. Create Recurring Triggers (scheduled to run repeatedly using cron expressions)
|
||||
|
||||
Always aim to fully resolve the user's query before yielding. Only ask for clarification once, using up to 4 concise, bullet-point questions to understand the user’s objective and what they want the workflow to achieve.
|
||||
|
||||
|
|
@ -193,6 +195,197 @@ Note: The agents have access to a tool called 'Generate Image'. This won't show
|
|||
|
||||
</agent_tools>
|
||||
|
||||
<about_triggers>
|
||||
|
||||
## Section: Creating Triggers
|
||||
|
||||
Triggers are automated mechanisms that activate your agents at specific times or intervals. Evaluate every user request for automation or event driven tasks. If the user needs something to happen when an external event occurs (for example a new email, calendar invite, CRM update, or chat message), plan to add an external trigger after confirming the correct integration.
|
||||
|
||||
IMPORTANT: External triggers cannot be edited once created. If the user wants to change an external trigger, you must explain that the only option is to delete the existing trigger and create a new one with the updated configuration. Always offer to perform the delete-and-recreate workflow for them.
|
||||
|
||||
### Trigger Tool Search
|
||||
- Use the "search_relevant_triggers" tool whenever you need to discover external triggers. Provide a toolkit slug (for example "gmail") and optionally keywords from the user's request.
|
||||
- Do not invent trigger names. Always call the tool to confirm that the trigger exists before adding it to the workflow.
|
||||
|
||||
### CRITICAL: External Trigger Creation Flow
|
||||
When a user asks to add an external trigger (e.g., "add Gmail trigger", "trigger on new Google Sheets row", "watch for Slack messages"):
|
||||
|
||||
1. **DO NOT ask for configuration details** in the chat. The user will configure the trigger in the UI after authentication.
|
||||
2. **Immediately create** an "external_trigger" action with minimal/default configuration fields.
|
||||
3. **Present the trigger card** with an "Open setup" button so the user can authenticate and configure it in the UI.
|
||||
4. **Keep your response brief**: Just mention what trigger you're adding and that they'll configure it via the setup button.
|
||||
|
||||
Example response pattern:
|
||||
"I'll add the [Trigger Name] trigger. Once you review and click 'Open setup', you can authenticate and configure the specific details like [brief mention of key fields]."
|
||||
|
||||
**DO NOT** engage in back-and-forth asking for spreadsheet IDs, sheet names, or other configuration values in chat. These are collected through the UI setup flow after the trigger card is created.
|
||||
|
||||
### Trigger Toolkits Library
|
||||
- Gmail (slug: gmail) - Gmail is Google's email service, featuring spam protection, search functions, and seamless integration with other G Suite apps for productivity.
|
||||
- GitHub (slug: github) - GitHub is a code hosting platform for version control and collaboration, offering Git based repository management, issue tracking, and continuous integration features.
|
||||
- Google Calendar (slug: googlecalendar) - Google Calendar is a time management tool providing scheduling features, event reminders, and integration with email and other apps for streamlined organization.
|
||||
- Notion (slug: notion) - Notion centralizes notes, docs, wikis, and tasks in a unified workspace, letting teams build custom workflows for collaboration and knowledge management.
|
||||
- Google Sheets (slug: googlesheets) - Google Sheets is a cloud based spreadsheet tool enabling real time collaboration, data analysis, and integration with other Google Workspace apps.
|
||||
- Slack (slug: slack) - Slack is a channel based messaging platform that helps teams collaborate, integrate software tools, and surface information within a secure environment.
|
||||
- Outlook (slug: outlook) - Outlook is Microsoft's email and calendaring platform integrating contacts, tasks, and scheduling so users can manage communications and events together.
|
||||
- Google Drive (slug: googledrive) - Google Drive is a cloud storage solution for uploading, sharing, and collaborating on files across devices, with robust search and offline access.
|
||||
- Google Docs (slug: googledocs) - Google Docs is a cloud based word processor with real time collaboration, version history, and integration with other Google Workspace apps.
|
||||
- Hubspot (slug: hubspot) - HubSpot is an inbound marketing, sales, and customer service platform integrating CRM, email automation, and analytics to nurture leads and manage customer experiences.
|
||||
- Linear (slug: linear) - Linear is a streamlined issue tracking and project planning tool for modern teams, featuring fast workflows, keyboard shortcuts, and GitHub integrations.
|
||||
- Jira (slug: jira) - Jira is a tool for bug tracking, issue tracking, and agile project management.
|
||||
- Youtube (slug: youtube) - YouTube is a video sharing platform supporting user generated content, live streaming, and monetization for marketing, education, and entertainment.
|
||||
- Slackbot (slug: slackbot) - Slackbot automates responses and reminders within Slack, assisting with tasks like onboarding, FAQs, and notifications to streamline team productivity.
|
||||
- Canvas (slug: canvas) - Canvas is a learning management system supporting online courses, assignments, grading, and collaboration for schools and universities.
|
||||
- Discord (slug: discord) - Discord is an instant messaging and VoIP social platform.
|
||||
- Asana (slug: asana) - Asana helps teams organize, track, and manage their work.
|
||||
- One drive (slug: one_drive) - OneDrive is Microsoft's cloud storage solution enabling users to store, sync, and share files with offline access and enterprise security.
|
||||
- Salesforce (slug: salesforce) - Salesforce is a CRM platform integrating sales, service, marketing, and analytics to build customer relationships and drive growth.
|
||||
- Trello (slug: trello) - Trello is a web based, kanban style, list making application for organizing tasks.
|
||||
- Stripe (slug: stripe) - Stripe offers online payment infrastructure, fraud prevention, and APIs enabling businesses to accept and manage payments globally.
|
||||
- Mailchimp (slug: mailchimp) - Mailchimp is an email marketing and automation platform providing campaign templates, audience segmentation, and performance analytics.
|
||||
- Fireflies (slug: fireflies) - Fireflies.ai helps teams transcribe, summarize, search, and analyze voice conversations.
|
||||
- Coda (slug: coda) - Coda is a collaborative workspace platform that turns documents into powerful tools for team productivity and project management.
|
||||
- Pipedrive (slug: pipedrive) - Pipedrive is a sales management tool centered on pipeline visualization, lead tracking, activity reminders, and automation.
|
||||
- Zendesk (slug: zendesk) - Zendesk provides customer support software with ticketing, live chat, and knowledge base features for efficient helpdesk operations.
|
||||
- Google Super (slug: googlesuper) - Google Super App combines Google services including Drive, Calendar, Gmail, Sheets, Analytics, and Ads for unified management.
|
||||
- Todoist (slug: todoist) - Todoist is a task management tool for creating to do lists, setting deadlines, and collaborating with reminders and cross platform syncing.
|
||||
- Agent mail (slug: agent_mail) - AgentMail gives AI agents their own email inboxes so they can send, receive, and act upon emails for communication with services, people, and other agents.
|
||||
- Google Slides (slug: googleslides) - Google Slides is a cloud based presentation editor with real time collaboration, templates, and Workspace integrations.
|
||||
- Spotify (slug: spotify) - Spotify is a digital music and podcast streaming service with personalized playlists and social sharing features.
|
||||
- Timelinesai (slug: timelinesai) - TimelinesAI enables teams to manage and automate WhatsApp communications, integrating with CRMs to streamline workflows.
|
||||
|
||||
You can create two types of local triggers:
|
||||
|
||||
### One-Time Triggers
|
||||
- Execute once at a specific date and time
|
||||
- Use config_type: "one_time_trigger"
|
||||
- Require scheduledTime (ISO datetime string) in config_changes
|
||||
- Require input.messages array defining what messages to send to agents
|
||||
|
||||
### Recurring Triggers
|
||||
- Execute repeatedly based on a cron schedule
|
||||
- Use config_type: "recurring_trigger"
|
||||
- Require cron (cron expression) in config_changes
|
||||
- Require input.messages array defining what messages to send to agents
|
||||
|
||||
### When to Create Triggers
|
||||
- User asks for scheduled automation (daily reports, weekly summaries)
|
||||
- User mentions specific times ("every morning at 9 AM", "next Friday at 2 PM")
|
||||
- User wants periodic tasks (monitoring, maintenance, data syncing)
|
||||
|
||||
### Common Cron Patterns
|
||||
- "0 9 * * *" - Daily at 9:00 AM
|
||||
- "0 8 * * 1" - Every Monday at 8:00 AM
|
||||
- "*/15 * * * *" - Every 15 minutes
|
||||
- "0 0 1 * *" - First day of month at midnight
|
||||
|
||||
### Example Trigger Actions
|
||||
|
||||
CRITICAL: When creating triggers, follow the EXACT format shown below with comments above the JSON:
|
||||
- Put "action", "config_type", and "name" as comments (starting with //) ABOVE the JSON
|
||||
- The JSON should contain "change_description" and "config_changes"
|
||||
- Always use "action: create_new" for new triggers
|
||||
|
||||
One-time trigger example (COPY THIS EXACT FORMAT):
|
||||
// action: create_new
|
||||
// config_type: one_time_trigger
|
||||
// name: Weekly Report - Dec 15
|
||||
{
|
||||
"change_description": "Create a one-time trigger to generate weekly report on December 15th at 2 PM",
|
||||
"config_changes": {
|
||||
"scheduledTime": "2024-12-15T14:00:00Z",
|
||||
"input": {
|
||||
"messages": [{"role": "user", "content": "Generate the weekly performance report"}]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Recurring trigger example (COPY THIS EXACT FORMAT):
|
||||
// action: create_new
|
||||
// config_type: recurring_trigger
|
||||
// name: Daily Status Check
|
||||
{
|
||||
"change_description": "Create a recurring trigger to check system status every morning at 9 AM",
|
||||
"config_changes": {
|
||||
"cron": "0 9 * * *",
|
||||
"input": {
|
||||
"messages": [{"role": "user", "content": "Check system status and alert if any issues found"}]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
### Editing and Deleting Triggers
|
||||
|
||||
You can also edit or delete existing triggers that are shown in the current workflow context.
|
||||
|
||||
Edit trigger example:
|
||||
// action: edit
|
||||
// config_type: recurring_trigger
|
||||
// name: Daily Status Check
|
||||
{
|
||||
"change_description": "Update the daily status check to run at 10 AM instead of 9 AM",
|
||||
"config_changes": {
|
||||
"cron": "0 10 * * *"
|
||||
}
|
||||
}
|
||||
|
||||
Delete trigger example:
|
||||
// action: delete
|
||||
// config_type: one_time_trigger
|
||||
// name: Weekly Report - Dec 15
|
||||
{
|
||||
"change_description": "Remove the one-time trigger for weekly report as it's no longer needed"
|
||||
}
|
||||
|
||||
### External Triggers
|
||||
|
||||
External triggers connect to services like Gmail, Slack, GitHub, Google Sheets, etc. When creating external triggers, provide minimal default configuration - the user will complete setup via the UI.
|
||||
|
||||
External trigger creation examples (COPY THIS EXACT FORMAT):
|
||||
// action: create_new
|
||||
// config_type: external_trigger
|
||||
// name: New Gmail Message Received
|
||||
{
|
||||
"change_description": "Add the Gmail trigger for new message received with default configuration (checks INBOX every 1 minute for the authenticated user).",
|
||||
"config_changes": {
|
||||
"triggerTypeSlug": "GMAIL_NEW_GMAIL_MESSAGE",
|
||||
"toolkitSlug": "gmail",
|
||||
"triggerConfig": {
|
||||
"interval": 1,
|
||||
"labelIds": "INBOX",
|
||||
"query": "",
|
||||
"userId": "me"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// action: create_new
|
||||
// config_type: external_trigger
|
||||
// name: New Rows in Google Sheet
|
||||
{
|
||||
"change_description": "Add the Google Sheets trigger to detect new rows with default configuration",
|
||||
"config_changes": {
|
||||
"triggerTypeSlug": "GOOGLESHEETS_NEW_ROWS_IN_GOOGLE_SHEET",
|
||||
"toolkitSlug": "googlesheets",
|
||||
"triggerConfig": {
|
||||
"interval": 1,
|
||||
"sheet_name": "Sheet1",
|
||||
"start_row": 2,
|
||||
"spreadsheet_id": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
External trigger deletion:
|
||||
// action: delete
|
||||
// config_type: external_trigger
|
||||
// name: Slack Message Received
|
||||
{
|
||||
"change_description": "Remove the Slack message trigger as we're switching to a different notification system"
|
||||
}
|
||||
|
||||
</about_triggers>
|
||||
|
||||
<about_pipelines>
|
||||
|
||||
## Section: Creating and Managing Pipelines
|
||||
|
|
@ -260,4 +453,4 @@ Below are details you should use when a user asks questions on how to use the pr
|
|||
{USING_ROWBOAT_DOCS}
|
||||
|
||||
</general_guidelines>
|
||||
`;
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,70 @@
|
|||
const RANGE_SEPARATOR = "-";
|
||||
const STEP_SEPARATOR = "/";
|
||||
|
||||
export function isValidCronExpression(cron: string): boolean {
|
||||
const parts = cron.trim().split(/\s+/);
|
||||
if (parts.length !== 5) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const [minute, hour, day, month, dayOfWeek] = parts;
|
||||
|
||||
const validatePart = (part: string, max: number): boolean => {
|
||||
if (part === "*") {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (part.includes(STEP_SEPARATOR)) {
|
||||
const [range, step] = part.split(STEP_SEPARATOR);
|
||||
if (!step) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const stepValue = Number(step);
|
||||
if (!Number.isInteger(stepValue) || stepValue <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (range === "*") {
|
||||
return stepValue <= max;
|
||||
}
|
||||
|
||||
return validatePart(range, max);
|
||||
}
|
||||
|
||||
if (part.includes(RANGE_SEPARATOR)) {
|
||||
const [start, end] = part.split(RANGE_SEPARATOR);
|
||||
if (start === undefined || end === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const startValue = Number(start);
|
||||
const endValue = Number(end);
|
||||
|
||||
if (!Number.isInteger(startValue) || !Number.isInteger(endValue)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (startValue > endValue) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return startValue >= 0 && endValue <= max;
|
||||
}
|
||||
|
||||
const value = Number(part);
|
||||
if (!Number.isInteger(value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return value >= 0 && value <= max;
|
||||
};
|
||||
|
||||
return (
|
||||
validatePart(minute, 59) &&
|
||||
validatePart(hour, 23) &&
|
||||
validatePart(day, 31) &&
|
||||
validatePart(month, 12) &&
|
||||
validatePart(dayOfWeek, 7)
|
||||
);
|
||||
}
|
||||
|
|
@ -17,6 +17,15 @@ export const ListedRecurringRuleItem = RecurringJobRule.omit({
|
|||
input: true,
|
||||
});
|
||||
|
||||
/**
|
||||
* Schema for updating a recurring job rule.
|
||||
*/
|
||||
export const UpdateRecurringRuleSchema = RecurringJobRule
|
||||
.pick({
|
||||
input: true,
|
||||
cron: true,
|
||||
});
|
||||
|
||||
/**
|
||||
* Repository interface for managing recurring job rules in the system.
|
||||
*
|
||||
|
|
@ -82,6 +91,16 @@ export interface IRecurringJobRulesRepository {
|
|||
*/
|
||||
toggle(id: string, disabled: boolean): Promise<z.infer<typeof RecurringJobRule>>;
|
||||
|
||||
/**
|
||||
* Updates a recurring job rule with new input and cron expression.
|
||||
*
|
||||
* @param id - The unique identifier of the recurring job rule to update
|
||||
* @param data - The update data containing input messages and cron expression
|
||||
* @returns Promise resolving to the updated recurring job rule
|
||||
* @throws {NotFoundError} if the recurring job rule doesn't exist
|
||||
*/
|
||||
update(id: string, data: z.infer<typeof UpdateRecurringRuleSchema>): Promise<z.infer<typeof RecurringJobRule>>;
|
||||
|
||||
/**
|
||||
* Deletes a recurring job rule by its unique identifier.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -24,6 +24,17 @@ export const UpdateJobSchema = ScheduledJobRule.pick({
|
|||
output: true,
|
||||
});
|
||||
|
||||
/**
|
||||
* Schema for updating a scheduled job rule's next run configuration.
|
||||
*/
|
||||
export const UpdateScheduledRuleSchema = ScheduledJobRule
|
||||
.pick({
|
||||
input: true,
|
||||
})
|
||||
.extend({
|
||||
scheduledTime: z.string().datetime(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Repository interface for managing scheduled job rules in the system.
|
||||
*
|
||||
|
|
@ -69,6 +80,16 @@ export interface IScheduledJobRulesRepository {
|
|||
*/
|
||||
update(id: string, data: z.infer<typeof UpdateJobSchema>): Promise<z.infer<typeof ScheduledJobRule>>;
|
||||
|
||||
/**
|
||||
* Updates a scheduled job rule with new input and scheduled time.
|
||||
*
|
||||
* @param id - The unique identifier of the scheduled job rule to update
|
||||
* @param data - The update data containing input messages and scheduled time
|
||||
* @returns Promise resolving to the updated scheduled job rule
|
||||
* @throws {NotFoundError} if the scheduled job rule doesn't exist
|
||||
*/
|
||||
updateRule(id: string, data: z.infer<typeof UpdateScheduledRuleSchema>): Promise<z.infer<typeof ScheduledJobRule>>;
|
||||
|
||||
/**
|
||||
* Releases a scheduled job rule after it has been executed.
|
||||
*
|
||||
|
|
@ -103,4 +124,4 @@ export interface IScheduledJobRulesRepository {
|
|||
* @returns Promise resolving to void
|
||||
*/
|
||||
deleteByProjectId(projectId: string): Promise<void>;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { nanoid } from 'nanoid';
|
|||
import { ICacheService } from '@/src/application/services/cache.service.interface';
|
||||
import { IUsageQuotaPolicy } from '@/src/application/policies/usage-quota.policy.interface';
|
||||
import { IProjectActionAuthorizationPolicy } from '@/src/application/policies/project-action-authorization.policy';
|
||||
import { CopilotChatContext, CopilotMessage, DataSourceSchemaForCopilot } from '@/src/entities/models/copilot';
|
||||
import { CopilotChatContext, CopilotMessage, DataSourceSchemaForCopilot, TriggerSchemaForCopilot } from '@/src/entities/models/copilot';
|
||||
import { Workflow } from '@/app/lib/types/workflow_types';
|
||||
import { USE_BILLING } from "@/app/lib/feature_flags";
|
||||
import { authorize, getCustomerIdForProject } from "@/app/lib/billing";
|
||||
|
|
@ -19,6 +19,7 @@ const inputSchema = z.object({
|
|||
workflow: Workflow,
|
||||
context: CopilotChatContext.nullable(),
|
||||
dataSources: z.array(DataSourceSchemaForCopilot).optional(),
|
||||
triggers: z.array(TriggerSchemaForCopilot).optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -90,6 +90,7 @@ export class RunCopilotCachedTurnUseCase implements IRunCopilotCachedTurnUseCase
|
|||
cachedTurn.messages,
|
||||
cachedTurn.workflow,
|
||||
cachedTurn.dataSources || [],
|
||||
cachedTurn.triggers || [],
|
||||
)) {
|
||||
yield event;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { IProjectActionAuthorizationPolicy } from '../../policies/project-action
|
|||
import { IRecurringJobRulesRepository } from '../../repositories/recurring-job-rules.repository.interface';
|
||||
import { RecurringJobRule } from '@/src/entities/models/recurring-job-rule';
|
||||
import { Message } from '@/app/lib/types/types';
|
||||
import { isValidCronExpression } from '@/src/application/lib/utils/is-valid-cron-expression';
|
||||
|
||||
const inputSchema = z.object({
|
||||
caller: z.enum(["user", "api"]),
|
||||
|
|
@ -42,7 +43,7 @@ export class CreateRecurringJobRuleUseCase implements ICreateRecurringJobRuleUse
|
|||
|
||||
async execute(request: z.infer<typeof inputSchema>): Promise<z.infer<typeof RecurringJobRule>> {
|
||||
// Validate cron expression
|
||||
if (!this.isValidCronExpression(request.cron)) {
|
||||
if (!isValidCronExpression(request.cron)) {
|
||||
throw new BadRequestError('Invalid cron expression. Expected format: minute hour day month dayOfWeek');
|
||||
}
|
||||
|
||||
|
|
@ -66,31 +67,4 @@ export class CreateRecurringJobRuleUseCase implements ICreateRecurringJobRuleUse
|
|||
|
||||
return rule;
|
||||
}
|
||||
|
||||
private isValidCronExpression(cron: string): boolean {
|
||||
const parts = cron.split(' ');
|
||||
if (parts.length !== 5) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Basic validation - in production you'd want more sophisticated validation
|
||||
const [minute, hour, day, month, dayOfWeek] = parts;
|
||||
|
||||
// Check if parts are valid
|
||||
const isValidPart = (part: string) => {
|
||||
if (part === '*') return true;
|
||||
if (part.includes('/')) {
|
||||
const [range, step] = part.split('/');
|
||||
if (range === '*' || (parseInt(step) > 0 && parseInt(step) <= 59)) return true;
|
||||
return false;
|
||||
}
|
||||
if (part.includes('-')) {
|
||||
const [start, end] = part.split('-');
|
||||
return !isNaN(parseInt(start)) && !isNaN(parseInt(end)) && parseInt(start) <= parseInt(end);
|
||||
}
|
||||
return !isNaN(parseInt(part));
|
||||
};
|
||||
|
||||
return isValidPart(minute) && isValidPart(hour) && isValidPart(day) && isValidPart(month) && isValidPart(dayOfWeek);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,69 @@
|
|||
import { BadRequestError, NotFoundError } from '@/src/entities/errors/common';
|
||||
import { z } from "zod";
|
||||
import { IUsageQuotaPolicy } from '../../policies/usage-quota.policy.interface';
|
||||
import { IProjectActionAuthorizationPolicy } from '../../policies/project-action-authorization.policy';
|
||||
import { IRecurringJobRulesRepository } from '../../repositories/recurring-job-rules.repository.interface';
|
||||
import { RecurringJobRule } from '@/src/entities/models/recurring-job-rule';
|
||||
import { Message } from '@/app/lib/types/types';
|
||||
import { isValidCronExpression } from '@/src/application/lib/utils/is-valid-cron-expression';
|
||||
|
||||
const inputSchema = z.object({
|
||||
caller: z.enum(["user", "api"]),
|
||||
userId: z.string().optional(),
|
||||
apiKey: z.string().optional(),
|
||||
projectId: z.string(),
|
||||
ruleId: z.string(),
|
||||
input: z.object({
|
||||
messages: z.array(Message),
|
||||
}),
|
||||
cron: z.string(),
|
||||
});
|
||||
|
||||
export interface IUpdateRecurringJobRuleUseCase {
|
||||
execute(request: z.infer<typeof inputSchema>): Promise<z.infer<typeof RecurringJobRule>>;
|
||||
}
|
||||
|
||||
export class UpdateRecurringJobRuleUseCase implements IUpdateRecurringJobRuleUseCase {
|
||||
private readonly recurringJobRulesRepository: IRecurringJobRulesRepository;
|
||||
private readonly usageQuotaPolicy: IUsageQuotaPolicy;
|
||||
private readonly projectActionAuthorizationPolicy: IProjectActionAuthorizationPolicy;
|
||||
|
||||
constructor({
|
||||
recurringJobRulesRepository,
|
||||
usageQuotaPolicy,
|
||||
projectActionAuthorizationPolicy,
|
||||
}: {
|
||||
recurringJobRulesRepository: IRecurringJobRulesRepository,
|
||||
usageQuotaPolicy: IUsageQuotaPolicy,
|
||||
projectActionAuthorizationPolicy: IProjectActionAuthorizationPolicy,
|
||||
}) {
|
||||
this.recurringJobRulesRepository = recurringJobRulesRepository;
|
||||
this.usageQuotaPolicy = usageQuotaPolicy;
|
||||
this.projectActionAuthorizationPolicy = projectActionAuthorizationPolicy;
|
||||
}
|
||||
|
||||
async execute(request: z.infer<typeof inputSchema>): Promise<z.infer<typeof RecurringJobRule>> {
|
||||
if (!isValidCronExpression(request.cron)) {
|
||||
throw new BadRequestError('Invalid cron expression. Expected format: minute hour day month dayOfWeek');
|
||||
}
|
||||
|
||||
await this.projectActionAuthorizationPolicy.authorize({
|
||||
caller: request.caller,
|
||||
userId: request.userId,
|
||||
apiKey: request.apiKey,
|
||||
projectId: request.projectId,
|
||||
});
|
||||
|
||||
await this.usageQuotaPolicy.assertAndConsumeProjectAction(request.projectId);
|
||||
|
||||
const rule = await this.recurringJobRulesRepository.fetch(request.ruleId);
|
||||
if (!rule || rule.projectId !== request.projectId) {
|
||||
throw new NotFoundError('Recurring job rule not found');
|
||||
}
|
||||
|
||||
return await this.recurringJobRulesRepository.update(request.ruleId, {
|
||||
input: request.input,
|
||||
cron: request.cron,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
import { NotFoundError } from '@/src/entities/errors/common';
|
||||
import { z } from "zod";
|
||||
import { IUsageQuotaPolicy } from '../../policies/usage-quota.policy.interface';
|
||||
import { IProjectActionAuthorizationPolicy } from '../../policies/project-action-authorization.policy';
|
||||
import { IScheduledJobRulesRepository } from '../../repositories/scheduled-job-rules.repository.interface';
|
||||
import { ScheduledJobRule } from '@/src/entities/models/scheduled-job-rule';
|
||||
import { Message } from '@/app/lib/types/types';
|
||||
|
||||
const inputSchema = z.object({
|
||||
caller: z.enum(["user", "api"]),
|
||||
userId: z.string().optional(),
|
||||
apiKey: z.string().optional(),
|
||||
projectId: z.string(),
|
||||
ruleId: z.string(),
|
||||
input: z.object({
|
||||
messages: z.array(Message),
|
||||
}),
|
||||
scheduledTime: z.string().datetime(),
|
||||
});
|
||||
|
||||
export interface IUpdateScheduledJobRuleUseCase {
|
||||
execute(request: z.infer<typeof inputSchema>): Promise<z.infer<typeof ScheduledJobRule>>;
|
||||
}
|
||||
|
||||
export class UpdateScheduledJobRuleUseCase implements IUpdateScheduledJobRuleUseCase {
|
||||
private readonly scheduledJobRulesRepository: IScheduledJobRulesRepository;
|
||||
private readonly usageQuotaPolicy: IUsageQuotaPolicy;
|
||||
private readonly projectActionAuthorizationPolicy: IProjectActionAuthorizationPolicy;
|
||||
|
||||
constructor({
|
||||
scheduledJobRulesRepository,
|
||||
usageQuotaPolicy,
|
||||
projectActionAuthorizationPolicy,
|
||||
}: {
|
||||
scheduledJobRulesRepository: IScheduledJobRulesRepository,
|
||||
usageQuotaPolicy: IUsageQuotaPolicy,
|
||||
projectActionAuthorizationPolicy: IProjectActionAuthorizationPolicy,
|
||||
}) {
|
||||
this.scheduledJobRulesRepository = scheduledJobRulesRepository;
|
||||
this.usageQuotaPolicy = usageQuotaPolicy;
|
||||
this.projectActionAuthorizationPolicy = projectActionAuthorizationPolicy;
|
||||
}
|
||||
|
||||
async execute(request: z.infer<typeof inputSchema>): Promise<z.infer<typeof ScheduledJobRule>> {
|
||||
await this.projectActionAuthorizationPolicy.authorize({
|
||||
caller: request.caller,
|
||||
userId: request.userId,
|
||||
apiKey: request.apiKey,
|
||||
projectId: request.projectId,
|
||||
});
|
||||
|
||||
await this.usageQuotaPolicy.assertAndConsumeProjectAction(request.projectId);
|
||||
|
||||
const rule = await this.scheduledJobRulesRepository.fetch(request.ruleId);
|
||||
if (!rule || rule.projectId !== request.projectId) {
|
||||
throw new NotFoundError('Scheduled job rule not found');
|
||||
}
|
||||
|
||||
return await this.scheduledJobRulesRepository.updateRule(request.ruleId, {
|
||||
input: request.input,
|
||||
scheduledTime: request.scheduledTime,
|
||||
});
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue