mirror of
https://github.com/simstudioai/sim.git
synced 2026-02-06 04:35:03 -05:00
Compare commits
9 Commits
v0.5.80
...
feat/skill
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
92edf2f88a | ||
|
|
dfddb60cc5 | ||
|
|
6fea21c9aa | ||
|
|
84e77fe635 | ||
|
|
1d4d61a10a | ||
|
|
2d7e6c9796 | ||
|
|
ea3bab1f76 | ||
|
|
552dc56fc3 | ||
|
|
2147309365 |
@@ -206,10 +206,15 @@ export const {Service}Block: BlockConfig = {
|
||||
}
|
||||
```
|
||||
|
||||
**Critical:**
|
||||
- `canonicalParamId` must NOT match any other subblock's `id`, must be unique per block, and should only be used to link basic/advanced alternatives for the same parameter.
|
||||
- `mode` only controls UI visibility, NOT serialization. Without `canonicalParamId`, both basic and advanced field values would be sent.
|
||||
- Every subblock `id` must be unique within the block. Duplicate IDs cause conflicts even with different conditions.
|
||||
**Critical Canonical Param Rules:**
|
||||
- `canonicalParamId` must NOT match any subblock's `id` in the block
|
||||
- `canonicalParamId` must be unique per operation/condition context
|
||||
- Only use `canonicalParamId` to link basic/advanced alternatives for the same logical parameter
|
||||
- `mode` only controls UI visibility, NOT serialization. Without `canonicalParamId`, both basic and advanced field values would be sent
|
||||
- Every subblock `id` must be unique within the block. Duplicate IDs cause conflicts even with different conditions
|
||||
- **Required consistency:** If one subblock in a canonical group has `required: true`, ALL subblocks in that group must have `required: true` (prevents bypassing validation by switching modes)
|
||||
- **Inputs section:** Must list canonical param IDs (e.g., `fileId`), NOT raw subblock IDs (e.g., `fileSelector`, `manualFileId`)
|
||||
- **Params function:** Must use canonical param IDs, NOT raw subblock IDs (raw IDs are deleted after canonical transformation)
|
||||
|
||||
## Step 4: Add Icon
|
||||
|
||||
|
||||
@@ -157,6 +157,36 @@ dependsOn: { all: ['authMethod'], any: ['credential', 'botToken'] }
|
||||
- `'both'` - Show in both modes (default)
|
||||
- `'trigger'` - Only when block is used as trigger
|
||||
|
||||
### `canonicalParamId` - Link basic/advanced alternatives
|
||||
|
||||
Use to map multiple UI inputs to a single logical parameter:
|
||||
|
||||
```typescript
|
||||
// Basic mode: Visual selector
|
||||
{
|
||||
id: 'fileSelector',
|
||||
type: 'file-selector',
|
||||
mode: 'basic',
|
||||
canonicalParamId: 'fileId',
|
||||
required: true,
|
||||
},
|
||||
// Advanced mode: Manual input
|
||||
{
|
||||
id: 'manualFileId',
|
||||
type: 'short-input',
|
||||
mode: 'advanced',
|
||||
canonicalParamId: 'fileId',
|
||||
required: true,
|
||||
},
|
||||
```
|
||||
|
||||
**Critical Rules:**
|
||||
- `canonicalParamId` must NOT match any subblock's `id`
|
||||
- `canonicalParamId` must be unique per operation/condition context
|
||||
- **Required consistency:** All subblocks in a canonical group must have the same `required` status
|
||||
- **Inputs section:** Must list canonical param IDs (e.g., `fileId`), NOT raw subblock IDs
|
||||
- **Params function:** Must use canonical param IDs (raw IDs are deleted after canonical transformation)
|
||||
|
||||
**Register in `blocks/registry.ts`:**
|
||||
|
||||
```typescript
|
||||
|
||||
@@ -155,6 +155,36 @@ dependsOn: { all: ['authMethod'], any: ['credential', 'botToken'] }
|
||||
- `'both'` - Show in both modes (default)
|
||||
- `'trigger'` - Only when block is used as trigger
|
||||
|
||||
### `canonicalParamId` - Link basic/advanced alternatives
|
||||
|
||||
Use to map multiple UI inputs to a single logical parameter:
|
||||
|
||||
```typescript
|
||||
// Basic mode: Visual selector
|
||||
{
|
||||
id: 'fileSelector',
|
||||
type: 'file-selector',
|
||||
mode: 'basic',
|
||||
canonicalParamId: 'fileId',
|
||||
required: true,
|
||||
},
|
||||
// Advanced mode: Manual input
|
||||
{
|
||||
id: 'manualFileId',
|
||||
type: 'short-input',
|
||||
mode: 'advanced',
|
||||
canonicalParamId: 'fileId',
|
||||
required: true,
|
||||
},
|
||||
```
|
||||
|
||||
**Critical Rules:**
|
||||
- `canonicalParamId` must NOT match any subblock's `id`
|
||||
- `canonicalParamId` must be unique per operation/condition context
|
||||
- **Required consistency:** All subblocks in a canonical group must have the same `required` status
|
||||
- **Inputs section:** Must list canonical param IDs (e.g., `fileId`), NOT raw subblock IDs
|
||||
- **Params function:** Must use canonical param IDs (raw IDs are deleted after canonical transformation)
|
||||
|
||||
**Register in `blocks/registry.ts`:**
|
||||
|
||||
```typescript
|
||||
|
||||
@@ -163,9 +163,9 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
|
||||
elevenlabs: ElevenLabsIcon,
|
||||
enrich: EnrichSoIcon,
|
||||
exa: ExaAIIcon,
|
||||
file_v2: DocumentIcon,
|
||||
file_v3: DocumentIcon,
|
||||
firecrawl: FirecrawlIcon,
|
||||
fireflies: FirefliesIcon,
|
||||
fireflies_v2: FirefliesIcon,
|
||||
github_v2: GithubIcon,
|
||||
gitlab: GitLabIcon,
|
||||
gmail_v2: GmailIcon,
|
||||
@@ -177,7 +177,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
|
||||
google_maps: GoogleMapsIcon,
|
||||
google_search: GoogleIcon,
|
||||
google_sheets_v2: GoogleSheetsIcon,
|
||||
google_slides: GoogleSlidesIcon,
|
||||
google_slides_v2: GoogleSlidesIcon,
|
||||
google_vault: GoogleVaultIcon,
|
||||
grafana: GrafanaIcon,
|
||||
grain: GrainIcon,
|
||||
@@ -206,7 +206,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
|
||||
microsoft_excel_v2: MicrosoftExcelIcon,
|
||||
microsoft_planner: MicrosoftPlannerIcon,
|
||||
microsoft_teams: MicrosoftTeamsIcon,
|
||||
mistral_parse_v2: MistralIcon,
|
||||
mistral_parse_v3: MistralIcon,
|
||||
mongodb: MongoDBIcon,
|
||||
mysql: MySQLIcon,
|
||||
neo4j: Neo4jIcon,
|
||||
@@ -221,11 +221,11 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
|
||||
polymarket: PolymarketIcon,
|
||||
postgresql: PostgresIcon,
|
||||
posthog: PosthogIcon,
|
||||
pulse: PulseIcon,
|
||||
pulse_v2: PulseIcon,
|
||||
qdrant: QdrantIcon,
|
||||
rds: RDSIcon,
|
||||
reddit: RedditIcon,
|
||||
reducto: ReductoIcon,
|
||||
reducto_v2: ReductoIcon,
|
||||
resend: ResendIcon,
|
||||
s3: S3Icon,
|
||||
salesforce: SalesforceIcon,
|
||||
@@ -244,11 +244,11 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
|
||||
ssh: SshIcon,
|
||||
stagehand: StagehandIcon,
|
||||
stripe: StripeIcon,
|
||||
stt: STTIcon,
|
||||
stt_v2: STTIcon,
|
||||
supabase: SupabaseIcon,
|
||||
tavily: TavilyIcon,
|
||||
telegram: TelegramIcon,
|
||||
textract: TextractIcon,
|
||||
textract_v2: TextractIcon,
|
||||
tinybird: TinybirdIcon,
|
||||
translate: TranslateIcon,
|
||||
trello: TrelloIcon,
|
||||
@@ -257,7 +257,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
|
||||
twilio_voice: TwilioIcon,
|
||||
typeform: TypeformIcon,
|
||||
video_generator_v2: VideoIcon,
|
||||
vision: EyeIcon,
|
||||
vision_v2: EyeIcon,
|
||||
wealthbox: WealthboxIcon,
|
||||
webflow: WebflowIcon,
|
||||
whatsapp: WhatsAppIcon,
|
||||
|
||||
@@ -6,7 +6,7 @@ description: Mehrere Dateien lesen und parsen
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="file"
|
||||
type="file_v3"
|
||||
color="#40916C"
|
||||
/>
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ description: Interagieren Sie mit Fireflies.ai-Besprechungstranskripten und -auf
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="fireflies"
|
||||
type="fireflies_v2"
|
||||
color="#100730"
|
||||
/>
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ description: Text aus PDF-Dokumenten extrahieren
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="mistral_parse"
|
||||
type="mistral_parse_v3"
|
||||
color="#000000"
|
||||
/>
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"connections",
|
||||
"mcp",
|
||||
"copilot",
|
||||
"skills",
|
||||
"knowledgebase",
|
||||
"variables",
|
||||
"execution",
|
||||
|
||||
83
apps/docs/content/docs/en/skills/index.mdx
Normal file
83
apps/docs/content/docs/en/skills/index.mdx
Normal file
@@ -0,0 +1,83 @@
|
||||
---
|
||||
title: Agent Skills
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout'
|
||||
|
||||
Agent Skills are reusable packages of instructions that give your AI agents specialized capabilities. Based on the open [Agent Skills](https://agentskills.io) format, skills let you capture domain expertise, workflows, and best practices that agents can load on demand.
|
||||
|
||||
## How Skills Work
|
||||
|
||||
Skills use **progressive disclosure** to keep agent context lean:
|
||||
|
||||
1. **Discovery** — Only skill names and descriptions are included in the agent's system prompt (~50-100 tokens each)
|
||||
2. **Activation** — When the agent decides a skill is relevant, it calls the `load_skill` tool to load the full instructions into context
|
||||
3. **Execution** — The agent follows the loaded instructions to complete the task
|
||||
|
||||
This means you can attach many skills to an agent without bloating its context window. The agent only loads what it needs.
|
||||
|
||||
## Creating Skills
|
||||
|
||||
Go to **Settings** (gear icon) and select **Skills** under the Tools section.
|
||||
|
||||
Click **Add** to create a new skill with three fields:
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| **Name** | A kebab-case identifier (e.g. `sql-expert`, `code-reviewer`). Max 64 characters. |
|
||||
| **Description** | A short explanation of what the skill does and when to use it. This is what the agent reads to decide whether to activate the skill. Max 1024 characters. |
|
||||
| **Content** | The full skill instructions in markdown. This is loaded when the agent activates the skill. |
|
||||
|
||||
<Callout type="info">
|
||||
The description is critical — it's the only thing the agent sees before deciding to load a skill. Be specific about when and why the skill should be used.
|
||||
</Callout>
|
||||
|
||||
### Writing Good Skill Content
|
||||
|
||||
Skill content follows the same conventions as [SKILL.md files](https://agentskills.io/specification):
|
||||
|
||||
```markdown
|
||||
# SQL Expert
|
||||
|
||||
## When to use this skill
|
||||
Use when the user asks you to write, optimize, or debug SQL queries.
|
||||
|
||||
## Instructions
|
||||
1. Always ask which database engine (PostgreSQL, MySQL, SQLite)
|
||||
2. Use CTEs over subqueries for readability
|
||||
3. Add index recommendations when relevant
|
||||
4. Explain query plans for optimization requests
|
||||
|
||||
## Common Patterns
|
||||
...
|
||||
```
|
||||
|
||||
## Adding Skills to an Agent
|
||||
|
||||
Open any **Agent** block and find the **Skills** dropdown below the tools section. Select the skills you want the agent to have access to.
|
||||
|
||||
Selected skills appear as chips that you can click to edit or remove.
|
||||
|
||||
### What Happens at Runtime
|
||||
|
||||
When the workflow runs:
|
||||
|
||||
1. The agent's system prompt includes an `<available_skills>` section listing each skill's name and description
|
||||
2. A `load_skill` tool is automatically added to the agent's available tools
|
||||
3. When the agent determines a skill is relevant to the current task, it calls `load_skill` with the skill name
|
||||
4. The full skill content is returned as a tool response, giving the agent detailed instructions
|
||||
|
||||
This works across all supported LLM providers — the `load_skill` tool uses standard tool-calling, so no provider-specific configuration is needed.
|
||||
|
||||
## Tips
|
||||
|
||||
- **Keep descriptions actionable** — Instead of "Helps with SQL", write "Write optimized SQL queries for PostgreSQL, MySQL, and SQLite, including index recommendations and query plan analysis"
|
||||
- **One skill per domain** — A focused `sql-expert` skill works better than a broad `database-everything` skill
|
||||
- **Use markdown structure** — Headers, lists, and code blocks help the agent parse and follow instructions
|
||||
- **Test iteratively** — Run your workflow and check if the agent activates the skill when expected
|
||||
|
||||
## Learn More
|
||||
|
||||
- [Agent Skills specification](https://agentskills.io) — The open format for portable agent skills
|
||||
- [Example skills](https://github.com/anthropics/skills) — Browse community skill examples
|
||||
- [Best practices](https://agentskills.io/what-are-skills) — Writing effective skills
|
||||
@@ -49,10 +49,25 @@ Retrieve content from Confluence pages using the Confluence API.
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `ts` | string | Timestamp of retrieval |
|
||||
| `ts` | string | ISO 8601 timestamp of the operation |
|
||||
| `pageId` | string | Confluence page ID |
|
||||
| `content` | string | Page content with HTML tags stripped |
|
||||
| `title` | string | Page title |
|
||||
| `content` | string | Page content with HTML tags stripped |
|
||||
| `status` | string | Page status \(current, archived, trashed, draft\) |
|
||||
| `spaceId` | string | ID of the space containing the page |
|
||||
| `parentId` | string | ID of the parent page |
|
||||
| `authorId` | string | Account ID of the page author |
|
||||
| `createdAt` | string | ISO 8601 timestamp when the page was created |
|
||||
| `url` | string | URL to view the page in Confluence |
|
||||
| `body` | object | Raw page body content in storage format |
|
||||
| ↳ `value` | string | The content value in the specified format |
|
||||
| ↳ `representation` | string | Content representation type |
|
||||
| `version` | object | Page version information |
|
||||
| ↳ `number` | number | Version number |
|
||||
| ↳ `message` | string | Version message |
|
||||
| ↳ `minorEdit` | boolean | Whether this is a minor edit |
|
||||
| ↳ `authorId` | string | Account ID of the version author |
|
||||
| ↳ `createdAt` | string | ISO 8601 timestamp of version creation |
|
||||
|
||||
### `confluence_update`
|
||||
|
||||
@@ -76,6 +91,25 @@ Update a Confluence page using the Confluence API.
|
||||
| `ts` | string | Timestamp of update |
|
||||
| `pageId` | string | Confluence page ID |
|
||||
| `title` | string | Updated page title |
|
||||
| `status` | string | Page status |
|
||||
| `spaceId` | string | Space ID |
|
||||
| `body` | object | Page body content in storage format |
|
||||
| ↳ `storage` | object | Body in storage format \(Confluence markup\) |
|
||||
| ↳ `value` | string | The content value in the specified format |
|
||||
| ↳ `representation` | string | Content representation type |
|
||||
| ↳ `view` | object | Body in view format \(rendered HTML\) |
|
||||
| ↳ `value` | string | The content value in the specified format |
|
||||
| ↳ `representation` | string | Content representation type |
|
||||
| ↳ `atlas_doc_format` | object | Body in Atlassian Document Format \(ADF\) |
|
||||
| ↳ `value` | string | The content value in the specified format |
|
||||
| ↳ `representation` | string | Content representation type |
|
||||
| `version` | object | Page version information |
|
||||
| ↳ `number` | number | Version number |
|
||||
| ↳ `message` | string | Version message |
|
||||
| ↳ `minorEdit` | boolean | Whether this is a minor edit |
|
||||
| ↳ `authorId` | string | Account ID of the version author |
|
||||
| ↳ `createdAt` | string | ISO 8601 timestamp of version creation |
|
||||
| `url` | string | URL to view the page in Confluence |
|
||||
| `success` | boolean | Update operation success status |
|
||||
|
||||
### `confluence_create_page`
|
||||
@@ -100,11 +134,30 @@ Create a new page in a Confluence space.
|
||||
| `ts` | string | Timestamp of creation |
|
||||
| `pageId` | string | Created page ID |
|
||||
| `title` | string | Page title |
|
||||
| `status` | string | Page status |
|
||||
| `spaceId` | string | Space ID |
|
||||
| `parentId` | string | Parent page ID |
|
||||
| `body` | object | Page body content |
|
||||
| ↳ `storage` | object | Body in storage format \(Confluence markup\) |
|
||||
| ↳ `value` | string | The content value in the specified format |
|
||||
| ↳ `representation` | string | Content representation type |
|
||||
| ↳ `view` | object | Body in view format \(rendered HTML\) |
|
||||
| ↳ `value` | string | The content value in the specified format |
|
||||
| ↳ `representation` | string | Content representation type |
|
||||
| ↳ `atlas_doc_format` | object | Body in Atlassian Document Format \(ADF\) |
|
||||
| ↳ `value` | string | The content value in the specified format |
|
||||
| ↳ `representation` | string | Content representation type |
|
||||
| `version` | object | Page version information |
|
||||
| ↳ `number` | number | Version number |
|
||||
| ↳ `message` | string | Version message |
|
||||
| ↳ `minorEdit` | boolean | Whether this is a minor edit |
|
||||
| ↳ `authorId` | string | Account ID of the version author |
|
||||
| ↳ `createdAt` | string | ISO 8601 timestamp of version creation |
|
||||
| `url` | string | Page URL |
|
||||
|
||||
### `confluence_delete_page`
|
||||
|
||||
Delete a Confluence page (moves it to trash where it can be restored).
|
||||
Delete a Confluence page. By default moves to trash; use purge=true to permanently delete.
|
||||
|
||||
#### Input
|
||||
|
||||
@@ -112,6 +165,7 @@ Delete a Confluence page (moves it to trash where it can be restored).
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `domain` | string | Yes | Your Confluence domain \(e.g., yourcompany.atlassian.net\) |
|
||||
| `pageId` | string | Yes | Confluence page ID to delete |
|
||||
| `purge` | boolean | No | If true, permanently deletes the page instead of moving to trash \(default: false\) |
|
||||
| `cloudId` | string | No | Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain. |
|
||||
|
||||
#### Output
|
||||
@@ -122,6 +176,229 @@ Delete a Confluence page (moves it to trash where it can be restored).
|
||||
| `pageId` | string | Deleted page ID |
|
||||
| `deleted` | boolean | Deletion status |
|
||||
|
||||
### `confluence_list_pages_in_space`
|
||||
|
||||
List all pages within a specific Confluence space. Supports pagination and filtering by status.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `domain` | string | Yes | Your Confluence domain \(e.g., yourcompany.atlassian.net\) |
|
||||
| `spaceId` | string | Yes | The ID of the Confluence space to list pages from |
|
||||
| `limit` | number | No | Maximum number of pages to return \(default: 50, max: 250\) |
|
||||
| `status` | string | No | Filter pages by status: current, archived, trashed, or draft |
|
||||
| `bodyFormat` | string | No | Format for page body content: storage, atlas_doc_format, or view. If not specified, body is not included. |
|
||||
| `cursor` | string | No | Pagination cursor from previous response to get the next page of results |
|
||||
| `cloudId` | string | No | Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain. |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `ts` | string | ISO 8601 timestamp of the operation |
|
||||
| `pages` | array | Array of pages in the space |
|
||||
| ↳ `id` | string | Unique page identifier |
|
||||
| ↳ `title` | string | Page title |
|
||||
| ↳ `status` | string | Page status \(e.g., current, archived, trashed, draft\) |
|
||||
| ↳ `spaceId` | string | ID of the space containing the page |
|
||||
| ↳ `parentId` | string | ID of the parent page \(null if top-level\) |
|
||||
| ↳ `authorId` | string | Account ID of the page author |
|
||||
| ↳ `createdAt` | string | ISO 8601 timestamp when the page was created |
|
||||
| ↳ `version` | object | Page version information |
|
||||
| ↳ `number` | number | Version number |
|
||||
| ↳ `message` | string | Version message |
|
||||
| ↳ `minorEdit` | boolean | Whether this is a minor edit |
|
||||
| ↳ `authorId` | string | Account ID of the version author |
|
||||
| ↳ `createdAt` | string | ISO 8601 timestamp of version creation |
|
||||
| ↳ `body` | object | Page body content \(if bodyFormat was specified\) |
|
||||
| ↳ `storage` | object | Body in storage format \(Confluence markup\) |
|
||||
| ↳ `value` | string | The content value in the specified format |
|
||||
| ↳ `representation` | string | Content representation type |
|
||||
| ↳ `view` | object | Body in view format \(rendered HTML\) |
|
||||
| ↳ `value` | string | The content value in the specified format |
|
||||
| ↳ `representation` | string | Content representation type |
|
||||
| ↳ `atlas_doc_format` | object | Body in Atlassian Document Format \(ADF\) |
|
||||
| ↳ `value` | string | The content value in the specified format |
|
||||
| ↳ `representation` | string | Content representation type |
|
||||
| ↳ `webUrl` | string | URL to view the page in Confluence |
|
||||
| `nextCursor` | string | Cursor for fetching the next page of results |
|
||||
|
||||
### `confluence_get_page_children`
|
||||
|
||||
Get all child pages of a specific Confluence page. Useful for navigating page hierarchies.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `domain` | string | Yes | Your Confluence domain \(e.g., yourcompany.atlassian.net\) |
|
||||
| `pageId` | string | Yes | The ID of the parent page to get children from |
|
||||
| `limit` | number | No | Maximum number of child pages to return \(default: 50, max: 250\) |
|
||||
| `cursor` | string | No | Pagination cursor from previous response to get the next page of results |
|
||||
| `cloudId` | string | No | Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain. |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `ts` | string | ISO 8601 timestamp of the operation |
|
||||
| `parentId` | string | ID of the parent page |
|
||||
| `children` | array | Array of child pages |
|
||||
| ↳ `id` | string | Child page ID |
|
||||
| ↳ `title` | string | Child page title |
|
||||
| ↳ `status` | string | Page status |
|
||||
| ↳ `spaceId` | string | Space ID |
|
||||
| ↳ `childPosition` | number | Position among siblings |
|
||||
| ↳ `webUrl` | string | URL to view the page |
|
||||
| `nextCursor` | string | Cursor for fetching the next page of results |
|
||||
|
||||
### `confluence_get_page_ancestors`
|
||||
|
||||
Get the ancestor (parent) pages of a specific Confluence page. Returns the full hierarchy from the page up to the root.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `domain` | string | Yes | Your Confluence domain \(e.g., yourcompany.atlassian.net\) |
|
||||
| `pageId` | string | Yes | The ID of the page to get ancestors for |
|
||||
| `limit` | number | No | Maximum number of ancestors to return \(default: 25, max: 250\) |
|
||||
| `cloudId` | string | No | Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain. |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `ts` | string | ISO 8601 timestamp of the operation |
|
||||
| `pageId` | string | ID of the page whose ancestors were retrieved |
|
||||
| `ancestors` | array | Array of ancestor pages, ordered from direct parent to root |
|
||||
| ↳ `id` | string | Ancestor page ID |
|
||||
| ↳ `title` | string | Ancestor page title |
|
||||
| ↳ `status` | string | Page status |
|
||||
| ↳ `spaceId` | string | Space ID |
|
||||
| ↳ `webUrl` | string | URL to view the page |
|
||||
|
||||
### `confluence_list_page_versions`
|
||||
|
||||
List all versions (revision history) of a Confluence page.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `domain` | string | Yes | Your Confluence domain \(e.g., yourcompany.atlassian.net\) |
|
||||
| `pageId` | string | Yes | The ID of the page to get versions for |
|
||||
| `limit` | number | No | Maximum number of versions to return \(default: 50, max: 250\) |
|
||||
| `cursor` | string | No | Pagination cursor from previous response |
|
||||
| `cloudId` | string | No | Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain. |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `ts` | string | ISO 8601 timestamp of the operation |
|
||||
| `pageId` | string | ID of the page |
|
||||
| `versions` | array | Array of page versions |
|
||||
| ↳ `number` | number | Version number |
|
||||
| ↳ `message` | string | Version message |
|
||||
| ↳ `minorEdit` | boolean | Whether this is a minor edit |
|
||||
| ↳ `authorId` | string | Account ID of the version author |
|
||||
| ↳ `createdAt` | string | ISO 8601 timestamp of version creation |
|
||||
| `nextCursor` | string | Cursor for fetching the next page of results |
|
||||
|
||||
### `confluence_get_page_version`
|
||||
|
||||
Get details about a specific version of a Confluence page.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `domain` | string | Yes | Your Confluence domain \(e.g., yourcompany.atlassian.net\) |
|
||||
| `pageId` | string | Yes | The ID of the page |
|
||||
| `versionNumber` | number | Yes | The version number to retrieve \(e.g., 1, 2, 3\) |
|
||||
| `cloudId` | string | No | Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain. |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `ts` | string | ISO 8601 timestamp of the operation |
|
||||
| `pageId` | string | ID of the page |
|
||||
| `version` | object | Detailed version information |
|
||||
| ↳ `number` | number | Version number |
|
||||
| ↳ `message` | string | Version message |
|
||||
| ↳ `minorEdit` | boolean | Whether this is a minor edit |
|
||||
| ↳ `authorId` | string | Account ID of the version author |
|
||||
| ↳ `createdAt` | string | ISO 8601 timestamp of version creation |
|
||||
| ↳ `contentTypeModified` | boolean | Whether the content type was modified in this version |
|
||||
| ↳ `collaborators` | array | List of collaborator account IDs for this version |
|
||||
| ↳ `prevVersion` | number | Previous version number |
|
||||
| ↳ `nextVersion` | number | Next version number |
|
||||
|
||||
### `confluence_list_page_properties`
|
||||
|
||||
List all custom properties (metadata) attached to a Confluence page.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `domain` | string | Yes | Your Confluence domain \(e.g., yourcompany.atlassian.net\) |
|
||||
| `pageId` | string | Yes | The ID of the page to list properties from |
|
||||
| `limit` | number | No | Maximum number of properties to return \(default: 50, max: 250\) |
|
||||
| `cursor` | string | No | Pagination cursor from previous response |
|
||||
| `cloudId` | string | No | Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain. |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `ts` | string | ISO 8601 timestamp of the operation |
|
||||
| `pageId` | string | ID of the page |
|
||||
| `properties` | array | Array of content properties |
|
||||
| ↳ `id` | string | Property ID |
|
||||
| ↳ `key` | string | Property key |
|
||||
| ↳ `value` | json | Property value \(can be any JSON\) |
|
||||
| ↳ `version` | object | Version information |
|
||||
| ↳ `number` | number | Version number |
|
||||
| ↳ `message` | string | Version message |
|
||||
| ↳ `minorEdit` | boolean | Whether this is a minor edit |
|
||||
| ↳ `authorId` | string | Account ID of the version author |
|
||||
| ↳ `createdAt` | string | ISO 8601 timestamp of version creation |
|
||||
| `nextCursor` | string | Cursor for fetching the next page of results |
|
||||
|
||||
### `confluence_create_page_property`
|
||||
|
||||
Create a new custom property (metadata) on a Confluence page.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `domain` | string | Yes | Your Confluence domain \(e.g., yourcompany.atlassian.net\) |
|
||||
| `pageId` | string | Yes | The ID of the page to add the property to |
|
||||
| `key` | string | Yes | The key/name for the property |
|
||||
| `value` | json | Yes | The value for the property \(can be any JSON value\) |
|
||||
| `cloudId` | string | No | Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain. |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `ts` | string | ISO 8601 timestamp of the operation |
|
||||
| `pageId` | string | ID of the page |
|
||||
| `propertyId` | string | ID of the created property |
|
||||
| `key` | string | Property key |
|
||||
| `value` | json | Property value |
|
||||
| `version` | object | Version information |
|
||||
| ↳ `number` | number | Version number |
|
||||
| ↳ `message` | string | Version message |
|
||||
| ↳ `minorEdit` | boolean | Whether this is a minor edit |
|
||||
| ↳ `authorId` | string | Account ID of the version author |
|
||||
| ↳ `createdAt` | string | ISO 8601 timestamp of version creation |
|
||||
|
||||
### `confluence_search`
|
||||
|
||||
Search for content across Confluence pages, blog posts, and other content.
|
||||
@@ -155,6 +432,211 @@ Search for content across Confluence pages, blog posts, and other content.
|
||||
| ↳ `lastModified` | string | ISO 8601 timestamp of last modification |
|
||||
| ↳ `entityType` | string | Entity type identifier \(e.g., content, space\) |
|
||||
|
||||
### `confluence_search_in_space`
|
||||
|
||||
Search for content within a specific Confluence space. Optionally filter by text query and content type.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `domain` | string | Yes | Your Confluence domain \(e.g., yourcompany.atlassian.net\) |
|
||||
| `spaceKey` | string | Yes | The key of the Confluence space to search in \(e.g., "ENG", "HR"\) |
|
||||
| `query` | string | No | Text search query. If not provided, returns all content in the space. |
|
||||
| `contentType` | string | No | Filter by content type: page, blogpost, attachment, or comment |
|
||||
| `limit` | number | No | Maximum number of results to return \(default: 25, max: 250\) |
|
||||
| `cloudId` | string | No | Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain. |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `ts` | string | ISO 8601 timestamp of the operation |
|
||||
| `spaceKey` | string | The space key that was searched |
|
||||
| `totalSize` | number | Total number of matching results |
|
||||
| `results` | array | Array of search results |
|
||||
| ↳ `id` | string | Unique content identifier |
|
||||
| ↳ `title` | string | Content title |
|
||||
| ↳ `type` | string | Content type \(e.g., page, blogpost, attachment, comment\) |
|
||||
| ↳ `status` | string | Content status \(e.g., current\) |
|
||||
| ↳ `url` | string | URL to view the content in Confluence |
|
||||
| ↳ `excerpt` | string | Text excerpt matching the search query |
|
||||
| ↳ `spaceKey` | string | Key of the space containing the content |
|
||||
| ↳ `space` | object | Space information for the content |
|
||||
| ↳ `id` | string | Space identifier |
|
||||
| ↳ `key` | string | Space key |
|
||||
| ↳ `name` | string | Space name |
|
||||
| ↳ `lastModified` | string | ISO 8601 timestamp of last modification |
|
||||
| ↳ `entityType` | string | Entity type identifier \(e.g., content, space\) |
|
||||
|
||||
### `confluence_list_blogposts`
|
||||
|
||||
List all blog posts across all accessible Confluence spaces.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `domain` | string | Yes | Your Confluence domain \(e.g., yourcompany.atlassian.net\) |
|
||||
| `limit` | number | No | Maximum number of blog posts to return \(default: 25, max: 250\) |
|
||||
| `status` | string | No | Filter by status: current, archived, trashed, or draft |
|
||||
| `sort` | string | No | Sort order: created-date, -created-date, modified-date, -modified-date, title, -title |
|
||||
| `cursor` | string | No | Pagination cursor from previous response |
|
||||
| `cloudId` | string | No | Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain. |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `ts` | string | ISO 8601 timestamp of the operation |
|
||||
| `blogPosts` | array | Array of blog posts |
|
||||
| ↳ `id` | string | Blog post ID |
|
||||
| ↳ `title` | string | Blog post title |
|
||||
| ↳ `status` | string | Blog post status |
|
||||
| ↳ `spaceId` | string | Space ID |
|
||||
| ↳ `authorId` | string | Author account ID |
|
||||
| ↳ `createdAt` | string | Creation timestamp |
|
||||
| ↳ `version` | object | Version information |
|
||||
| ↳ `number` | number | Version number |
|
||||
| ↳ `message` | string | Version message |
|
||||
| ↳ `minorEdit` | boolean | Whether this is a minor edit |
|
||||
| ↳ `authorId` | string | Account ID of the version author |
|
||||
| ↳ `createdAt` | string | ISO 8601 timestamp of version creation |
|
||||
| ↳ `webUrl` | string | URL to view the blog post |
|
||||
| `nextCursor` | string | Cursor for fetching the next page of results |
|
||||
|
||||
### `confluence_get_blogpost`
|
||||
|
||||
Get a specific Confluence blog post by ID, including its content.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `domain` | string | Yes | Your Confluence domain \(e.g., yourcompany.atlassian.net\) |
|
||||
| `blogPostId` | string | Yes | The ID of the blog post to retrieve |
|
||||
| `bodyFormat` | string | No | Format for blog post body: storage, atlas_doc_format, or view |
|
||||
| `cloudId` | string | No | Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain. |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `ts` | string | ISO 8601 timestamp of the operation |
|
||||
| `id` | string | Blog post ID |
|
||||
| `title` | string | Blog post title |
|
||||
| `status` | string | Blog post status |
|
||||
| `spaceId` | string | Space ID |
|
||||
| `authorId` | string | Author account ID |
|
||||
| `createdAt` | string | Creation timestamp |
|
||||
| `version` | object | Version information |
|
||||
| ↳ `number` | number | Version number |
|
||||
| ↳ `message` | string | Version message |
|
||||
| ↳ `minorEdit` | boolean | Whether this is a minor edit |
|
||||
| ↳ `authorId` | string | Account ID of the version author |
|
||||
| ↳ `createdAt` | string | ISO 8601 timestamp of version creation |
|
||||
| `body` | object | Blog post body content in requested format\(s\) |
|
||||
| ↳ `storage` | object | Body in storage format \(Confluence markup\) |
|
||||
| ↳ `value` | string | The content value in the specified format |
|
||||
| ↳ `representation` | string | Content representation type |
|
||||
| ↳ `view` | object | Body in view format \(rendered HTML\) |
|
||||
| ↳ `value` | string | The content value in the specified format |
|
||||
| ↳ `representation` | string | Content representation type |
|
||||
| ↳ `atlas_doc_format` | object | Body in Atlassian Document Format \(ADF\) |
|
||||
| ↳ `value` | string | The content value in the specified format |
|
||||
| ↳ `representation` | string | Content representation type |
|
||||
| `webUrl` | string | URL to view the blog post |
|
||||
|
||||
### `confluence_create_blogpost`
|
||||
|
||||
Create a new blog post in a Confluence space.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `domain` | string | Yes | Your Confluence domain \(e.g., yourcompany.atlassian.net\) |
|
||||
| `spaceId` | string | Yes | The ID of the space to create the blog post in |
|
||||
| `title` | string | Yes | Title of the blog post |
|
||||
| `content` | string | Yes | Blog post content in Confluence storage format \(HTML\) |
|
||||
| `status` | string | No | Blog post status: current \(default\) or draft |
|
||||
| `cloudId` | string | No | Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain. |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `ts` | string | ISO 8601 timestamp of the operation |
|
||||
| `id` | string | Created blog post ID |
|
||||
| `title` | string | Blog post title |
|
||||
| `status` | string | Blog post status |
|
||||
| `spaceId` | string | Space ID |
|
||||
| `authorId` | string | Author account ID |
|
||||
| `body` | object | Blog post body content |
|
||||
| ↳ `storage` | object | Body in storage format \(Confluence markup\) |
|
||||
| ↳ `value` | string | The content value in the specified format |
|
||||
| ↳ `representation` | string | Content representation type |
|
||||
| ↳ `view` | object | Body in view format \(rendered HTML\) |
|
||||
| ↳ `value` | string | The content value in the specified format |
|
||||
| ↳ `representation` | string | Content representation type |
|
||||
| ↳ `atlas_doc_format` | object | Body in Atlassian Document Format \(ADF\) |
|
||||
| ↳ `value` | string | The content value in the specified format |
|
||||
| ↳ `representation` | string | Content representation type |
|
||||
| `version` | object | Blog post version information |
|
||||
| ↳ `number` | number | Version number |
|
||||
| ↳ `message` | string | Version message |
|
||||
| ↳ `minorEdit` | boolean | Whether this is a minor edit |
|
||||
| ↳ `authorId` | string | Account ID of the version author |
|
||||
| ↳ `createdAt` | string | ISO 8601 timestamp of version creation |
|
||||
| `webUrl` | string | URL to view the blog post |
|
||||
|
||||
### `confluence_list_blogposts_in_space`
|
||||
|
||||
List all blog posts within a specific Confluence space.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `domain` | string | Yes | Your Confluence domain \(e.g., yourcompany.atlassian.net\) |
|
||||
| `spaceId` | string | Yes | The ID of the Confluence space to list blog posts from |
|
||||
| `limit` | number | No | Maximum number of blog posts to return \(default: 25, max: 250\) |
|
||||
| `status` | string | No | Filter by status: current, archived, trashed, or draft |
|
||||
| `bodyFormat` | string | No | Format for blog post body: storage, atlas_doc_format, or view |
|
||||
| `cursor` | string | No | Pagination cursor from previous response |
|
||||
| `cloudId` | string | No | Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain. |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `ts` | string | ISO 8601 timestamp of the operation |
|
||||
| `blogPosts` | array | Array of blog posts in the space |
|
||||
| ↳ `id` | string | Blog post ID |
|
||||
| ↳ `title` | string | Blog post title |
|
||||
| ↳ `status` | string | Blog post status |
|
||||
| ↳ `spaceId` | string | Space ID |
|
||||
| ↳ `authorId` | string | Author account ID |
|
||||
| ↳ `createdAt` | string | Creation timestamp |
|
||||
| ↳ `version` | object | Version information |
|
||||
| ↳ `number` | number | Version number |
|
||||
| ↳ `message` | string | Version message |
|
||||
| ↳ `minorEdit` | boolean | Whether this is a minor edit |
|
||||
| ↳ `authorId` | string | Account ID of the version author |
|
||||
| ↳ `createdAt` | string | ISO 8601 timestamp of version creation |
|
||||
| ↳ `body` | object | Blog post body content |
|
||||
| ↳ `storage` | object | Body in storage format \(Confluence markup\) |
|
||||
| ↳ `value` | string | The content value in the specified format |
|
||||
| ↳ `representation` | string | Content representation type |
|
||||
| ↳ `view` | object | Body in view format \(rendered HTML\) |
|
||||
| ↳ `value` | string | The content value in the specified format |
|
||||
| ↳ `representation` | string | Content representation type |
|
||||
| ↳ `atlas_doc_format` | object | Body in Atlassian Document Format \(ADF\) |
|
||||
| ↳ `value` | string | The content value in the specified format |
|
||||
| ↳ `representation` | string | Content representation type |
|
||||
| ↳ `webUrl` | string | URL to view the blog post |
|
||||
| `nextCursor` | string | Cursor for fetching the next page of results |
|
||||
|
||||
### `confluence_create_comment`
|
||||
|
||||
Add a comment to a Confluence page.
|
||||
@@ -187,6 +669,8 @@ List all comments on a Confluence page.
|
||||
| `domain` | string | Yes | Your Confluence domain \(e.g., yourcompany.atlassian.net\) |
|
||||
| `pageId` | string | Yes | Confluence page ID to list comments from |
|
||||
| `limit` | number | No | Maximum number of comments to return \(default: 25\) |
|
||||
| `bodyFormat` | string | No | Format for the comment body: storage, atlas_doc_format, view, or export_view \(default: storage\) |
|
||||
| `cursor` | string | No | Pagination cursor from previous response |
|
||||
| `cloudId` | string | No | Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain. |
|
||||
|
||||
#### Output
|
||||
@@ -212,6 +696,7 @@ List all comments on a Confluence page.
|
||||
| ↳ `minorEdit` | boolean | Whether this is a minor edit |
|
||||
| ↳ `authorId` | string | Account ID of the version author |
|
||||
| ↳ `createdAt` | string | ISO 8601 timestamp of version creation |
|
||||
| `nextCursor` | string | Cursor for fetching the next page of results |
|
||||
|
||||
### `confluence_update_comment`
|
||||
|
||||
@@ -291,7 +776,8 @@ List all attachments on a Confluence page.
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `domain` | string | Yes | Your Confluence domain \(e.g., yourcompany.atlassian.net\) |
|
||||
| `pageId` | string | Yes | Confluence page ID to list attachments from |
|
||||
| `limit` | number | No | Maximum number of attachments to return \(default: 25\) |
|
||||
| `limit` | number | No | Maximum number of attachments to return \(default: 50, max: 250\) |
|
||||
| `cursor` | string | No | Pagination cursor from previous response |
|
||||
| `cloudId` | string | No | Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain. |
|
||||
|
||||
#### Output
|
||||
@@ -316,6 +802,7 @@ List all attachments on a Confluence page.
|
||||
| ↳ `minorEdit` | boolean | Whether this is a minor edit |
|
||||
| ↳ `authorId` | string | Account ID of the version author |
|
||||
| ↳ `createdAt` | string | ISO 8601 timestamp of version creation |
|
||||
| `nextCursor` | string | Cursor for fetching the next page of results |
|
||||
|
||||
### `confluence_delete_attachment`
|
||||
|
||||
@@ -347,6 +834,8 @@ List all labels on a Confluence page.
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `domain` | string | Yes | Your Confluence domain \(e.g., yourcompany.atlassian.net\) |
|
||||
| `pageId` | string | Yes | Confluence page ID to list labels from |
|
||||
| `limit` | number | No | Maximum number of labels to return \(default: 25, max: 250\) |
|
||||
| `cursor` | string | No | Pagination cursor from previous response |
|
||||
| `cloudId` | string | No | Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain. |
|
||||
|
||||
#### Output
|
||||
@@ -358,6 +847,30 @@ List all labels on a Confluence page.
|
||||
| ↳ `id` | string | Unique label identifier |
|
||||
| ↳ `name` | string | Label name |
|
||||
| ↳ `prefix` | string | Label prefix/type \(e.g., global, my, team\) |
|
||||
| `nextCursor` | string | Cursor for fetching the next page of results |
|
||||
|
||||
### `confluence_add_label`
|
||||
|
||||
Add a label to a Confluence page for organization and categorization.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `domain` | string | Yes | Your Confluence domain \(e.g., yourcompany.atlassian.net\) |
|
||||
| `pageId` | string | Yes | Confluence page ID to add the label to |
|
||||
| `labelName` | string | Yes | Name of the label to add |
|
||||
| `prefix` | string | No | Label prefix: global \(default\), my, team, or system |
|
||||
| `cloudId` | string | No | Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain. |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `ts` | string | ISO 8601 timestamp of the operation |
|
||||
| `pageId` | string | Page ID that the label was added to |
|
||||
| `labelName` | string | Name of the added label |
|
||||
| `labelId` | string | ID of the added label |
|
||||
|
||||
### `confluence_get_space`
|
||||
|
||||
@@ -375,13 +888,19 @@ Get details about a specific Confluence space.
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `ts` | string | Timestamp of retrieval |
|
||||
| `ts` | string | ISO 8601 timestamp of the operation |
|
||||
| `spaceId` | string | Space ID |
|
||||
| `name` | string | Space name |
|
||||
| `key` | string | Space key |
|
||||
| `type` | string | Space type |
|
||||
| `status` | string | Space status |
|
||||
| `url` | string | Space URL |
|
||||
| `type` | string | Space type \(global, personal\) |
|
||||
| `status` | string | Space status \(current, archived\) |
|
||||
| `url` | string | URL to view the space in Confluence |
|
||||
| `authorId` | string | Account ID of the space creator |
|
||||
| `createdAt` | string | ISO 8601 timestamp when the space was created |
|
||||
| `homepageId` | string | ID of the space homepage |
|
||||
| `description` | object | Space description content |
|
||||
| ↳ `value` | string | Description text content |
|
||||
| ↳ `representation` | string | Content representation format \(e.g., plain, view, storage\) |
|
||||
|
||||
### `confluence_list_spaces`
|
||||
|
||||
@@ -392,7 +911,8 @@ List all Confluence spaces accessible to the user.
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `domain` | string | Yes | Your Confluence domain \(e.g., yourcompany.atlassian.net\) |
|
||||
| `limit` | number | No | Maximum number of spaces to return \(default: 25\) |
|
||||
| `limit` | number | No | Maximum number of spaces to return \(default: 25, max: 250\) |
|
||||
| `cursor` | string | No | Pagination cursor from previous response |
|
||||
| `cloudId` | string | No | Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain. |
|
||||
|
||||
#### Output
|
||||
@@ -412,5 +932,6 @@ List all Confluence spaces accessible to the user.
|
||||
| ↳ `description` | object | Space description |
|
||||
| ↳ `value` | string | Description text content |
|
||||
| ↳ `representation` | string | Content representation format \(e.g., plain, view, storage\) |
|
||||
| `nextCursor` | string | Cursor for fetching the next page of results |
|
||||
|
||||
|
||||
|
||||
@@ -63,6 +63,7 @@ Send a message to a Discord channel
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `message` | string | Success or error message |
|
||||
| `files` | file[] | Files attached to the message |
|
||||
| `data` | object | Discord message data |
|
||||
| ↳ `id` | string | Message ID |
|
||||
| ↳ `content` | string | Message content |
|
||||
|
||||
@@ -43,7 +43,8 @@ Upload a file to Dropbox
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `path` | string | Yes | The path in Dropbox where the file should be saved \(e.g., /folder/document.pdf\) |
|
||||
| `fileContent` | string | Yes | The base64 encoded content of the file to upload |
|
||||
| `file` | file | No | The file to upload \(UserFile object\) |
|
||||
| `fileContent` | string | No | Legacy: base64 encoded file content |
|
||||
| `fileName` | string | No | Optional filename \(used if path is a folder\) |
|
||||
| `mode` | string | No | Write mode: add \(default\) or overwrite |
|
||||
| `autorename` | boolean | No | If true, rename the file if there is a conflict |
|
||||
@@ -66,7 +67,7 @@ Upload a file to Dropbox
|
||||
|
||||
### `dropbox_download`
|
||||
|
||||
Download a file from Dropbox and get a temporary link
|
||||
Download a file from Dropbox with metadata and content
|
||||
|
||||
#### Input
|
||||
|
||||
@@ -78,11 +79,8 @@ Download a file from Dropbox and get a temporary link
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `file` | object | The file metadata |
|
||||
| ↳ `id` | string | Unique identifier for the file |
|
||||
| ↳ `name` | string | Name of the file |
|
||||
| ↳ `path_display` | string | Display path of the file |
|
||||
| ↳ `size` | number | Size of the file in bytes |
|
||||
| `file` | file | Downloaded file stored in execution files |
|
||||
| `metadata` | json | The file metadata |
|
||||
| `temporaryLink` | string | Temporary link to download the file \(valid for ~4 hours\) |
|
||||
| `content` | string | Base64 encoded file content \(if fetched\) |
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ description: Read and parse multiple files
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="file_v2"
|
||||
type="file_v3"
|
||||
color="#40916C"
|
||||
/>
|
||||
|
||||
@@ -27,7 +27,7 @@ The File Parser tool is particularly useful for scenarios where your agents need
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Integrate File into the workflow. Can upload a file manually or insert a file url.
|
||||
Upload files directly or import from external URLs to get UserFile objects for use in other blocks.
|
||||
|
||||
|
||||
|
||||
@@ -41,14 +41,15 @@ Parse one or more uploaded files or files from URLs (text, PDF, CSV, images, etc
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `filePath` | string | Yes | Path to the file\(s\). Can be a single path, URL, or an array of paths. |
|
||||
| `filePath` | string | No | Path to the file\(s\). Can be a single path, URL, or an array of paths. |
|
||||
| `file` | file | No | Uploaded file\(s\) to parse |
|
||||
| `fileType` | string | No | Type of file to parse \(auto-detected if not specified\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `files` | array | Array of parsed files with content, metadata, and file properties |
|
||||
| `combinedContent` | string | All file contents merged into a single text string |
|
||||
| `files` | file[] | Parsed files as UserFile objects |
|
||||
| `combinedContent` | string | Combined content of all parsed files |
|
||||
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ description: Interact with Fireflies.ai meeting transcripts and recordings
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="fireflies"
|
||||
type="fireflies_v2"
|
||||
color="#100730"
|
||||
/>
|
||||
|
||||
|
||||
@@ -692,6 +692,7 @@ Get the content of a file from a GitHub repository. Supports files up to 1MB. Co
|
||||
| `download_url` | string | Direct download URL |
|
||||
| `git_url` | string | Git blob API URL |
|
||||
| `_links` | json | Related links |
|
||||
| `file` | file | Downloaded file stored in execution files |
|
||||
|
||||
### `github_create_file`
|
||||
|
||||
|
||||
@@ -291,11 +291,7 @@ Download a file from Google Drive with complete metadata (exports Google Workspa
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `file` | object | Downloaded file data |
|
||||
| ↳ `name` | string | File name |
|
||||
| ↳ `mimeType` | string | MIME type of the file |
|
||||
| ↳ `data` | string | File content as base64-encoded string |
|
||||
| ↳ `size` | number | File size in bytes |
|
||||
| `file` | file | Downloaded file stored in execution files |
|
||||
| `metadata` | object | Complete file metadata from Google Drive |
|
||||
| ↳ `id` | string | Google Drive file ID |
|
||||
| ↳ `kind` | string | Resource type identifier |
|
||||
|
||||
@@ -6,7 +6,7 @@ description: Read, write, and create presentations
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="google_slides"
|
||||
type="google_slides_v2"
|
||||
color="#E0E0E0"
|
||||
/>
|
||||
|
||||
|
||||
@@ -333,6 +333,28 @@ Get all attachments from a Jira issue
|
||||
| `issueKey` | string | Issue key |
|
||||
| `attachments` | array | Array of attachments with id, filename, size, mimeType, created, author |
|
||||
|
||||
### `jira_add_attachment`
|
||||
|
||||
Add attachments to a Jira issue
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `domain` | string | Yes | Your Jira domain \(e.g., yourcompany.atlassian.net\) |
|
||||
| `issueKey` | string | Yes | Jira issue key to add attachments to \(e.g., PROJ-123\) |
|
||||
| `files` | file[] | Yes | Files to attach to the Jira issue |
|
||||
| `cloudId` | string | No | Jira Cloud ID for the instance. If not provided, it will be fetched using the domain. |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `ts` | string | Timestamp of the operation |
|
||||
| `issueKey` | string | Issue key |
|
||||
| `attachmentIds` | json | IDs of uploaded attachments |
|
||||
| `files` | file[] | Uploaded attachment files |
|
||||
|
||||
### `jira_delete_attachment`
|
||||
|
||||
Delete an attachment from a Jira issue
|
||||
|
||||
@@ -1022,7 +1022,8 @@ Add an attachment to an issue in Linear
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `issueId` | string | Yes | Issue ID to attach to |
|
||||
| `url` | string | Yes | URL of the attachment |
|
||||
| `url` | string | No | URL of the attachment |
|
||||
| `file` | file | No | File to attach |
|
||||
| `title` | string | Yes | Attachment title |
|
||||
| `subtitle` | string | No | Attachment subtitle/description |
|
||||
|
||||
|
||||
@@ -81,6 +81,7 @@ Write or update content in a Microsoft Teams chat
|
||||
| `createdTime` | string | Timestamp when message was created |
|
||||
| `url` | string | Web URL to the message |
|
||||
| `updatedContent` | boolean | Whether content was successfully updated |
|
||||
| `files` | file[] | Files attached to the message |
|
||||
|
||||
### `microsoft_teams_read_channel`
|
||||
|
||||
@@ -132,6 +133,7 @@ Write or send a message to a Microsoft Teams channel
|
||||
| `createdTime` | string | Timestamp when message was created |
|
||||
| `url` | string | Web URL to the message |
|
||||
| `updatedContent` | boolean | Whether content was successfully updated |
|
||||
| `files` | file[] | Files attached to the message |
|
||||
|
||||
### `microsoft_teams_update_chat_message`
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ description: Extract text from PDF documents
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="mistral_parse_v2"
|
||||
type="mistral_parse_v3"
|
||||
color="#000000"
|
||||
/>
|
||||
|
||||
@@ -35,13 +35,12 @@ Integrate Mistral Parse into the workflow. Can extract text from uploaded PDF do
|
||||
|
||||
### `mistral_parser`
|
||||
|
||||
Parse PDF documents using Mistral OCR API
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `filePath` | string | Yes | URL to a PDF document to be processed |
|
||||
| `filePath` | string | No | URL to a PDF document to be processed |
|
||||
| `file` | file | No | Document file to be processed |
|
||||
| `fileUpload` | object | No | File upload data from file-upload component |
|
||||
| `resultType` | string | No | Type of parsed result \(markdown, text, or json\). Defaults to markdown. |
|
||||
| `includeImageBase64` | boolean | No | Include base64-encoded images in the response |
|
||||
@@ -55,27 +54,8 @@ Parse PDF documents using Mistral OCR API
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `pages` | array | Array of page objects from Mistral OCR |
|
||||
| ↳ `index` | number | Page index \(zero-based\) |
|
||||
| ↳ `markdown` | string | Extracted markdown content |
|
||||
| ↳ `images` | array | Images extracted from this page with bounding boxes |
|
||||
| ↳ `id` | string | Image identifier \(e.g., img-0.jpeg\) |
|
||||
| ↳ `top_left_x` | number | Top-left X coordinate in pixels |
|
||||
| ↳ `top_left_y` | number | Top-left Y coordinate in pixels |
|
||||
| ↳ `bottom_right_x` | number | Bottom-right X coordinate in pixels |
|
||||
| ↳ `bottom_right_y` | number | Bottom-right Y coordinate in pixels |
|
||||
| ↳ `image_base64` | string | Base64-encoded image data \(when include_image_base64=true\) |
|
||||
| ↳ `dimensions` | object | Page dimensions |
|
||||
| ↳ `dpi` | number | Dots per inch |
|
||||
| ↳ `height` | number | Page height in pixels |
|
||||
| ↳ `width` | number | Page width in pixels |
|
||||
| ↳ `tables` | array | Extracted tables as HTML/markdown \(when table_format is set\). Referenced via placeholders like \[tbl-0.html\] |
|
||||
| ↳ `hyperlinks` | array | Array of URL strings detected in the page \(e.g., \["https://...", "mailto:..."\]\) |
|
||||
| ↳ `header` | string | Page header content \(when extract_header=true\) |
|
||||
| ↳ `footer` | string | Page footer content \(when extract_footer=true\) |
|
||||
| `model` | string | Mistral OCR model identifier \(e.g., mistral-ocr-latest\) |
|
||||
| `usage_info` | object | Usage and processing statistics |
|
||||
| ↳ `pages_processed` | number | Total number of pages processed |
|
||||
| ↳ `doc_size_bytes` | number | Document file size in bytes |
|
||||
| `document_annotation` | string | Structured annotation data as JSON string \(when applicable\) |
|
||||
| `model` | string | Mistral OCR model identifier |
|
||||
| `usage_info` | json | Usage statistics from the API |
|
||||
| `document_annotation` | string | Structured annotation data |
|
||||
|
||||
|
||||
|
||||
@@ -113,6 +113,26 @@ Create a new page in Notion
|
||||
| `last_edited_time` | string | ISO 8601 last edit timestamp |
|
||||
| `title` | string | Page title |
|
||||
|
||||
### `notion_update_page`
|
||||
|
||||
Update properties of a Notion page
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `pageId` | string | Yes | The UUID of the Notion page to update |
|
||||
| `properties` | json | Yes | JSON object of properties to update |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `id` | string | Page UUID |
|
||||
| `url` | string | Notion page URL |
|
||||
| `last_edited_time` | string | ISO 8601 last edit timestamp |
|
||||
| `title` | string | Page title |
|
||||
|
||||
### `notion_query_database`
|
||||
|
||||
Query and filter Notion database entries with advanced filtering
|
||||
|
||||
@@ -152,6 +152,7 @@ Retrieve files from Pipedrive with optional filters
|
||||
| `person_id` | string | No | Filter files by person ID \(e.g., "456"\) |
|
||||
| `org_id` | string | No | Filter files by organization ID \(e.g., "789"\) |
|
||||
| `limit` | string | No | Number of results to return \(e.g., "50", default: 100, max: 500\) |
|
||||
| `downloadFiles` | boolean | No | Download file contents into file outputs |
|
||||
|
||||
#### Output
|
||||
|
||||
@@ -168,6 +169,7 @@ Retrieve files from Pipedrive with optional filters
|
||||
| ↳ `person_id` | number | Associated person ID |
|
||||
| ↳ `org_id` | number | Associated organization ID |
|
||||
| ↳ `url` | string | File download URL |
|
||||
| `downloadedFiles` | file[] | Downloaded files from Pipedrive |
|
||||
| `total_items` | number | Total number of files returned |
|
||||
| `success` | boolean | Operation success status |
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ description: Extract text from documents using Pulse OCR
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="pulse"
|
||||
type="pulse_v2"
|
||||
color="#E0E0E0"
|
||||
/>
|
||||
|
||||
@@ -31,7 +31,7 @@ If you need accurate, scalable, and developer-friendly document parsing capabili
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Integrate Pulse into the workflow. Extract text from PDF documents, images, and Office files via URL or upload.
|
||||
Integrate Pulse into the workflow. Extract text from PDF documents, images, and Office files via upload or file references.
|
||||
|
||||
|
||||
|
||||
@@ -39,13 +39,12 @@ Integrate Pulse into the workflow. Extract text from PDF documents, images, and
|
||||
|
||||
### `pulse_parser`
|
||||
|
||||
Parse documents (PDF, images, Office docs) using Pulse OCR API
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `filePath` | string | Yes | URL to a document to be processed |
|
||||
| `filePath` | string | No | URL to a document to be processed |
|
||||
| `file` | file | No | Document file to be processed |
|
||||
| `fileUpload` | object | No | File upload data from file-upload component |
|
||||
| `pages` | string | No | Page range to process \(1-indexed, e.g., "1-2,5"\) |
|
||||
| `extractFigure` | boolean | No | Enable figure extraction from the document |
|
||||
@@ -57,16 +56,6 @@ Parse documents (PDF, images, Office docs) using Pulse OCR API
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `markdown` | string | Extracted content in markdown format |
|
||||
| `page_count` | number | Number of pages in the document |
|
||||
| `job_id` | string | Unique job identifier |
|
||||
| `bounding_boxes` | json | Bounding box layout information |
|
||||
| `extraction_url` | string | URL for extraction results \(for large documents\) |
|
||||
| `html` | string | HTML content if requested |
|
||||
| `structured_output` | json | Structured output if schema was provided |
|
||||
| `chunks` | json | Chunked content if chunking was enabled |
|
||||
| `figures` | json | Extracted figures if figure extraction was enabled |
|
||||
This tool does not produce any outputs.
|
||||
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ description: Extract text from PDF documents
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="reducto"
|
||||
type="reducto_v2"
|
||||
color="#5c0c5c"
|
||||
/>
|
||||
|
||||
@@ -29,7 +29,7 @@ Looking for reliable and scalable PDF parsing? Reducto is optimized for develope
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Integrate Reducto Parse into the workflow. Can extract text from uploaded PDF documents, or from a URL.
|
||||
Integrate Reducto Parse into the workflow. Can extract text from uploaded PDF documents or file references.
|
||||
|
||||
|
||||
|
||||
@@ -37,13 +37,12 @@ Integrate Reducto Parse into the workflow. Can extract text from uploaded PDF do
|
||||
|
||||
### `reducto_parser`
|
||||
|
||||
Parse PDF documents using Reducto OCR API
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `filePath` | string | Yes | URL to a PDF document to be processed |
|
||||
| `filePath` | string | No | URL to a PDF document to be processed |
|
||||
| `file` | file | No | Document file to be processed |
|
||||
| `fileUpload` | object | No | File upload data from file-upload component |
|
||||
| `pages` | array | No | Specific pages to process \(1-indexed page numbers\) |
|
||||
| `tableOutputFormat` | string | No | Table output format \(html or markdown\). Defaults to markdown. |
|
||||
@@ -51,13 +50,6 @@ Parse PDF documents using Reducto OCR API
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `job_id` | string | Unique identifier for the processing job |
|
||||
| `duration` | number | Processing time in seconds |
|
||||
| `usage` | json | Resource consumption data |
|
||||
| `result` | json | Parsed document content with chunks and blocks |
|
||||
| `pdf_url` | string | Storage URL of converted PDF |
|
||||
| `studio_link` | string | Link to Reducto studio interface |
|
||||
This tool does not produce any outputs.
|
||||
|
||||
|
||||
|
||||
@@ -78,6 +78,7 @@ Retrieve an object from an AWS S3 bucket
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `url` | string | Pre-signed URL for downloading the S3 object |
|
||||
| `file` | file | Downloaded file stored in execution files |
|
||||
| `metadata` | object | File metadata including type, size, name, and last modified date |
|
||||
|
||||
### `s3_list_objects`
|
||||
|
||||
@@ -62,7 +62,7 @@ Send an email using SendGrid API
|
||||
| `bcc` | string | No | BCC email address |
|
||||
| `replyTo` | string | No | Reply-to email address |
|
||||
| `replyToName` | string | No | Reply-to name |
|
||||
| `attachments` | file[] | No | Files to attach to the email as an array of attachment objects |
|
||||
| `attachments` | file[] | No | Files to attach to the email \(UserFile objects\) |
|
||||
| `templateId` | string | No | SendGrid template ID to use |
|
||||
| `dynamicTemplateData` | json | No | JSON object of dynamic template data |
|
||||
|
||||
|
||||
@@ -97,6 +97,7 @@ Download a file from a remote SFTP server
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `success` | boolean | Whether the download was successful |
|
||||
| `file` | file | Downloaded file stored in execution files |
|
||||
| `fileName` | string | Name of the downloaded file |
|
||||
| `content` | string | File content \(text or base64 encoded\) |
|
||||
| `size` | number | File size in bytes |
|
||||
|
||||
@@ -144,6 +144,7 @@ Send messages to Slack channels or direct messages. Supports Slack mrkdwn format
|
||||
| `ts` | string | Message timestamp |
|
||||
| `channel` | string | Channel ID where message was sent |
|
||||
| `fileCount` | number | Number of files uploaded \(when files are attached\) |
|
||||
| `files` | file[] | Files attached to the message |
|
||||
|
||||
### `slack_canvas`
|
||||
|
||||
|
||||
@@ -170,6 +170,7 @@ Download a file from a remote SSH server
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `downloaded` | boolean | Whether the file was downloaded successfully |
|
||||
| `file` | file | Downloaded file stored in execution files |
|
||||
| `fileContent` | string | File content \(base64 encoded for binary files\) |
|
||||
| `fileName` | string | Name of the downloaded file |
|
||||
| `remotePath` | string | Source path on the remote server |
|
||||
|
||||
@@ -6,7 +6,7 @@ description: Convert speech to text using AI
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="stt"
|
||||
type="stt_v2"
|
||||
color="#181C1E"
|
||||
/>
|
||||
|
||||
@@ -50,8 +50,6 @@ Transcribe audio and video files to text using leading AI providers. Supports mu
|
||||
|
||||
### `stt_whisper`
|
||||
|
||||
Transcribe audio to text using OpenAI Whisper
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
@@ -71,22 +69,10 @@ Transcribe audio to text using OpenAI Whisper
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `transcript` | string | Full transcribed text |
|
||||
| `segments` | array | Timestamped segments |
|
||||
| ↳ `text` | string | Transcribed text for this segment |
|
||||
| ↳ `start` | number | Start time in seconds |
|
||||
| ↳ `end` | number | End time in seconds |
|
||||
| ↳ `speaker` | string | Speaker identifier \(if diarization enabled\) |
|
||||
| ↳ `confidence` | number | Confidence score \(0-1\) |
|
||||
| `language` | string | Detected or specified language |
|
||||
| `duration` | number | Audio duration in seconds |
|
||||
This tool does not produce any outputs.
|
||||
|
||||
### `stt_deepgram`
|
||||
|
||||
Transcribe audio to text using Deepgram
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
@@ -103,23 +89,10 @@ Transcribe audio to text using Deepgram
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `transcript` | string | Full transcribed text |
|
||||
| `segments` | array | Timestamped segments with speaker labels |
|
||||
| ↳ `text` | string | Transcribed text for this segment |
|
||||
| ↳ `start` | number | Start time in seconds |
|
||||
| ↳ `end` | number | End time in seconds |
|
||||
| ↳ `speaker` | string | Speaker identifier \(if diarization enabled\) |
|
||||
| ↳ `confidence` | number | Confidence score \(0-1\) |
|
||||
| `language` | string | Detected or specified language |
|
||||
| `duration` | number | Audio duration in seconds |
|
||||
| `confidence` | number | Overall confidence score |
|
||||
This tool does not produce any outputs.
|
||||
|
||||
### `stt_elevenlabs`
|
||||
|
||||
Transcribe audio to text using ElevenLabs
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
@@ -135,18 +108,10 @@ Transcribe audio to text using ElevenLabs
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `transcript` | string | Full transcribed text |
|
||||
| `segments` | array | Timestamped segments |
|
||||
| `language` | string | Detected or specified language |
|
||||
| `duration` | number | Audio duration in seconds |
|
||||
| `confidence` | number | Overall confidence score |
|
||||
This tool does not produce any outputs.
|
||||
|
||||
### `stt_assemblyai`
|
||||
|
||||
Transcribe audio to text using AssemblyAI with advanced NLP features
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
@@ -167,35 +132,10 @@ Transcribe audio to text using AssemblyAI with advanced NLP features
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `transcript` | string | Full transcribed text |
|
||||
| `segments` | array | Timestamped segments with speaker labels |
|
||||
| ↳ `text` | string | Transcribed text for this segment |
|
||||
| ↳ `start` | number | Start time in seconds |
|
||||
| ↳ `end` | number | End time in seconds |
|
||||
| ↳ `speaker` | string | Speaker identifier \(if diarization enabled\) |
|
||||
| ↳ `confidence` | number | Confidence score \(0-1\) |
|
||||
| `language` | string | Detected or specified language |
|
||||
| `duration` | number | Audio duration in seconds |
|
||||
| `confidence` | number | Overall confidence score |
|
||||
| `sentiment` | array | Sentiment analysis results |
|
||||
| ↳ `text` | string | Text that was analyzed |
|
||||
| ↳ `sentiment` | string | Sentiment \(POSITIVE, NEGATIVE, NEUTRAL\) |
|
||||
| ↳ `confidence` | number | Confidence score |
|
||||
| ↳ `start` | number | Start time in milliseconds |
|
||||
| ↳ `end` | number | End time in milliseconds |
|
||||
| `entities` | array | Detected entities |
|
||||
| ↳ `entity_type` | string | Entity type \(e.g., person_name, location, organization\) |
|
||||
| ↳ `text` | string | Entity text |
|
||||
| ↳ `start` | number | Start time in milliseconds |
|
||||
| ↳ `end` | number | End time in milliseconds |
|
||||
| `summary` | string | Auto-generated summary |
|
||||
This tool does not produce any outputs.
|
||||
|
||||
### `stt_gemini`
|
||||
|
||||
Transcribe audio to text using Google Gemini with multimodal capabilities
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
@@ -211,12 +151,6 @@ Transcribe audio to text using Google Gemini with multimodal capabilities
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `transcript` | string | Full transcribed text |
|
||||
| `segments` | array | Timestamped segments |
|
||||
| `language` | string | Detected or specified language |
|
||||
| `duration` | number | Audio duration in seconds |
|
||||
| `confidence` | number | Overall confidence score |
|
||||
This tool does not produce any outputs.
|
||||
|
||||
|
||||
|
||||
@@ -354,6 +354,7 @@ Send documents (PDF, ZIP, DOC, etc.) to Telegram channels or users through the T
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `message` | string | Success or error message |
|
||||
| `files` | file[] | Files attached to the message |
|
||||
| `data` | object | Telegram message data including document |
|
||||
| ↳ `message_id` | number | Unique Telegram message identifier |
|
||||
| ↳ `from` | object | Information about the sender |
|
||||
|
||||
@@ -6,7 +6,7 @@ description: Extract text, tables, and forms from documents
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="textract"
|
||||
type="textract_v2"
|
||||
color="linear-gradient(135deg, #055F4E 0%, #56C0A7 100%)"
|
||||
/>
|
||||
|
||||
@@ -35,8 +35,6 @@ Integrate AWS Textract into your workflow to extract text, tables, forms, and ke
|
||||
|
||||
### `textract_parser`
|
||||
|
||||
Parse documents using AWS Textract OCR and document analysis
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
@@ -46,8 +44,8 @@ Parse documents using AWS Textract OCR and document analysis
|
||||
| `region` | string | Yes | AWS region for Textract service \(e.g., us-east-1\) |
|
||||
| `processingMode` | string | No | Document type: single-page or multi-page. Defaults to single-page. |
|
||||
| `filePath` | string | No | URL to a document to be processed \(JPEG, PNG, or single-page PDF\). |
|
||||
| `file` | file | No | Document file to be processed \(JPEG, PNG, or single-page PDF\). |
|
||||
| `s3Uri` | string | No | S3 URI for multi-page processing \(s3://bucket/key\). |
|
||||
| `fileUpload` | object | No | File upload data from file-upload component |
|
||||
| `featureTypes` | array | No | Feature types to detect: TABLES, FORMS, QUERIES, SIGNATURES, LAYOUT. If not specified, only text detection is performed. |
|
||||
| `items` | string | No | Feature type |
|
||||
| `queries` | array | No | Custom queries to extract specific information. Only used when featureTypes includes QUERIES. |
|
||||
@@ -58,39 +56,6 @@ Parse documents using AWS Textract OCR and document analysis
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `blocks` | array | Array of Block objects containing detected text, tables, forms, and other elements |
|
||||
| ↳ `BlockType` | string | Type of block \(PAGE, LINE, WORD, TABLE, CELL, KEY_VALUE_SET, etc.\) |
|
||||
| ↳ `Id` | string | Unique identifier for the block |
|
||||
| ↳ `Text` | string | The text content \(for LINE and WORD blocks\) |
|
||||
| ↳ `TextType` | string | Type of text \(PRINTED or HANDWRITING\) |
|
||||
| ↳ `Confidence` | number | Confidence score \(0-100\) |
|
||||
| ↳ `Page` | number | Page number |
|
||||
| ↳ `Geometry` | object | Location and bounding box information |
|
||||
| ↳ `BoundingBox` | object | Height as ratio of document height |
|
||||
| ↳ `Height` | number | Height as ratio of document height |
|
||||
| ↳ `Left` | number | Left position as ratio of document width |
|
||||
| ↳ `Top` | number | Top position as ratio of document height |
|
||||
| ↳ `Width` | number | Width as ratio of document width |
|
||||
| ↳ `Polygon` | array | Polygon coordinates |
|
||||
| ↳ `X` | number | X coordinate |
|
||||
| ↳ `Y` | number | Y coordinate |
|
||||
| ↳ `Relationships` | array | Relationships to other blocks |
|
||||
| ↳ `Type` | string | Relationship type \(CHILD, VALUE, ANSWER, etc.\) |
|
||||
| ↳ `Ids` | array | IDs of related blocks |
|
||||
| ↳ `EntityTypes` | array | Entity types for KEY_VALUE_SET \(KEY or VALUE\) |
|
||||
| ↳ `SelectionStatus` | string | For checkboxes: SELECTED or NOT_SELECTED |
|
||||
| ↳ `RowIndex` | number | Row index for table cells |
|
||||
| ↳ `ColumnIndex` | number | Column index for table cells |
|
||||
| ↳ `RowSpan` | number | Row span for merged cells |
|
||||
| ↳ `ColumnSpan` | number | Column span for merged cells |
|
||||
| ↳ `Query` | object | Query information for QUERY blocks |
|
||||
| ↳ `Text` | string | Query text |
|
||||
| ↳ `Alias` | string | Query alias |
|
||||
| ↳ `Pages` | array | Pages to search |
|
||||
| `documentMetadata` | object | Metadata about the analyzed document |
|
||||
| ↳ `pages` | number | Number of pages in the document |
|
||||
| `modelVersion` | string | Version of the Textract model used for processing |
|
||||
This tool does not produce any outputs.
|
||||
|
||||
|
||||
|
||||
@@ -122,6 +122,7 @@ Retrieve call recording information and transcription (if enabled via TwiML).
|
||||
| `channels` | number | Number of channels \(1 for mono, 2 for dual\) |
|
||||
| `source` | string | How the recording was created |
|
||||
| `mediaUrl` | string | URL to download the recording media file |
|
||||
| `file` | file | Downloaded recording media file |
|
||||
| `price` | string | Cost of the recording |
|
||||
| `priceUnit` | string | Currency of the price |
|
||||
| `uri` | string | Relative URI of the recording resource |
|
||||
|
||||
@@ -75,6 +75,7 @@ Download files uploaded in Typeform responses
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `fileUrl` | string | Direct download URL for the uploaded file |
|
||||
| `file` | file | Downloaded file stored in execution files |
|
||||
| `contentType` | string | MIME type of the uploaded file |
|
||||
| `filename` | string | Original filename of the uploaded file |
|
||||
|
||||
|
||||
@@ -57,14 +57,14 @@ Generate videos using Runway Gen-4 with world consistency and visual references
|
||||
| `duration` | number | No | Video duration in seconds \(5 or 10, default: 5\) |
|
||||
| `aspectRatio` | string | No | Aspect ratio: 16:9 \(landscape\), 9:16 \(portrait\), or 1:1 \(square\) |
|
||||
| `resolution` | string | No | Video resolution \(720p output\). Note: Gen-4 Turbo outputs at 720p natively |
|
||||
| `visualReference` | json | Yes | Reference image REQUIRED for Gen-4 \(UserFile object\). Gen-4 only supports image-to-video, not text-only generation |
|
||||
| `visualReference` | file | Yes | Reference image REQUIRED for Gen-4 \(UserFile object\). Gen-4 only supports image-to-video, not text-only generation |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `videoUrl` | string | Generated video URL |
|
||||
| `videoFile` | json | Video file object with metadata |
|
||||
| `videoFile` | file | Video file object with metadata |
|
||||
| `duration` | number | Video duration in seconds |
|
||||
| `width` | number | Video width in pixels |
|
||||
| `height` | number | Video height in pixels |
|
||||
@@ -93,7 +93,7 @@ Generate videos using Google Veo 3/3.1 with native audio generation
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `videoUrl` | string | Generated video URL |
|
||||
| `videoFile` | json | Video file object with metadata |
|
||||
| `videoFile` | file | Video file object with metadata |
|
||||
| `duration` | number | Video duration in seconds |
|
||||
| `width` | number | Video width in pixels |
|
||||
| `height` | number | Video height in pixels |
|
||||
@@ -123,7 +123,7 @@ Generate videos using Luma Dream Machine with advanced camera controls
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `videoUrl` | string | Generated video URL |
|
||||
| `videoFile` | json | Video file object with metadata |
|
||||
| `videoFile` | file | Video file object with metadata |
|
||||
| `duration` | number | Video duration in seconds |
|
||||
| `width` | number | Video width in pixels |
|
||||
| `height` | number | Video height in pixels |
|
||||
@@ -151,7 +151,7 @@ Generate videos using MiniMax Hailuo through MiniMax Platform API with advanced
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `videoUrl` | string | Generated video URL |
|
||||
| `videoFile` | json | Video file object with metadata |
|
||||
| `videoFile` | file | Video file object with metadata |
|
||||
| `duration` | number | Video duration in seconds |
|
||||
| `width` | number | Video width in pixels |
|
||||
| `height` | number | Video height in pixels |
|
||||
@@ -181,7 +181,7 @@ Generate videos using Fal.ai platform with access to multiple models including V
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `videoUrl` | string | Generated video URL |
|
||||
| `videoFile` | json | Video file object with metadata |
|
||||
| `videoFile` | file | Video file object with metadata |
|
||||
| `duration` | number | Video duration in seconds |
|
||||
| `width` | number | Video width in pixels |
|
||||
| `height` | number | Video height in pixels |
|
||||
|
||||
@@ -6,7 +6,7 @@ description: Analyze images with vision models
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="vision"
|
||||
type="vision_v2"
|
||||
color="#4D5FFF"
|
||||
/>
|
||||
|
||||
@@ -35,8 +35,6 @@ Integrate Vision into the workflow. Can analyze images with vision models.
|
||||
|
||||
### `vision_tool`
|
||||
|
||||
Process and analyze images using advanced vision models. Capable of understanding image content, extracting text, identifying objects, and providing detailed visual descriptions.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
@@ -49,14 +47,6 @@ Process and analyze images using advanced vision models. Capable of understandin
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `content` | string | The analyzed content and description of the image |
|
||||
| `model` | string | The vision model that was used for analysis |
|
||||
| `tokens` | number | Total tokens used for the analysis |
|
||||
| `usage` | object | Detailed token usage breakdown |
|
||||
| ↳ `input_tokens` | number | Tokens used for input processing |
|
||||
| ↳ `output_tokens` | number | Tokens used for response generation |
|
||||
| ↳ `total_tokens` | number | Total tokens consumed |
|
||||
This tool does not produce any outputs.
|
||||
|
||||
|
||||
|
||||
@@ -335,6 +335,7 @@ Get all recordings for a specific Zoom meeting
|
||||
| `meetingId` | string | Yes | The meeting ID or meeting UUID \(e.g., "1234567890" or "4444AAABBBccccc12345=="\) |
|
||||
| `includeFolderItems` | boolean | No | Include items within a folder |
|
||||
| `ttl` | number | No | Time to live for download URLs in seconds \(max 604800\) |
|
||||
| `downloadFiles` | boolean | No | Download recording files into file outputs |
|
||||
|
||||
#### Output
|
||||
|
||||
@@ -364,6 +365,7 @@ Get all recordings for a specific Zoom meeting
|
||||
| ↳ `download_url` | string | URL to download the recording |
|
||||
| ↳ `status` | string | Recording status |
|
||||
| ↳ `recording_type` | string | Type of recording \(shared_screen, audio_only, etc.\) |
|
||||
| `files` | file[] | Downloaded recording files |
|
||||
|
||||
### `zoom_delete_recording`
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ description: Leer y analizar múltiples archivos
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="file"
|
||||
type="file_v3"
|
||||
color="#40916C"
|
||||
/>
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ description: Interactúa con transcripciones y grabaciones de reuniones de Firef
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="fireflies"
|
||||
type="fireflies_v2"
|
||||
color="#100730"
|
||||
/>
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ description: Extraer texto de documentos PDF
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="mistral_parse"
|
||||
type="mistral_parse_v3"
|
||||
color="#000000"
|
||||
/>
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ description: Lire et analyser plusieurs fichiers
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="file"
|
||||
type="file_v3"
|
||||
color="#40916C"
|
||||
/>
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ description: Interagissez avec les transcriptions et enregistrements de réunion
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="fireflies"
|
||||
type="fireflies_v2"
|
||||
color="#100730"
|
||||
/>
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ description: Extraire du texte à partir de documents PDF
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="mistral_parse"
|
||||
type="mistral_parse_v3"
|
||||
color="#000000"
|
||||
/>
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ description: 複数のファイルを読み込んで解析する
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="file"
|
||||
type="file_v3"
|
||||
color="#40916C"
|
||||
/>
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ description: Fireflies.aiの会議文字起こしと録画を操作
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="fireflies"
|
||||
type="fireflies_v2"
|
||||
color="#100730"
|
||||
/>
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ description: PDFドキュメントからテキストを抽出する
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="mistral_parse"
|
||||
type="mistral_parse_v3"
|
||||
color="#000000"
|
||||
/>
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ description: 读取并解析多个文件
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="file"
|
||||
type="file_v3"
|
||||
color="#40916C"
|
||||
/>
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ description: 与 Fireflies.ai 会议转录和录音进行交互
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="fireflies"
|
||||
type="fireflies_v2"
|
||||
color="#100730"
|
||||
/>
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ description: 从 PDF 文档中提取文本
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="mistral_parse"
|
||||
type="mistral_parse_v3"
|
||||
color="#000000"
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useBrandConfig } from '@/lib/branding/branding'
|
||||
import { inter } from '@/app/_styles/fonts/inter/inter'
|
||||
import { useBrandConfig } from '@/ee/whitelabeling'
|
||||
|
||||
export interface SupportFooterProps {
|
||||
/** Position style - 'fixed' for pages without AuthLayout, 'absolute' for pages with AuthLayout */
|
||||
|
||||
@@ -7,10 +7,10 @@ import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { GithubIcon } from '@/components/icons'
|
||||
import { useBrandConfig } from '@/lib/branding/branding'
|
||||
import { isHosted } from '@/lib/core/config/feature-flags'
|
||||
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
|
||||
import { getFormattedGitHubStars } from '@/app/(landing)/actions/github'
|
||||
import { useBrandConfig } from '@/ee/whitelabeling'
|
||||
import { useBrandedButtonClass } from '@/hooks/use-branded-button-class'
|
||||
|
||||
const logger = createLogger('nav')
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
parseWorkflowSSEChunk,
|
||||
} from '@/lib/a2a/utils'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { getBrandConfig } from '@/lib/branding/branding'
|
||||
import { acquireLock, getRedisClient, releaseLock } from '@/lib/core/config/redis'
|
||||
import { validateUrlWithDNS } from '@/lib/core/security/input-validation.server'
|
||||
import { SSE_HEADERS } from '@/lib/core/utils/sse'
|
||||
@@ -35,6 +34,7 @@ import {
|
||||
type PushNotificationSetParams,
|
||||
type TaskIdParams,
|
||||
} from '@/app/api/a2a/serve/[agentId]/utils'
|
||||
import { getBrandConfig } from '@/ee/whitelabeling'
|
||||
|
||||
const logger = createLogger('A2AServeAPI')
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ const configSchema = z.object({
|
||||
hideFilesTab: z.boolean().optional(),
|
||||
disableMcpTools: z.boolean().optional(),
|
||||
disableCustomTools: z.boolean().optional(),
|
||||
disableSkills: z.boolean().optional(),
|
||||
hideTemplates: z.boolean().optional(),
|
||||
disableInvitations: z.boolean().optional(),
|
||||
hideDeployApi: z.boolean().optional(),
|
||||
|
||||
@@ -25,6 +25,7 @@ const configSchema = z.object({
|
||||
hideFilesTab: z.boolean().optional(),
|
||||
disableMcpTools: z.boolean().optional(),
|
||||
disableCustomTools: z.boolean().optional(),
|
||||
disableSkills: z.boolean().optional(),
|
||||
hideTemplates: z.boolean().optional(),
|
||||
disableInvitations: z.boolean().optional(),
|
||||
hideDeployApi: z.boolean().optional(),
|
||||
|
||||
182
apps/sim/app/api/skills/route.ts
Normal file
182
apps/sim/app/api/skills/route.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import { db } from '@sim/db'
|
||||
import { skill } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, desc, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { upsertSkills } from '@/lib/workflows/skills/operations'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
const logger = createLogger('SkillsAPI')
|
||||
|
||||
const SkillSchema = z.object({
|
||||
skills: z.array(
|
||||
z.object({
|
||||
id: z.string().optional(),
|
||||
name: z
|
||||
.string()
|
||||
.min(1, 'Skill name is required')
|
||||
.max(64)
|
||||
.regex(/^[a-z0-9]+(-[a-z0-9]+)*$/, 'Name must be kebab-case (e.g. my-skill)'),
|
||||
description: z.string().min(1, 'Description is required').max(1024),
|
||||
content: z.string().min(1, 'Content is required').max(50000, 'Content is too large'),
|
||||
})
|
||||
),
|
||||
workspaceId: z.string().optional(),
|
||||
})
|
||||
|
||||
/** GET - Fetch all skills for a workspace */
|
||||
export async function GET(request: NextRequest) {
|
||||
const requestId = generateRequestId()
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const workspaceId = searchParams.get('workspaceId')
|
||||
|
||||
try {
|
||||
const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
|
||||
if (!authResult.success || !authResult.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized skills access attempt`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const userId = authResult.userId
|
||||
|
||||
if (!workspaceId) {
|
||||
logger.warn(`[${requestId}] Missing workspaceId`)
|
||||
return NextResponse.json({ error: 'workspaceId is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const userPermission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
|
||||
if (!userPermission) {
|
||||
logger.warn(`[${requestId}] User ${userId} does not have access to workspace ${workspaceId}`)
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
}
|
||||
|
||||
const result = await db
|
||||
.select()
|
||||
.from(skill)
|
||||
.where(eq(skill.workspaceId, workspaceId))
|
||||
.orderBy(desc(skill.createdAt))
|
||||
|
||||
return NextResponse.json({ data: result }, { status: 200 })
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error fetching skills:`, error)
|
||||
return NextResponse.json({ error: 'Failed to fetch skills' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/** POST - Create or update skills */
|
||||
export async function POST(req: NextRequest) {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const authResult = await checkSessionOrInternalAuth(req, { requireWorkflowId: false })
|
||||
if (!authResult.success || !authResult.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized skills update attempt`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const userId = authResult.userId
|
||||
const body = await req.json()
|
||||
|
||||
try {
|
||||
const { skills, workspaceId } = SkillSchema.parse(body)
|
||||
|
||||
if (!workspaceId) {
|
||||
logger.warn(`[${requestId}] Missing workspaceId in request body`)
|
||||
return NextResponse.json({ error: 'workspaceId is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const userPermission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
|
||||
if (!userPermission || (userPermission !== 'admin' && userPermission !== 'write')) {
|
||||
logger.warn(
|
||||
`[${requestId}] User ${userId} does not have write permission for workspace ${workspaceId}`
|
||||
)
|
||||
return NextResponse.json({ error: 'Write permission required' }, { status: 403 })
|
||||
}
|
||||
|
||||
const resultSkills = await upsertSkills({
|
||||
skills,
|
||||
workspaceId,
|
||||
userId,
|
||||
requestId,
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true, data: resultSkills })
|
||||
} catch (validationError) {
|
||||
if (validationError instanceof z.ZodError) {
|
||||
logger.warn(`[${requestId}] Invalid skills data`, {
|
||||
errors: validationError.errors,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid request data', details: validationError.errors },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
if (validationError instanceof Error && validationError.message.includes('already exists')) {
|
||||
return NextResponse.json({ error: validationError.message }, { status: 409 })
|
||||
}
|
||||
throw validationError
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error updating skills`, error)
|
||||
return NextResponse.json({ error: 'Failed to update skills' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/** DELETE - Delete a skill by ID */
|
||||
export async function DELETE(request: NextRequest) {
|
||||
const requestId = generateRequestId()
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const skillId = searchParams.get('id')
|
||||
const workspaceId = searchParams.get('workspaceId')
|
||||
|
||||
try {
|
||||
const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
|
||||
if (!authResult.success || !authResult.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized skill deletion attempt`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const userId = authResult.userId
|
||||
|
||||
if (!skillId) {
|
||||
logger.warn(`[${requestId}] Missing skill ID for deletion`)
|
||||
return NextResponse.json({ error: 'Skill ID is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!workspaceId) {
|
||||
logger.warn(`[${requestId}] Missing workspaceId for deletion`)
|
||||
return NextResponse.json({ error: 'workspaceId is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const userPermission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
|
||||
if (!userPermission || (userPermission !== 'admin' && userPermission !== 'write')) {
|
||||
logger.warn(
|
||||
`[${requestId}] User ${userId} does not have write permission for workspace ${workspaceId}`
|
||||
)
|
||||
return NextResponse.json({ error: 'Write permission required' }, { status: 403 })
|
||||
}
|
||||
|
||||
const existingSkill = await db.select().from(skill).where(eq(skill.id, skillId)).limit(1)
|
||||
|
||||
if (existingSkill.length === 0) {
|
||||
logger.warn(`[${requestId}] Skill not found: ${skillId}`)
|
||||
return NextResponse.json({ error: 'Skill not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (existingSkill[0].workspaceId !== workspaceId) {
|
||||
logger.warn(`[${requestId}] Skill ${skillId} does not belong to workspace ${workspaceId}`)
|
||||
return NextResponse.json({ error: 'Skill not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
await db.delete(skill).where(and(eq(skill.id, skillId), eq(skill.workspaceId, workspaceId)))
|
||||
|
||||
logger.info(`[${requestId}] Deleted skill: ${skillId}`)
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error deleting skill:`, error)
|
||||
return NextResponse.json({ error: 'Failed to delete skill' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,8 @@ export async function GET(request: NextRequest) {
|
||||
const accessToken = searchParams.get('accessToken')
|
||||
const pageId = searchParams.get('pageId')
|
||||
const providedCloudId = searchParams.get('cloudId')
|
||||
const limit = searchParams.get('limit') || '25'
|
||||
const limit = searchParams.get('limit') || '50'
|
||||
const cursor = searchParams.get('cursor')
|
||||
|
||||
if (!domain) {
|
||||
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
|
||||
@@ -47,7 +48,12 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/pages/${pageId}/attachments?limit=${limit}`
|
||||
const queryParams = new URLSearchParams()
|
||||
queryParams.append('limit', String(Math.min(Number(limit), 250)))
|
||||
if (cursor) {
|
||||
queryParams.append('cursor', cursor)
|
||||
}
|
||||
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/pages/${pageId}/attachments?${queryParams.toString()}`
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
@@ -77,9 +83,20 @@ export async function GET(request: NextRequest) {
|
||||
fileSize: attachment.fileSize || 0,
|
||||
mediaType: attachment.mediaType || '',
|
||||
downloadUrl: attachment.downloadLink || attachment._links?.download || '',
|
||||
status: attachment.status ?? null,
|
||||
webuiUrl: attachment._links?.webui ?? null,
|
||||
pageId: attachment.pageId ?? null,
|
||||
blogPostId: attachment.blogPostId ?? null,
|
||||
comment: attachment.comment ?? null,
|
||||
version: attachment.version ?? null,
|
||||
}))
|
||||
|
||||
return NextResponse.json({ attachments })
|
||||
return NextResponse.json({
|
||||
attachments,
|
||||
nextCursor: data._links?.next
|
||||
? new URL(data._links.next, 'https://placeholder').searchParams.get('cursor')
|
||||
: null,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error listing Confluence attachments:', error)
|
||||
return NextResponse.json(
|
||||
|
||||
285
apps/sim/app/api/tools/confluence/blogposts/route.ts
Normal file
285
apps/sim/app/api/tools/confluence/blogposts/route.ts
Normal file
@@ -0,0 +1,285 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation'
|
||||
import { getConfluenceCloudId } from '@/tools/confluence/utils'
|
||||
|
||||
const logger = createLogger('ConfluenceBlogPostsAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const getBlogPostSchema = z
|
||||
.object({
|
||||
domain: z.string().min(1, 'Domain is required'),
|
||||
accessToken: z.string().min(1, 'Access token is required'),
|
||||
cloudId: z.string().optional(),
|
||||
blogPostId: z.string().min(1, 'Blog post ID is required'),
|
||||
bodyFormat: z.string().optional(),
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
const validation = validateAlphanumericId(data.blogPostId, 'blogPostId', 255)
|
||||
return validation.isValid
|
||||
},
|
||||
(data) => {
|
||||
const validation = validateAlphanumericId(data.blogPostId, 'blogPostId', 255)
|
||||
return { message: validation.error || 'Invalid blog post ID', path: ['blogPostId'] }
|
||||
}
|
||||
)
|
||||
|
||||
const createBlogPostSchema = z.object({
|
||||
domain: z.string().min(1, 'Domain is required'),
|
||||
accessToken: z.string().min(1, 'Access token is required'),
|
||||
cloudId: z.string().optional(),
|
||||
spaceId: z.string().min(1, 'Space ID is required'),
|
||||
title: z.string().min(1, 'Title is required'),
|
||||
content: z.string().min(1, 'Content is required'),
|
||||
status: z.enum(['current', 'draft']).optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* List all blog posts or get a specific blog post
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const auth = await checkSessionOrInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const domain = searchParams.get('domain')
|
||||
const accessToken = searchParams.get('accessToken')
|
||||
const providedCloudId = searchParams.get('cloudId')
|
||||
const limit = searchParams.get('limit') || '25'
|
||||
const status = searchParams.get('status')
|
||||
const sortOrder = searchParams.get('sort')
|
||||
const cursor = searchParams.get('cursor')
|
||||
|
||||
if (!domain) {
|
||||
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!accessToken) {
|
||||
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken))
|
||||
|
||||
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
|
||||
if (!cloudIdValidation.isValid) {
|
||||
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
const queryParams = new URLSearchParams()
|
||||
queryParams.append('limit', String(Math.min(Number(limit), 250)))
|
||||
|
||||
if (status) {
|
||||
queryParams.append('status', status)
|
||||
}
|
||||
|
||||
if (sortOrder) {
|
||||
queryParams.append('sort', sortOrder)
|
||||
}
|
||||
|
||||
if (cursor) {
|
||||
queryParams.append('cursor', cursor)
|
||||
}
|
||||
|
||||
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/blogposts?${queryParams.toString()}`
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => null)
|
||||
logger.error('Confluence API error response:', {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
error: JSON.stringify(errorData, null, 2),
|
||||
})
|
||||
const errorMessage = errorData?.message || `Failed to list blog posts (${response.status})`
|
||||
return NextResponse.json({ error: errorMessage }, { status: response.status })
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
const blogPosts = (data.results || []).map((post: any) => ({
|
||||
id: post.id,
|
||||
title: post.title,
|
||||
status: post.status ?? null,
|
||||
spaceId: post.spaceId ?? null,
|
||||
authorId: post.authorId ?? null,
|
||||
createdAt: post.createdAt ?? null,
|
||||
version: post.version ?? null,
|
||||
webUrl: post._links?.webui ?? null,
|
||||
}))
|
||||
|
||||
return NextResponse.json({
|
||||
blogPosts,
|
||||
nextCursor: data._links?.next
|
||||
? new URL(data._links.next, 'https://placeholder').searchParams.get('cursor')
|
||||
: null,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error listing blog posts:', error)
|
||||
return NextResponse.json(
|
||||
{ error: (error as Error).message || 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific blog post by ID
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const auth = await checkSessionOrInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
|
||||
// Check if this is a create or get request
|
||||
if (body.title && body.content && body.spaceId) {
|
||||
// Create blog post
|
||||
const validation = createBlogPostSchema.safeParse(body)
|
||||
if (!validation.success) {
|
||||
const firstError = validation.error.errors[0]
|
||||
return NextResponse.json({ error: firstError.message }, { status: 400 })
|
||||
}
|
||||
|
||||
const {
|
||||
domain,
|
||||
accessToken,
|
||||
cloudId: providedCloudId,
|
||||
spaceId,
|
||||
title,
|
||||
content,
|
||||
status,
|
||||
} = validation.data
|
||||
|
||||
const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken))
|
||||
|
||||
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
|
||||
if (!cloudIdValidation.isValid) {
|
||||
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/blogposts`
|
||||
|
||||
const createBody = {
|
||||
spaceId,
|
||||
status: status || 'current',
|
||||
title,
|
||||
body: {
|
||||
representation: 'storage',
|
||||
value: content,
|
||||
},
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
body: JSON.stringify(createBody),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => null)
|
||||
logger.error('Confluence API error response:', {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
error: JSON.stringify(errorData, null, 2),
|
||||
})
|
||||
const errorMessage = errorData?.message || `Failed to create blog post (${response.status})`
|
||||
return NextResponse.json({ error: errorMessage }, { status: response.status })
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return NextResponse.json({
|
||||
id: data.id,
|
||||
title: data.title,
|
||||
spaceId: data.spaceId,
|
||||
webUrl: data._links?.webui ?? null,
|
||||
})
|
||||
}
|
||||
// Get blog post by ID
|
||||
const validation = getBlogPostSchema.safeParse(body)
|
||||
if (!validation.success) {
|
||||
const firstError = validation.error.errors[0]
|
||||
return NextResponse.json({ error: firstError.message }, { status: 400 })
|
||||
}
|
||||
|
||||
const {
|
||||
domain,
|
||||
accessToken,
|
||||
cloudId: providedCloudId,
|
||||
blogPostId,
|
||||
bodyFormat,
|
||||
} = validation.data
|
||||
|
||||
const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken))
|
||||
|
||||
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
|
||||
if (!cloudIdValidation.isValid) {
|
||||
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
const queryParams = new URLSearchParams()
|
||||
if (bodyFormat) {
|
||||
queryParams.append('body-format', bodyFormat)
|
||||
}
|
||||
|
||||
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/blogposts/${blogPostId}${queryParams.toString() ? `?${queryParams.toString()}` : ''}`
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => null)
|
||||
logger.error('Confluence API error response:', {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
error: JSON.stringify(errorData, null, 2),
|
||||
})
|
||||
const errorMessage = errorData?.message || `Failed to get blog post (${response.status})`
|
||||
return NextResponse.json({ error: errorMessage }, { status: response.status })
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return NextResponse.json({
|
||||
id: data.id,
|
||||
title: data.title,
|
||||
status: data.status ?? null,
|
||||
spaceId: data.spaceId ?? null,
|
||||
authorId: data.authorId ?? null,
|
||||
createdAt: data.createdAt ?? null,
|
||||
version: data.version ?? null,
|
||||
body: data.body ?? null,
|
||||
webUrl: data._links?.webui ?? null,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error with blog post operation:', error)
|
||||
return NextResponse.json(
|
||||
{ error: (error as Error).message || 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -105,6 +105,8 @@ export async function GET(request: NextRequest) {
|
||||
const pageId = searchParams.get('pageId')
|
||||
const providedCloudId = searchParams.get('cloudId')
|
||||
const limit = searchParams.get('limit') || '25'
|
||||
const bodyFormat = searchParams.get('bodyFormat') || 'storage'
|
||||
const cursor = searchParams.get('cursor')
|
||||
|
||||
if (!domain) {
|
||||
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
|
||||
@@ -130,7 +132,13 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/pages/${pageId}/footer-comments?limit=${limit}`
|
||||
const queryParams = new URLSearchParams()
|
||||
queryParams.append('limit', String(Math.min(Number(limit), 250)))
|
||||
queryParams.append('body-format', bodyFormat)
|
||||
if (cursor) {
|
||||
queryParams.append('cursor', cursor)
|
||||
}
|
||||
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/pages/${pageId}/footer-comments?${queryParams.toString()}`
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
@@ -154,14 +162,31 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
const comments = (data.results || []).map((comment: any) => ({
|
||||
id: comment.id,
|
||||
body: comment.body?.storage?.value || comment.body?.view?.value || '',
|
||||
createdAt: comment.createdAt || '',
|
||||
authorId: comment.authorId || '',
|
||||
}))
|
||||
const comments = (data.results || []).map((comment: any) => {
|
||||
const bodyValue = comment.body?.storage?.value || comment.body?.view?.value || ''
|
||||
return {
|
||||
id: comment.id,
|
||||
body: {
|
||||
value: bodyValue,
|
||||
representation: bodyFormat,
|
||||
},
|
||||
createdAt: comment.createdAt || '',
|
||||
authorId: comment.authorId || '',
|
||||
status: comment.status ?? null,
|
||||
title: comment.title ?? null,
|
||||
pageId: comment.pageId ?? null,
|
||||
blogPostId: comment.blogPostId ?? null,
|
||||
parentCommentId: comment.parentCommentId ?? null,
|
||||
version: comment.version ?? null,
|
||||
}
|
||||
})
|
||||
|
||||
return NextResponse.json({ comments })
|
||||
return NextResponse.json({
|
||||
comments,
|
||||
nextCursor: data._links?.next
|
||||
? new URL(data._links.next, 'https://placeholder').searchParams.get('cursor')
|
||||
: null,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error listing Confluence comments:', error)
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -22,6 +22,7 @@ export async function POST(request: NextRequest) {
|
||||
cloudId: providedCloudId,
|
||||
pageId,
|
||||
labelName,
|
||||
prefix: labelPrefix,
|
||||
} = await request.json()
|
||||
|
||||
if (!domain) {
|
||||
@@ -52,12 +53,14 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/pages/${pageId}/labels`
|
||||
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/rest/api/content/${pageId}/label`
|
||||
|
||||
const body = {
|
||||
prefix: 'global',
|
||||
name: labelName,
|
||||
}
|
||||
const body = [
|
||||
{
|
||||
prefix: labelPrefix || 'global',
|
||||
name: labelName,
|
||||
},
|
||||
]
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
@@ -82,7 +85,14 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return NextResponse.json({ ...data, pageId, labelName })
|
||||
const addedLabel = data.results?.[0] || data[0] || data
|
||||
return NextResponse.json({
|
||||
id: addedLabel.id ?? '',
|
||||
name: addedLabel.name ?? labelName,
|
||||
prefix: addedLabel.prefix ?? labelPrefix ?? 'global',
|
||||
pageId,
|
||||
labelName,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error adding Confluence label:', error)
|
||||
return NextResponse.json(
|
||||
@@ -105,6 +115,8 @@ export async function GET(request: NextRequest) {
|
||||
const accessToken = searchParams.get('accessToken')
|
||||
const pageId = searchParams.get('pageId')
|
||||
const providedCloudId = searchParams.get('cloudId')
|
||||
const limit = searchParams.get('limit') || '25'
|
||||
const cursor = searchParams.get('cursor')
|
||||
|
||||
if (!domain) {
|
||||
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
|
||||
@@ -130,7 +142,12 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/pages/${pageId}/labels`
|
||||
const queryParams = new URLSearchParams()
|
||||
queryParams.append('limit', String(Math.min(Number(limit), 250)))
|
||||
if (cursor) {
|
||||
queryParams.append('cursor', cursor)
|
||||
}
|
||||
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/pages/${pageId}/labels?${queryParams.toString()}`
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
@@ -160,7 +177,12 @@ export async function GET(request: NextRequest) {
|
||||
prefix: label.prefix || 'global',
|
||||
}))
|
||||
|
||||
return NextResponse.json({ labels })
|
||||
return NextResponse.json({
|
||||
labels,
|
||||
nextCursor: data._links?.next
|
||||
? new URL(data._links.next, 'https://placeholder').searchParams.get('cursor')
|
||||
: null,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error listing Confluence labels:', error)
|
||||
return NextResponse.json(
|
||||
|
||||
96
apps/sim/app/api/tools/confluence/page-ancestors/route.ts
Normal file
96
apps/sim/app/api/tools/confluence/page-ancestors/route.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation'
|
||||
import { getConfluenceCloudId } from '@/tools/confluence/utils'
|
||||
|
||||
const logger = createLogger('ConfluencePageAncestorsAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
/**
|
||||
* Get ancestors (parent pages) of a specific Confluence page.
|
||||
* Uses GET /wiki/api/v2/pages/{id}/ancestors
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const auth = await checkSessionOrInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { domain, accessToken, pageId, cloudId: providedCloudId, limit = 25 } = body
|
||||
|
||||
if (!domain) {
|
||||
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!accessToken) {
|
||||
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!pageId) {
|
||||
return NextResponse.json({ error: 'Page ID is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const pageIdValidation = validateAlphanumericId(pageId, 'pageId', 255)
|
||||
if (!pageIdValidation.isValid) {
|
||||
return NextResponse.json({ error: pageIdValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken))
|
||||
|
||||
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
|
||||
if (!cloudIdValidation.isValid) {
|
||||
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
const queryParams = new URLSearchParams()
|
||||
queryParams.append('limit', String(Math.min(limit, 250)))
|
||||
|
||||
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/pages/${pageId}/ancestors?${queryParams.toString()}`
|
||||
|
||||
logger.info(`Fetching ancestors for page ${pageId}`)
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => null)
|
||||
logger.error('Confluence API error response:', {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
error: JSON.stringify(errorData, null, 2),
|
||||
})
|
||||
const errorMessage = errorData?.message || `Failed to get page ancestors (${response.status})`
|
||||
return NextResponse.json({ error: errorMessage }, { status: response.status })
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
const ancestors = (data.results || []).map((page: any) => ({
|
||||
id: page.id,
|
||||
title: page.title,
|
||||
status: page.status ?? null,
|
||||
spaceId: page.spaceId ?? null,
|
||||
webUrl: page._links?.webui ?? null,
|
||||
}))
|
||||
|
||||
return NextResponse.json({
|
||||
ancestors,
|
||||
pageId,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error getting page ancestors:', error)
|
||||
return NextResponse.json(
|
||||
{ error: (error as Error).message || 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
104
apps/sim/app/api/tools/confluence/page-children/route.ts
Normal file
104
apps/sim/app/api/tools/confluence/page-children/route.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation'
|
||||
import { getConfluenceCloudId } from '@/tools/confluence/utils'
|
||||
|
||||
const logger = createLogger('ConfluencePageChildrenAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
/**
|
||||
* Get child pages of a specific Confluence page.
|
||||
* Uses GET /wiki/api/v2/pages/{id}/children
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const auth = await checkSessionOrInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { domain, accessToken, pageId, cloudId: providedCloudId, limit = 50, cursor } = body
|
||||
|
||||
if (!domain) {
|
||||
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!accessToken) {
|
||||
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!pageId) {
|
||||
return NextResponse.json({ error: 'Page ID is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const pageIdValidation = validateAlphanumericId(pageId, 'pageId', 255)
|
||||
if (!pageIdValidation.isValid) {
|
||||
return NextResponse.json({ error: pageIdValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken))
|
||||
|
||||
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
|
||||
if (!cloudIdValidation.isValid) {
|
||||
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
const queryParams = new URLSearchParams()
|
||||
queryParams.append('limit', String(Math.min(limit, 250)))
|
||||
|
||||
if (cursor) {
|
||||
queryParams.append('cursor', cursor)
|
||||
}
|
||||
|
||||
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/pages/${pageId}/children?${queryParams.toString()}`
|
||||
|
||||
logger.info(`Fetching child pages for page ${pageId}`)
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => null)
|
||||
logger.error('Confluence API error response:', {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
error: JSON.stringify(errorData, null, 2),
|
||||
})
|
||||
const errorMessage = errorData?.message || `Failed to get child pages (${response.status})`
|
||||
return NextResponse.json({ error: errorMessage }, { status: response.status })
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
const children = (data.results || []).map((page: any) => ({
|
||||
id: page.id,
|
||||
title: page.title,
|
||||
status: page.status ?? null,
|
||||
spaceId: page.spaceId ?? null,
|
||||
childPosition: page.childPosition ?? null,
|
||||
webUrl: page._links?.webui ?? null,
|
||||
}))
|
||||
|
||||
return NextResponse.json({
|
||||
children,
|
||||
parentId: pageId,
|
||||
nextCursor: data._links?.next
|
||||
? new URL(data._links.next, 'https://placeholder').searchParams.get('cursor')
|
||||
: null,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error getting child pages:', error)
|
||||
return NextResponse.json(
|
||||
{ error: (error as Error).message || 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
365
apps/sim/app/api/tools/confluence/page-properties/route.ts
Normal file
365
apps/sim/app/api/tools/confluence/page-properties/route.ts
Normal file
@@ -0,0 +1,365 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation'
|
||||
import { getConfluenceCloudId } from '@/tools/confluence/utils'
|
||||
|
||||
const logger = createLogger('ConfluencePagePropertiesAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const createPropertySchema = z.object({
|
||||
domain: z.string().min(1, 'Domain is required'),
|
||||
accessToken: z.string().min(1, 'Access token is required'),
|
||||
cloudId: z.string().optional(),
|
||||
pageId: z.string().min(1, 'Page ID is required'),
|
||||
key: z.string().min(1, 'Property key is required'),
|
||||
value: z.any(),
|
||||
})
|
||||
|
||||
const updatePropertySchema = z.object({
|
||||
domain: z.string().min(1, 'Domain is required'),
|
||||
accessToken: z.string().min(1, 'Access token is required'),
|
||||
cloudId: z.string().optional(),
|
||||
pageId: z.string().min(1, 'Page ID is required'),
|
||||
propertyId: z.string().min(1, 'Property ID is required'),
|
||||
key: z.string().min(1, 'Property key is required'),
|
||||
value: z.any(),
|
||||
versionNumber: z.number().min(1, 'Version number is required'),
|
||||
})
|
||||
|
||||
const deletePropertySchema = z.object({
|
||||
domain: z.string().min(1, 'Domain is required'),
|
||||
accessToken: z.string().min(1, 'Access token is required'),
|
||||
cloudId: z.string().optional(),
|
||||
pageId: z.string().min(1, 'Page ID is required'),
|
||||
propertyId: z.string().min(1, 'Property ID is required'),
|
||||
})
|
||||
|
||||
/**
|
||||
* List all content properties on a page.
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const auth = await checkSessionOrInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const domain = searchParams.get('domain')
|
||||
const accessToken = searchParams.get('accessToken')
|
||||
const pageId = searchParams.get('pageId')
|
||||
const providedCloudId = searchParams.get('cloudId')
|
||||
const limit = searchParams.get('limit') || '50'
|
||||
const cursor = searchParams.get('cursor')
|
||||
|
||||
if (!domain) {
|
||||
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!accessToken) {
|
||||
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!pageId) {
|
||||
return NextResponse.json({ error: 'Page ID is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const pageIdValidation = validateAlphanumericId(pageId, 'pageId', 255)
|
||||
if (!pageIdValidation.isValid) {
|
||||
return NextResponse.json({ error: pageIdValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken))
|
||||
|
||||
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
|
||||
if (!cloudIdValidation.isValid) {
|
||||
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
const queryParams = new URLSearchParams()
|
||||
queryParams.append('limit', String(Math.min(Number(limit), 250)))
|
||||
if (cursor) {
|
||||
queryParams.append('cursor', cursor)
|
||||
}
|
||||
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/pages/${pageId}/properties?${queryParams.toString()}`
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => null)
|
||||
logger.error('Confluence API error response:', {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
error: JSON.stringify(errorData, null, 2),
|
||||
})
|
||||
const errorMessage =
|
||||
errorData?.message || `Failed to list page properties (${response.status})`
|
||||
return NextResponse.json({ error: errorMessage }, { status: response.status })
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
const properties = (data.results || []).map((prop: any) => ({
|
||||
id: prop.id,
|
||||
key: prop.key,
|
||||
value: prop.value ?? null,
|
||||
version: prop.version ?? null,
|
||||
}))
|
||||
|
||||
return NextResponse.json({
|
||||
properties,
|
||||
pageId,
|
||||
nextCursor: data._links?.next
|
||||
? new URL(data._links.next, 'https://placeholder').searchParams.get('cursor')
|
||||
: null,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error listing page properties:', error)
|
||||
return NextResponse.json(
|
||||
{ error: (error as Error).message || 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new content property on a page.
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const auth = await checkSessionOrInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
|
||||
const validation = createPropertySchema.safeParse(body)
|
||||
if (!validation.success) {
|
||||
const firstError = validation.error.errors[0]
|
||||
return NextResponse.json({ error: firstError.message }, { status: 400 })
|
||||
}
|
||||
|
||||
const { domain, accessToken, cloudId: providedCloudId, pageId, key, value } = validation.data
|
||||
|
||||
const pageIdValidation = validateAlphanumericId(pageId, 'pageId', 255)
|
||||
if (!pageIdValidation.isValid) {
|
||||
return NextResponse.json({ error: pageIdValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken))
|
||||
|
||||
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
|
||||
if (!cloudIdValidation.isValid) {
|
||||
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/pages/${pageId}/properties`
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
body: JSON.stringify({ key, value }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => null)
|
||||
logger.error('Confluence API error response:', {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
error: JSON.stringify(errorData, null, 2),
|
||||
})
|
||||
const errorMessage =
|
||||
errorData?.message || `Failed to create page property (${response.status})`
|
||||
return NextResponse.json({ error: errorMessage }, { status: response.status })
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return NextResponse.json({
|
||||
id: data.id,
|
||||
key: data.key,
|
||||
value: data.value,
|
||||
version: data.version,
|
||||
pageId,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error creating page property:', error)
|
||||
return NextResponse.json(
|
||||
{ error: (error as Error).message || 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a content property on a page.
|
||||
*/
|
||||
export async function PUT(request: NextRequest) {
|
||||
try {
|
||||
const auth = await checkSessionOrInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
|
||||
const validation = updatePropertySchema.safeParse(body)
|
||||
if (!validation.success) {
|
||||
const firstError = validation.error.errors[0]
|
||||
return NextResponse.json({ error: firstError.message }, { status: 400 })
|
||||
}
|
||||
|
||||
const {
|
||||
domain,
|
||||
accessToken,
|
||||
cloudId: providedCloudId,
|
||||
pageId,
|
||||
propertyId,
|
||||
key,
|
||||
value,
|
||||
versionNumber,
|
||||
} = validation.data
|
||||
|
||||
const pageIdValidation = validateAlphanumericId(pageId, 'pageId', 255)
|
||||
if (!pageIdValidation.isValid) {
|
||||
return NextResponse.json({ error: pageIdValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
const propertyIdValidation = validateAlphanumericId(propertyId, 'propertyId', 255)
|
||||
if (!propertyIdValidation.isValid) {
|
||||
return NextResponse.json({ error: propertyIdValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken))
|
||||
|
||||
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
|
||||
if (!cloudIdValidation.isValid) {
|
||||
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/pages/${pageId}/properties/${propertyId}`
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
key,
|
||||
value,
|
||||
version: { number: versionNumber },
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => null)
|
||||
logger.error('Confluence API error response:', {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
error: JSON.stringify(errorData, null, 2),
|
||||
})
|
||||
const errorMessage =
|
||||
errorData?.message || `Failed to update page property (${response.status})`
|
||||
return NextResponse.json({ error: errorMessage }, { status: response.status })
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return NextResponse.json({
|
||||
id: data.id,
|
||||
key: data.key,
|
||||
value: data.value,
|
||||
version: data.version,
|
||||
pageId,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error updating page property:', error)
|
||||
return NextResponse.json(
|
||||
{ error: (error as Error).message || 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a content property from a page.
|
||||
*/
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const auth = await checkSessionOrInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
|
||||
const validation = deletePropertySchema.safeParse(body)
|
||||
if (!validation.success) {
|
||||
const firstError = validation.error.errors[0]
|
||||
return NextResponse.json({ error: firstError.message }, { status: 400 })
|
||||
}
|
||||
|
||||
const { domain, accessToken, cloudId: providedCloudId, pageId, propertyId } = validation.data
|
||||
|
||||
const pageIdValidation = validateAlphanumericId(pageId, 'pageId', 255)
|
||||
if (!pageIdValidation.isValid) {
|
||||
return NextResponse.json({ error: pageIdValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
const propertyIdValidation = validateAlphanumericId(propertyId, 'propertyId', 255)
|
||||
if (!propertyIdValidation.isValid) {
|
||||
return NextResponse.json({ error: propertyIdValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken))
|
||||
|
||||
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
|
||||
if (!cloudIdValidation.isValid) {
|
||||
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/pages/${pageId}/properties/${propertyId}`
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => null)
|
||||
logger.error('Confluence API error response:', {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
error: JSON.stringify(errorData, null, 2),
|
||||
})
|
||||
const errorMessage =
|
||||
errorData?.message || `Failed to delete page property (${response.status})`
|
||||
return NextResponse.json({ error: errorMessage }, { status: response.status })
|
||||
}
|
||||
|
||||
return NextResponse.json({ propertyId, pageId, deleted: true })
|
||||
} catch (error) {
|
||||
logger.error('Error deleting page property:', error)
|
||||
return NextResponse.json(
|
||||
{ error: (error as Error).message || 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
151
apps/sim/app/api/tools/confluence/page-versions/route.ts
Normal file
151
apps/sim/app/api/tools/confluence/page-versions/route.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation'
|
||||
import { getConfluenceCloudId } from '@/tools/confluence/utils'
|
||||
|
||||
const logger = createLogger('ConfluencePageVersionsAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
/**
|
||||
* List all versions of a page or get a specific version.
|
||||
* Uses GET /wiki/api/v2/pages/{id}/versions
|
||||
* and GET /wiki/api/v2/pages/{page-id}/versions/{version-number}
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const auth = await checkSessionOrInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const {
|
||||
domain,
|
||||
accessToken,
|
||||
pageId,
|
||||
versionNumber,
|
||||
cloudId: providedCloudId,
|
||||
limit = 50,
|
||||
cursor,
|
||||
} = body
|
||||
|
||||
if (!domain) {
|
||||
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!accessToken) {
|
||||
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!pageId) {
|
||||
return NextResponse.json({ error: 'Page ID is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const pageIdValidation = validateAlphanumericId(pageId, 'pageId', 255)
|
||||
if (!pageIdValidation.isValid) {
|
||||
return NextResponse.json({ error: pageIdValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken))
|
||||
|
||||
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
|
||||
if (!cloudIdValidation.isValid) {
|
||||
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
// If versionNumber is provided, get specific version
|
||||
if (versionNumber !== undefined && versionNumber !== null) {
|
||||
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/pages/${pageId}/versions/${versionNumber}`
|
||||
|
||||
logger.info(`Fetching version ${versionNumber} for page ${pageId}`)
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => null)
|
||||
logger.error('Confluence API error response:', {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
error: JSON.stringify(errorData, null, 2),
|
||||
})
|
||||
const errorMessage = errorData?.message || `Failed to get page version (${response.status})`
|
||||
return NextResponse.json({ error: errorMessage }, { status: response.status })
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
return NextResponse.json({
|
||||
version: {
|
||||
number: data.number,
|
||||
message: data.message ?? null,
|
||||
minorEdit: data.minorEdit ?? false,
|
||||
authorId: data.authorId ?? null,
|
||||
createdAt: data.createdAt ?? null,
|
||||
},
|
||||
pageId,
|
||||
})
|
||||
}
|
||||
// List all versions
|
||||
const queryParams = new URLSearchParams()
|
||||
queryParams.append('limit', String(Math.min(limit, 250)))
|
||||
|
||||
if (cursor) {
|
||||
queryParams.append('cursor', cursor)
|
||||
}
|
||||
|
||||
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/pages/${pageId}/versions?${queryParams.toString()}`
|
||||
|
||||
logger.info(`Fetching versions for page ${pageId}`)
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => null)
|
||||
logger.error('Confluence API error response:', {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
error: JSON.stringify(errorData, null, 2),
|
||||
})
|
||||
const errorMessage = errorData?.message || `Failed to list page versions (${response.status})`
|
||||
return NextResponse.json({ error: errorMessage }, { status: response.status })
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
const versions = (data.results || []).map((version: any) => ({
|
||||
number: version.number,
|
||||
message: version.message ?? null,
|
||||
minorEdit: version.minorEdit ?? false,
|
||||
authorId: version.authorId ?? null,
|
||||
createdAt: version.createdAt ?? null,
|
||||
}))
|
||||
|
||||
return NextResponse.json({
|
||||
versions,
|
||||
pageId,
|
||||
nextCursor: data._links?.next
|
||||
? new URL(data._links.next, 'https://placeholder').searchParams.get('cursor')
|
||||
: null,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error with page versions:', error)
|
||||
return NextResponse.json(
|
||||
{ error: (error as Error).message || 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -62,6 +62,7 @@ const deletePageSchema = z
|
||||
accessToken: z.string().min(1, 'Access token is required'),
|
||||
cloudId: z.string().optional(),
|
||||
pageId: z.string().min(1, 'Page ID is required'),
|
||||
purge: z.boolean().optional(),
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
@@ -98,7 +99,7 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/pages/${pageId}?expand=body.storage,body.view,body.atlas_doc_format`
|
||||
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/pages/${pageId}?body-format=storage`
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
@@ -130,16 +131,18 @@ export async function POST(request: NextRequest) {
|
||||
id: data.id,
|
||||
title: data.title,
|
||||
body: {
|
||||
view: {
|
||||
value:
|
||||
data.body?.storage?.value ||
|
||||
data.body?.view?.value ||
|
||||
data.body?.atlas_doc_format?.value ||
|
||||
data.content || // try alternative fields
|
||||
data.description ||
|
||||
`Content for page ${data.title}`, // fallback content
|
||||
storage: {
|
||||
value: data.body?.storage?.value ?? null,
|
||||
representation: 'storage',
|
||||
},
|
||||
},
|
||||
status: data.status ?? null,
|
||||
spaceId: data.spaceId ?? null,
|
||||
parentId: data.parentId ?? null,
|
||||
authorId: data.authorId ?? null,
|
||||
createdAt: data.createdAt ?? null,
|
||||
version: data.version ?? null,
|
||||
_links: data._links ?? null,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error fetching Confluence page:', error)
|
||||
@@ -274,7 +277,7 @@ export async function DELETE(request: NextRequest) {
|
||||
return NextResponse.json({ error: firstError.message }, { status: 400 })
|
||||
}
|
||||
|
||||
const { domain, accessToken, cloudId: providedCloudId, pageId } = validation.data
|
||||
const { domain, accessToken, cloudId: providedCloudId, pageId, purge } = validation.data
|
||||
|
||||
const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken))
|
||||
|
||||
@@ -283,7 +286,12 @@ export async function DELETE(request: NextRequest) {
|
||||
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/pages/${pageId}`
|
||||
const queryParams = new URLSearchParams()
|
||||
if (purge) {
|
||||
queryParams.append('purge', 'true')
|
||||
}
|
||||
const queryString = queryParams.toString()
|
||||
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/pages/${pageId}${queryString ? `?${queryString}` : ''}`
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'DELETE',
|
||||
|
||||
@@ -32,7 +32,6 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Use provided cloudId or fetch it if not provided
|
||||
const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken))
|
||||
|
||||
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
|
||||
@@ -40,7 +39,6 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
// Build the URL with query parameters
|
||||
const baseUrl = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/pages`
|
||||
const queryParams = new URLSearchParams()
|
||||
|
||||
@@ -57,7 +55,6 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
logger.info(`Fetching Confluence pages from: ${url}`)
|
||||
|
||||
// Make the request to Confluence API with OAuth Bearer token
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
@@ -79,7 +76,6 @@ export async function POST(request: NextRequest) {
|
||||
} catch (e) {
|
||||
logger.error('Could not parse error response as JSON:', e)
|
||||
|
||||
// Try to get the response text for more context
|
||||
try {
|
||||
const text = await response.text()
|
||||
logger.error('Response text:', text)
|
||||
|
||||
120
apps/sim/app/api/tools/confluence/search-in-space/route.ts
Normal file
120
apps/sim/app/api/tools/confluence/search-in-space/route.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation'
|
||||
import { getConfluenceCloudId } from '@/tools/confluence/utils'
|
||||
|
||||
const logger = createLogger('ConfluenceSearchInSpaceAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
/**
|
||||
* Search for content within a specific Confluence space using CQL.
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const auth = await checkSessionOrInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const {
|
||||
domain,
|
||||
accessToken,
|
||||
spaceKey,
|
||||
query,
|
||||
cloudId: providedCloudId,
|
||||
limit = 25,
|
||||
contentType,
|
||||
} = body
|
||||
|
||||
if (!domain) {
|
||||
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!accessToken) {
|
||||
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!spaceKey) {
|
||||
return NextResponse.json({ error: 'Space key is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const spaceKeyValidation = validateAlphanumericId(spaceKey, 'spaceKey', 255)
|
||||
if (!spaceKeyValidation.isValid) {
|
||||
return NextResponse.json({ error: spaceKeyValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken))
|
||||
|
||||
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
|
||||
if (!cloudIdValidation.isValid) {
|
||||
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
const escapeCqlValue = (value: string) => value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
|
||||
|
||||
let cql = `space = "${escapeCqlValue(spaceKey)}"`
|
||||
|
||||
if (query) {
|
||||
cql += ` AND text ~ "${escapeCqlValue(query)}"`
|
||||
}
|
||||
|
||||
if (contentType) {
|
||||
cql += ` AND type = "${escapeCqlValue(contentType)}"`
|
||||
}
|
||||
|
||||
const searchParams = new URLSearchParams({
|
||||
cql,
|
||||
limit: String(Math.min(limit, 250)),
|
||||
})
|
||||
|
||||
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/rest/api/search?${searchParams.toString()}`
|
||||
|
||||
logger.info(`Searching in space ${spaceKey} with CQL: ${cql}`)
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => null)
|
||||
logger.error('Confluence API error response:', {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
error: JSON.stringify(errorData, null, 2),
|
||||
})
|
||||
const errorMessage = errorData?.message || `Failed to search in space (${response.status})`
|
||||
return NextResponse.json({ error: errorMessage }, { status: response.status })
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
const results = (data.results || []).map((result: any) => ({
|
||||
id: result.content?.id ?? result.id,
|
||||
title: result.content?.title ?? result.title,
|
||||
type: result.content?.type ?? result.type,
|
||||
status: result.content?.status ?? null,
|
||||
url: result.url ?? result._links?.webui ?? '',
|
||||
excerpt: result.excerpt ?? '',
|
||||
lastModified: result.lastModified ?? null,
|
||||
}))
|
||||
|
||||
return NextResponse.json({
|
||||
results,
|
||||
spaceKey,
|
||||
totalSize: data.totalSize ?? results.length,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error searching in space:', error)
|
||||
return NextResponse.json(
|
||||
{ error: (error as Error).message || 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -42,8 +42,10 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
const escapeCqlValue = (value: string) => value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
|
||||
|
||||
const searchParams = new URLSearchParams({
|
||||
cql: `text ~ "${query}"`,
|
||||
cql: `text ~ "${escapeCqlValue(query)}"`,
|
||||
limit: limit.toString(),
|
||||
})
|
||||
|
||||
@@ -70,13 +72,27 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
const results = (data.results || []).map((result: any) => ({
|
||||
id: result.content?.id || result.id,
|
||||
title: result.content?.title || result.title,
|
||||
type: result.content?.type || result.type,
|
||||
url: result.url || result._links?.webui || '',
|
||||
excerpt: result.excerpt || '',
|
||||
}))
|
||||
const results = (data.results || []).map((result: any) => {
|
||||
const spaceData = result.resultGlobalContainer || result.content?.space
|
||||
return {
|
||||
id: result.content?.id || result.id,
|
||||
title: result.content?.title || result.title,
|
||||
type: result.content?.type || result.type,
|
||||
url: result.url || result._links?.webui || '',
|
||||
excerpt: result.excerpt || '',
|
||||
status: result.content?.status ?? null,
|
||||
spaceKey: result.resultGlobalContainer?.key ?? result.content?.space?.key ?? null,
|
||||
space: spaceData
|
||||
? {
|
||||
id: spaceData.id ?? null,
|
||||
key: spaceData.key ?? null,
|
||||
name: spaceData.name ?? spaceData.title ?? null,
|
||||
}
|
||||
: null,
|
||||
lastModified: result.lastModified ?? result.content?.history?.lastUpdated?.when ?? null,
|
||||
entityType: result.entityType ?? null,
|
||||
}
|
||||
})
|
||||
|
||||
return NextResponse.json({ results })
|
||||
} catch (error) {
|
||||
|
||||
124
apps/sim/app/api/tools/confluence/space-blogposts/route.ts
Normal file
124
apps/sim/app/api/tools/confluence/space-blogposts/route.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation'
|
||||
import { getConfluenceCloudId } from '@/tools/confluence/utils'
|
||||
|
||||
const logger = createLogger('ConfluenceSpaceBlogPostsAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
/**
|
||||
* List all blog posts in a specific Confluence space.
|
||||
* Uses GET /wiki/api/v2/spaces/{id}/blogposts
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const auth = await checkSessionOrInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const {
|
||||
domain,
|
||||
accessToken,
|
||||
spaceId,
|
||||
cloudId: providedCloudId,
|
||||
limit = 25,
|
||||
status,
|
||||
bodyFormat,
|
||||
cursor,
|
||||
} = body
|
||||
|
||||
if (!domain) {
|
||||
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!accessToken) {
|
||||
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!spaceId) {
|
||||
return NextResponse.json({ error: 'Space ID is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const spaceIdValidation = validateAlphanumericId(spaceId, 'spaceId', 255)
|
||||
if (!spaceIdValidation.isValid) {
|
||||
return NextResponse.json({ error: spaceIdValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken))
|
||||
|
||||
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
|
||||
if (!cloudIdValidation.isValid) {
|
||||
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
const queryParams = new URLSearchParams()
|
||||
queryParams.append('limit', String(Math.min(limit, 250)))
|
||||
|
||||
if (status) {
|
||||
queryParams.append('status', status)
|
||||
}
|
||||
|
||||
if (bodyFormat) {
|
||||
queryParams.append('body-format', bodyFormat)
|
||||
}
|
||||
|
||||
if (cursor) {
|
||||
queryParams.append('cursor', cursor)
|
||||
}
|
||||
|
||||
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/spaces/${spaceId}/blogposts?${queryParams.toString()}`
|
||||
|
||||
logger.info(`Fetching blog posts in space ${spaceId}`)
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => null)
|
||||
logger.error('Confluence API error response:', {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
error: JSON.stringify(errorData, null, 2),
|
||||
})
|
||||
const errorMessage =
|
||||
errorData?.message || `Failed to list blog posts in space (${response.status})`
|
||||
return NextResponse.json({ error: errorMessage }, { status: response.status })
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
const blogPosts = (data.results || []).map((post: any) => ({
|
||||
id: post.id,
|
||||
title: post.title,
|
||||
status: post.status ?? null,
|
||||
spaceId: post.spaceId ?? null,
|
||||
authorId: post.authorId ?? null,
|
||||
createdAt: post.createdAt ?? null,
|
||||
version: post.version ?? null,
|
||||
body: post.body ?? null,
|
||||
webUrl: post._links?.webui ?? null,
|
||||
}))
|
||||
|
||||
return NextResponse.json({
|
||||
blogPosts,
|
||||
nextCursor: data._links?.next
|
||||
? new URL(data._links.next, 'https://placeholder').searchParams.get('cursor')
|
||||
: null,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error listing blog posts in space:', error)
|
||||
return NextResponse.json(
|
||||
{ error: (error as Error).message || 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
125
apps/sim/app/api/tools/confluence/space-pages/route.ts
Normal file
125
apps/sim/app/api/tools/confluence/space-pages/route.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation'
|
||||
import { getConfluenceCloudId } from '@/tools/confluence/utils'
|
||||
|
||||
const logger = createLogger('ConfluenceSpacePagesAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
/**
|
||||
* List all pages in a specific Confluence space.
|
||||
* Uses GET /wiki/api/v2/spaces/{id}/pages
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const auth = await checkSessionOrInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const {
|
||||
domain,
|
||||
accessToken,
|
||||
spaceId,
|
||||
cloudId: providedCloudId,
|
||||
limit = 50,
|
||||
status,
|
||||
bodyFormat,
|
||||
cursor,
|
||||
} = body
|
||||
|
||||
if (!domain) {
|
||||
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!accessToken) {
|
||||
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!spaceId) {
|
||||
return NextResponse.json({ error: 'Space ID is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const spaceIdValidation = validateAlphanumericId(spaceId, 'spaceId', 255)
|
||||
if (!spaceIdValidation.isValid) {
|
||||
return NextResponse.json({ error: spaceIdValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken))
|
||||
|
||||
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
|
||||
if (!cloudIdValidation.isValid) {
|
||||
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
const queryParams = new URLSearchParams()
|
||||
queryParams.append('limit', String(Math.min(limit, 250)))
|
||||
|
||||
if (status) {
|
||||
queryParams.append('status', status)
|
||||
}
|
||||
|
||||
if (bodyFormat) {
|
||||
queryParams.append('body-format', bodyFormat)
|
||||
}
|
||||
|
||||
if (cursor) {
|
||||
queryParams.append('cursor', cursor)
|
||||
}
|
||||
|
||||
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/spaces/${spaceId}/pages?${queryParams.toString()}`
|
||||
|
||||
logger.info(`Fetching pages in space ${spaceId}`)
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => null)
|
||||
logger.error('Confluence API error response:', {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
error: JSON.stringify(errorData, null, 2),
|
||||
})
|
||||
const errorMessage =
|
||||
errorData?.message || `Failed to list pages in space (${response.status})`
|
||||
return NextResponse.json({ error: errorMessage }, { status: response.status })
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
const pages = (data.results || []).map((page: any) => ({
|
||||
id: page.id,
|
||||
title: page.title,
|
||||
status: page.status ?? null,
|
||||
spaceId: page.spaceId ?? null,
|
||||
parentId: page.parentId ?? null,
|
||||
authorId: page.authorId ?? null,
|
||||
createdAt: page.createdAt ?? null,
|
||||
version: page.version ?? null,
|
||||
body: page.body ?? null,
|
||||
webUrl: page._links?.webui ?? null,
|
||||
}))
|
||||
|
||||
return NextResponse.json({
|
||||
pages,
|
||||
nextCursor: data._links?.next
|
||||
? new URL(data._links.next, 'https://placeholder').searchParams.get('cursor')
|
||||
: null,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error listing pages in space:', error)
|
||||
return NextResponse.json(
|
||||
{ error: (error as Error).message || 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ export async function GET(request: NextRequest) {
|
||||
const accessToken = searchParams.get('accessToken')
|
||||
const providedCloudId = searchParams.get('cloudId')
|
||||
const limit = searchParams.get('limit') || '25'
|
||||
const cursor = searchParams.get('cursor')
|
||||
|
||||
if (!domain) {
|
||||
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
|
||||
@@ -37,7 +38,12 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/spaces?limit=${limit}`
|
||||
const queryParams = new URLSearchParams()
|
||||
queryParams.append('limit', String(Math.min(Number(limit), 250)))
|
||||
if (cursor) {
|
||||
queryParams.append('cursor', cursor)
|
||||
}
|
||||
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/spaces?${queryParams.toString()}`
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
@@ -67,9 +73,18 @@ export async function GET(request: NextRequest) {
|
||||
key: space.key,
|
||||
type: space.type,
|
||||
status: space.status,
|
||||
authorId: space.authorId ?? null,
|
||||
createdAt: space.createdAt ?? null,
|
||||
homepageId: space.homepageId ?? null,
|
||||
description: space.description ?? null,
|
||||
}))
|
||||
|
||||
return NextResponse.json({ spaces })
|
||||
return NextResponse.json({
|
||||
spaces,
|
||||
nextCursor: data._links?.next
|
||||
? new URL(data._links.next, 'https://placeholder').searchParams.get('cursor')
|
||||
: null,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error listing Confluence spaces:', error)
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { GithubIcon } from '@/components/icons'
|
||||
import { useBrandConfig } from '@/lib/branding/branding'
|
||||
import { inter } from '@/app/_styles/fonts/inter/inter'
|
||||
import { useBrandConfig } from '@/ee/whitelabeling'
|
||||
|
||||
interface ChatHeaderProps {
|
||||
chatConfig: {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import Image from 'next/image'
|
||||
import { useBrandConfig } from '@/lib/branding/branding'
|
||||
import { inter } from '@/app/_styles/fonts/inter/inter'
|
||||
import { useBrandConfig } from '@/ee/whitelabeling'
|
||||
|
||||
export function PoweredBySim() {
|
||||
const brandConfig = useBrandConfig()
|
||||
|
||||
@@ -2,9 +2,12 @@ import type { Metadata, Viewport } from 'next'
|
||||
import Script from 'next/script'
|
||||
import { PublicEnvScript } from 'next-runtime-env'
|
||||
import { BrandedLayout } from '@/components/branded-layout'
|
||||
import { generateThemeCSS } from '@/lib/branding/inject-theme'
|
||||
import { generateBrandedMetadata, generateStructuredData } from '@/lib/branding/metadata'
|
||||
import { PostHogProvider } from '@/app/_shell/providers/posthog-provider'
|
||||
import {
|
||||
generateBrandedMetadata,
|
||||
generateStructuredData,
|
||||
generateThemeCSS,
|
||||
} from '@/ee/whitelabeling'
|
||||
import '@/app/_styles/globals.css'
|
||||
import { OneDollarStats } from '@/components/analytics/onedollarstats'
|
||||
import { isReactGrabEnabled, isReactScanEnabled } from '@/lib/core/config/feature-flags'
|
||||
|
||||
@@ -56,7 +56,7 @@ An execution is a single run of a workflow. It includes:
|
||||
### LLM Orchestration
|
||||
Sim supports all major LLM providers:
|
||||
- OpenAI (GPT-5.2, GPT-5.1, GPT-5, GPT-4o, GPT-4.1)
|
||||
- Anthropic (Claude Opus 4.5, Claude Opus 4.1, Claude Sonnet 4.5, Claude Haiku 4.5)
|
||||
- Anthropic (Claude Opus 4.6, Claude Opus 4.5, Claude Sonnet 4.5, Claude Haiku 4.5)
|
||||
- Google (Gemini Pro 3, Gemini Pro 3 Preview, Gemini 2.5 Pro, Gemini 2.5 Flash)
|
||||
- Mistral (Mistral Large, Mistral Medium)
|
||||
- xAI (Grok)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { MetadataRoute } from 'next'
|
||||
import { getBrandConfig } from '@/lib/branding/branding'
|
||||
import { getBrandConfig } from '@/ee/whitelabeling'
|
||||
|
||||
export default function manifest(): MetadataRoute.Manifest {
|
||||
const brand = getBrandConfig()
|
||||
|
||||
@@ -24,8 +24,8 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { useBrandConfig } from '@/lib/branding/branding'
|
||||
import Nav from '@/app/(landing)/components/nav/nav'
|
||||
import { useBrandConfig } from '@/ee/whitelabeling'
|
||||
import type { ResumeStatus } from '@/executor/types'
|
||||
|
||||
interface ResumeLinks {
|
||||
|
||||
@@ -74,6 +74,12 @@ const SCOPE_DESCRIPTIONS: Record<string, string> = {
|
||||
'write:label:confluence': 'Add and remove labels',
|
||||
'search:confluence': 'Search Confluence content',
|
||||
'readonly:content.attachment:confluence': 'View attachments',
|
||||
'read:blogpost:confluence': 'View Confluence blog posts',
|
||||
'write:blogpost:confluence': 'Create and update Confluence blog posts',
|
||||
'read:content.property:confluence': 'View properties on Confluence content',
|
||||
'write:content.property:confluence': 'Create and manage content properties',
|
||||
'read:hierarchical-content:confluence': 'View page hierarchy (children and ancestors)',
|
||||
'read:content.metadata:confluence': 'View content metadata (required for ancestors)',
|
||||
'read:me': 'Read profile information',
|
||||
'database.read': 'Read database',
|
||||
'database.write': 'Write to database',
|
||||
@@ -358,6 +364,7 @@ export function OAuthRequiredModal({
|
||||
logger.info('Linking OAuth2:', {
|
||||
providerId,
|
||||
requiredScopes,
|
||||
hasNewScopes: newScopes.length > 0,
|
||||
})
|
||||
|
||||
if (providerId === 'trello') {
|
||||
|
||||
@@ -34,6 +34,103 @@ interface UploadedFile {
|
||||
type: string
|
||||
}
|
||||
|
||||
interface SingleFileSelectorProps {
|
||||
file: UploadedFile
|
||||
options: Array<{ label: string; value: string; disabled?: boolean }>
|
||||
selectedValue: string
|
||||
inputValue: string
|
||||
onInputChange: (value: string) => void
|
||||
onClear: (e: React.MouseEvent) => void
|
||||
onOpenChange: (open: boolean) => void
|
||||
disabled: boolean
|
||||
isLoading: boolean
|
||||
formatFileSize: (bytes: number) => string
|
||||
truncateMiddle: (text: string, start?: number, end?: number) => string
|
||||
isDeleting: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Single file selector component that shows the selected file with both
|
||||
* a clear button (X) and a chevron to change the selection.
|
||||
* Follows the same pattern as SelectorCombobox for consistency.
|
||||
*/
|
||||
function SingleFileSelector({
|
||||
file,
|
||||
options,
|
||||
selectedValue,
|
||||
inputValue,
|
||||
onInputChange,
|
||||
onClear,
|
||||
onOpenChange,
|
||||
disabled,
|
||||
isLoading,
|
||||
formatFileSize,
|
||||
truncateMiddle,
|
||||
isDeleting,
|
||||
}: SingleFileSelectorProps) {
|
||||
const displayLabel = `${truncateMiddle(file.name, 20, 12)} (${formatFileSize(file.size)})`
|
||||
const [localInputValue, setLocalInputValue] = useState(displayLabel)
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
|
||||
// Sync display label when file changes
|
||||
useEffect(() => {
|
||||
if (!isEditing) {
|
||||
setLocalInputValue(displayLabel)
|
||||
}
|
||||
}, [displayLabel, isEditing])
|
||||
|
||||
return (
|
||||
<div className='relative w-full'>
|
||||
<Combobox
|
||||
options={options}
|
||||
value={localInputValue}
|
||||
selectedValue={selectedValue}
|
||||
onChange={(newValue) => {
|
||||
// Check if user selected an option
|
||||
const matched = options.find((opt) => opt.value === newValue || opt.label === newValue)
|
||||
if (matched) {
|
||||
setIsEditing(false)
|
||||
setLocalInputValue(displayLabel)
|
||||
onInputChange(matched.value)
|
||||
return
|
||||
}
|
||||
// User is typing to search
|
||||
setIsEditing(true)
|
||||
setLocalInputValue(newValue)
|
||||
}}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setIsEditing(false)
|
||||
setLocalInputValue(displayLabel)
|
||||
}
|
||||
onOpenChange(open)
|
||||
}}
|
||||
placeholder={isLoading ? 'Loading files...' : 'Select or upload file'}
|
||||
disabled={disabled || isDeleting}
|
||||
editable={true}
|
||||
filterOptions={isEditing}
|
||||
isLoading={isLoading}
|
||||
inputProps={{
|
||||
className: 'pr-[60px]',
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
className='-translate-y-1/2 absolute top-1/2 right-[28px] z-10 h-6 w-6 p-0'
|
||||
onClick={onClear}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? (
|
||||
<div className='h-4 w-4 animate-spin rounded-full border-[1.5px] border-current border-t-transparent' />
|
||||
) : (
|
||||
<X className='h-4 w-4 opacity-50 hover:opacity-100' />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface UploadingFile {
|
||||
id: string
|
||||
name: string
|
||||
@@ -500,6 +597,7 @@ export function FileUpload({
|
||||
const hasFiles = filesArray.length > 0
|
||||
const isUploading = uploadingFiles.length > 0
|
||||
|
||||
// Options for multiple file mode (filters out already selected files)
|
||||
const comboboxOptions = useMemo(
|
||||
() => [
|
||||
{ label: 'Upload New File', value: '__upload_new__' },
|
||||
@@ -516,10 +614,43 @@ export function FileUpload({
|
||||
[availableWorkspaceFiles, acceptedTypes]
|
||||
)
|
||||
|
||||
// Options for single file mode (includes all files, selected one will be highlighted)
|
||||
const singleFileOptions = useMemo(
|
||||
() => [
|
||||
{ label: 'Upload New File', value: '__upload_new__' },
|
||||
...workspaceFiles.map((file) => {
|
||||
const isAccepted =
|
||||
!acceptedTypes || acceptedTypes === '*' || isFileTypeAccepted(file.type, acceptedTypes)
|
||||
return {
|
||||
label: file.name,
|
||||
value: file.id,
|
||||
disabled: !isAccepted,
|
||||
}
|
||||
}),
|
||||
],
|
||||
[workspaceFiles, acceptedTypes]
|
||||
)
|
||||
|
||||
// Find the selected file's workspace ID for highlighting in single file mode
|
||||
const selectedFileId = useMemo(() => {
|
||||
if (!hasFiles || multiple) return ''
|
||||
const currentFile = filesArray[0]
|
||||
if (!currentFile) return ''
|
||||
// Match by key or path
|
||||
const matchedWorkspaceFile = workspaceFiles.find(
|
||||
(wf) =>
|
||||
wf.key === currentFile.key ||
|
||||
wf.name === currentFile.name ||
|
||||
currentFile.path?.includes(wf.key)
|
||||
)
|
||||
return matchedWorkspaceFile?.id || ''
|
||||
}, [filesArray, workspaceFiles, hasFiles, multiple])
|
||||
|
||||
const handleComboboxChange = (value: string) => {
|
||||
setInputValue(value)
|
||||
|
||||
const selectedFile = availableWorkspaceFiles.find((file) => file.id === value)
|
||||
// Look in full workspaceFiles list (not filtered) to allow re-selecting same file in single mode
|
||||
const selectedFile = workspaceFiles.find((file) => file.id === value)
|
||||
const isAcceptedType =
|
||||
selectedFile &&
|
||||
(!acceptedTypes ||
|
||||
@@ -559,16 +690,17 @@ export function FileUpload({
|
||||
{/* Error message */}
|
||||
{uploadError && <div className='mb-2 text-red-600 text-sm'>{uploadError}</div>}
|
||||
|
||||
{/* File list with consistent spacing */}
|
||||
{(hasFiles || isUploading) && (
|
||||
{/* File list with consistent spacing - only show for multiple mode or when uploading */}
|
||||
{((hasFiles && multiple) || isUploading) && (
|
||||
<div className={cn('space-y-2', multiple && 'mb-2')}>
|
||||
{/* Only show files that aren't currently uploading */}
|
||||
{filesArray.map((file) => {
|
||||
const isCurrentlyUploading = uploadingFiles.some(
|
||||
(uploadingFile) => uploadingFile.name === file.name
|
||||
)
|
||||
return !isCurrentlyUploading && renderFileItem(file)
|
||||
})}
|
||||
{/* Only show files that aren't currently uploading (for multiple mode only) */}
|
||||
{multiple &&
|
||||
filesArray.map((file) => {
|
||||
const isCurrentlyUploading = uploadingFiles.some(
|
||||
(uploadingFile) => uploadingFile.name === file.name
|
||||
)
|
||||
return !isCurrentlyUploading && renderFileItem(file)
|
||||
})}
|
||||
{isUploading && (
|
||||
<>
|
||||
{uploadingFiles.map(renderUploadingItem)}
|
||||
@@ -604,6 +736,26 @@ export function FileUpload({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Single file mode with file selected: show combobox-style UI with X and chevron */}
|
||||
{hasFiles && !multiple && !isUploading && (
|
||||
<SingleFileSelector
|
||||
file={filesArray[0]}
|
||||
options={singleFileOptions}
|
||||
selectedValue={selectedFileId}
|
||||
inputValue={inputValue}
|
||||
onInputChange={handleComboboxChange}
|
||||
onClear={(e) => handleRemoveFile(filesArray[0], e)}
|
||||
onOpenChange={(open) => {
|
||||
if (open) void loadWorkspaceFiles()
|
||||
}}
|
||||
disabled={disabled}
|
||||
isLoading={loadingWorkspaceFiles}
|
||||
formatFileSize={formatFileSize}
|
||||
truncateMiddle={truncateMiddle}
|
||||
isDeleting={deletingFiles[filesArray[0]?.path || '']}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Show dropdown selector if no files and not uploading */}
|
||||
{!hasFiles && !isUploading && (
|
||||
<Combobox
|
||||
|
||||
@@ -24,6 +24,7 @@ export { ResponseFormat } from './response/response-format'
|
||||
export { ScheduleInfo } from './schedule-info/schedule-info'
|
||||
export { SheetSelectorInput } from './sheet-selector/sheet-selector-input'
|
||||
export { ShortInput } from './short-input/short-input'
|
||||
export { SkillInput } from './skill-input/skill-input'
|
||||
export { SlackSelectorInput } from './slack-selector/slack-selector-input'
|
||||
export { SliderInput } from './slider-input/slider-input'
|
||||
export { InputFormat } from './starter/input-format'
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type React from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Combobox as EditableCombobox } from '@/components/emcn/components'
|
||||
import { X } from 'lucide-react'
|
||||
import { Button, Combobox as EditableCombobox } from '@/components/emcn/components'
|
||||
import { SubBlockInputController } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/sub-block-input-controller'
|
||||
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
@@ -108,6 +109,20 @@ export function SelectorCombobox({
|
||||
[setStoreValue, onOptionChange, readOnly, disabled]
|
||||
)
|
||||
|
||||
const handleClear = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (readOnly || disabled) return
|
||||
setStoreValue(null)
|
||||
setInputValue('')
|
||||
onOptionChange?.('')
|
||||
},
|
||||
[setStoreValue, onOptionChange, readOnly, disabled]
|
||||
)
|
||||
|
||||
const showClearButton = Boolean(activeValue) && !disabled && !readOnly
|
||||
|
||||
return (
|
||||
<div className='w-full'>
|
||||
<SubBlockInputController
|
||||
@@ -119,36 +134,49 @@ export function SelectorCombobox({
|
||||
isPreview={isPreview}
|
||||
>
|
||||
{({ ref, onDrop, onDragOver }) => (
|
||||
<EditableCombobox
|
||||
options={comboboxOptions}
|
||||
value={allowSearch ? inputValue : selectedLabel}
|
||||
selectedValue={activeValue ?? ''}
|
||||
onChange={(newValue) => {
|
||||
const matched = optionMap.get(newValue)
|
||||
if (matched) {
|
||||
setInputValue(matched.label)
|
||||
setIsEditing(false)
|
||||
handleSelection(matched.id)
|
||||
return
|
||||
}
|
||||
if (allowSearch) {
|
||||
setInputValue(newValue)
|
||||
setIsEditing(true)
|
||||
setSearchTerm(newValue)
|
||||
}
|
||||
}}
|
||||
placeholder={placeholder || subBlock.placeholder || 'Select an option'}
|
||||
disabled={disabled || readOnly}
|
||||
editable={allowSearch}
|
||||
filterOptions={allowSearch}
|
||||
inputRef={ref as React.RefObject<HTMLInputElement>}
|
||||
inputProps={{
|
||||
onDrop: onDrop as (e: React.DragEvent<HTMLInputElement>) => void,
|
||||
onDragOver: onDragOver as (e: React.DragEvent<HTMLInputElement>) => void,
|
||||
}}
|
||||
isLoading={isLoading}
|
||||
error={error instanceof Error ? error.message : null}
|
||||
/>
|
||||
<div className='relative w-full'>
|
||||
<EditableCombobox
|
||||
options={comboboxOptions}
|
||||
value={allowSearch ? inputValue : selectedLabel}
|
||||
selectedValue={activeValue ?? ''}
|
||||
onChange={(newValue) => {
|
||||
const matched = optionMap.get(newValue)
|
||||
if (matched) {
|
||||
setInputValue(matched.label)
|
||||
setIsEditing(false)
|
||||
handleSelection(matched.id)
|
||||
return
|
||||
}
|
||||
if (allowSearch) {
|
||||
setInputValue(newValue)
|
||||
setIsEditing(true)
|
||||
setSearchTerm(newValue)
|
||||
}
|
||||
}}
|
||||
placeholder={placeholder || subBlock.placeholder || 'Select an option'}
|
||||
disabled={disabled || readOnly}
|
||||
editable={allowSearch}
|
||||
filterOptions={allowSearch}
|
||||
inputRef={ref as React.RefObject<HTMLInputElement>}
|
||||
inputProps={{
|
||||
onDrop: onDrop as (e: React.DragEvent<HTMLInputElement>) => void,
|
||||
onDragOver: onDragOver as (e: React.DragEvent<HTMLInputElement>) => void,
|
||||
className: showClearButton ? 'pr-[60px]' : undefined,
|
||||
}}
|
||||
isLoading={isLoading}
|
||||
error={error instanceof Error ? error.message : null}
|
||||
/>
|
||||
{showClearButton && (
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
className='-translate-y-1/2 absolute top-1/2 right-[28px] z-10 h-6 w-6 p-0'
|
||||
onClick={handleClear}
|
||||
>
|
||||
<X className='h-4 w-4 opacity-50 hover:opacity-100' />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</SubBlockInputController>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { Plus, XIcon } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { Combobox, type ComboboxOptionGroup } from '@/components/emcn'
|
||||
import { AgentSkillsIcon } from '@/components/icons'
|
||||
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
|
||||
import { SkillModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/skills/components/skill-modal'
|
||||
import type { SkillDefinition } from '@/hooks/queries/skills'
|
||||
import { useSkills } from '@/hooks/queries/skills'
|
||||
import { usePermissionConfig } from '@/hooks/use-permission-config'
|
||||
|
||||
interface StoredSkill {
|
||||
skillId: string
|
||||
name?: string
|
||||
}
|
||||
|
||||
interface SkillInputProps {
|
||||
blockId: string
|
||||
subBlockId: string
|
||||
isPreview?: boolean
|
||||
previewValue?: unknown
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function SkillInput({
|
||||
blockId,
|
||||
subBlockId,
|
||||
isPreview,
|
||||
previewValue,
|
||||
disabled,
|
||||
}: SkillInputProps) {
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
|
||||
const { config: permissionConfig } = usePermissionConfig()
|
||||
const { data: workspaceSkills = [] } = useSkills(workspaceId)
|
||||
const [value, setValue] = useSubBlockValue<StoredSkill[]>(blockId, subBlockId)
|
||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||
const [editingSkill, setEditingSkill] = useState<SkillDefinition | null>(null)
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const selectedSkills: StoredSkill[] = useMemo(() => {
|
||||
if (isPreview && previewValue) {
|
||||
return Array.isArray(previewValue) ? previewValue : []
|
||||
}
|
||||
return Array.isArray(value) ? value : []
|
||||
}, [isPreview, previewValue, value])
|
||||
|
||||
const selectedIds = useMemo(() => new Set(selectedSkills.map((s) => s.skillId)), [selectedSkills])
|
||||
|
||||
const skillsDisabled = permissionConfig.disableSkills
|
||||
|
||||
const skillGroups = useMemo((): ComboboxOptionGroup[] => {
|
||||
const groups: ComboboxOptionGroup[] = []
|
||||
|
||||
if (!skillsDisabled) {
|
||||
groups.push({
|
||||
items: [
|
||||
{
|
||||
label: 'Create Skill',
|
||||
value: 'action-create-skill',
|
||||
icon: Plus,
|
||||
onSelect: () => {
|
||||
setShowCreateModal(true)
|
||||
setOpen(false)
|
||||
},
|
||||
disabled: isPreview,
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
const availableSkills = workspaceSkills.filter((s) => !selectedIds.has(s.id))
|
||||
if (availableSkills.length > 0) {
|
||||
groups.push({
|
||||
section: 'Skills',
|
||||
items: availableSkills.map((s) => {
|
||||
return {
|
||||
label: s.name,
|
||||
value: `skill-${s.id}`,
|
||||
icon: AgentSkillsIcon,
|
||||
onSelect: () => {
|
||||
const newSkills: StoredSkill[] = [...selectedSkills, { skillId: s.id, name: s.name }]
|
||||
setValue(newSkills)
|
||||
setOpen(false)
|
||||
},
|
||||
}
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
return groups
|
||||
}, [workspaceSkills, selectedIds, selectedSkills, setValue, isPreview, skillsDisabled])
|
||||
|
||||
const handleRemove = useCallback(
|
||||
(skillId: string) => {
|
||||
const newSkills = selectedSkills.filter((s) => s.skillId !== skillId)
|
||||
setValue(newSkills)
|
||||
},
|
||||
[selectedSkills, setValue]
|
||||
)
|
||||
|
||||
const handleSkillSaved = useCallback(() => {
|
||||
setShowCreateModal(false)
|
||||
setEditingSkill(null)
|
||||
}, [])
|
||||
|
||||
const resolveSkillName = useCallback(
|
||||
(stored: StoredSkill): string => {
|
||||
const found = workspaceSkills.find((s) => s.id === stored.skillId)
|
||||
return found?.name ?? stored.name ?? stored.skillId
|
||||
},
|
||||
[workspaceSkills]
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='w-full space-y-[8px]'>
|
||||
<Combobox
|
||||
options={[]}
|
||||
groups={skillGroups}
|
||||
placeholder='Add skill...'
|
||||
disabled={disabled}
|
||||
searchable
|
||||
searchPlaceholder='Search skills...'
|
||||
maxHeight={240}
|
||||
emptyMessage='No skills found'
|
||||
onOpenChange={setOpen}
|
||||
/>
|
||||
|
||||
{selectedSkills.length > 0 && (
|
||||
<div className='flex flex-wrap gap-[4px]'>
|
||||
{selectedSkills.map((stored) => {
|
||||
const fullSkill = workspaceSkills.find((s) => s.id === stored.skillId)
|
||||
return (
|
||||
<div
|
||||
key={stored.skillId}
|
||||
className='flex cursor-pointer items-center gap-[4px] rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-5)] px-[6px] py-[2px] font-medium text-[12px] text-[var(--text-secondary)] hover:bg-[var(--surface-6)]'
|
||||
onClick={() => {
|
||||
if (fullSkill && !disabled && !isPreview) {
|
||||
setEditingSkill(fullSkill)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<AgentSkillsIcon className='h-[10px] w-[10px] text-[var(--text-tertiary)]' />
|
||||
<span className='max-w-[140px] truncate'>{resolveSkillName(stored)}</span>
|
||||
{!disabled && !isPreview && (
|
||||
<button
|
||||
type='button'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleRemove(stored.skillId)
|
||||
}}
|
||||
className='ml-[2px] rounded-[2px] p-[1px] text-[var(--text-tertiary)] hover:bg-[var(--surface-7)] hover:text-[var(--text-secondary)]'
|
||||
>
|
||||
<XIcon className='h-[10px] w-[10px]' />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SkillModal
|
||||
open={showCreateModal || !!editingSkill}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) {
|
||||
setShowCreateModal(false)
|
||||
setEditingSkill(null)
|
||||
}
|
||||
}}
|
||||
onSave={handleSkillSaved}
|
||||
initialValues={editingSkill ?? undefined}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
ScheduleInfo,
|
||||
SheetSelectorInput,
|
||||
ShortInput,
|
||||
SkillInput,
|
||||
SlackSelectorInput,
|
||||
SliderInput,
|
||||
Switch,
|
||||
@@ -687,6 +688,17 @@ function SubBlockComponent({
|
||||
/>
|
||||
)
|
||||
|
||||
case 'skill-input':
|
||||
return (
|
||||
<SkillInput
|
||||
blockId={blockId}
|
||||
subBlockId={config.id}
|
||||
isPreview={isPreview}
|
||||
previewValue={previewValue}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'checkbox-list':
|
||||
return (
|
||||
<CheckboxList
|
||||
|
||||
@@ -100,7 +100,7 @@ const BlockRow = memo(function BlockRow({
|
||||
>
|
||||
<div className='flex min-w-0 flex-1 items-center gap-[8px]'>
|
||||
<div
|
||||
className='relative flex h-[14px] w-[14px] flex-shrink-0 items-center justify-center overflow-hidden rounded-[4px]'
|
||||
className='flex h-[14px] w-[14px] flex-shrink-0 items-center justify-center rounded-[4px]'
|
||||
style={{ background: bgColor }}
|
||||
>
|
||||
{BlockIcon && <BlockIcon className='h-[9px] w-[9px] text-white' />}
|
||||
@@ -276,7 +276,7 @@ const SubflowNodeRow = memo(function SubflowNodeRow({
|
||||
>
|
||||
<div className='flex min-w-0 flex-1 items-center gap-[8px]'>
|
||||
<div
|
||||
className='relative flex h-[14px] w-[14px] flex-shrink-0 items-center justify-center overflow-hidden rounded-[4px]'
|
||||
className='flex h-[14px] w-[14px] flex-shrink-0 items-center justify-center rounded-[4px]'
|
||||
style={{ background: bgColor }}
|
||||
>
|
||||
{BlockIcon && <BlockIcon className='h-[9px] w-[9px] text-white' />}
|
||||
|
||||
@@ -1288,6 +1288,13 @@ export function useWorkflowExecution() {
|
||||
onBlockCompleteCallback: onBlockComplete,
|
||||
})
|
||||
|
||||
const clientWorkflowState = executionWorkflowState || {
|
||||
blocks: filteredStates,
|
||||
edges: workflowEdges,
|
||||
loops: latestWorkflowState.loops,
|
||||
parallels: latestWorkflowState.parallels,
|
||||
}
|
||||
|
||||
await executionStream.execute({
|
||||
workflowId: activeWorkflowId,
|
||||
input: finalWorkflowInput,
|
||||
@@ -1297,14 +1304,12 @@ export function useWorkflowExecution() {
|
||||
useDraftState: true,
|
||||
isClientSession: true,
|
||||
stopAfterBlockId,
|
||||
workflowStateOverride: executionWorkflowState
|
||||
? {
|
||||
blocks: executionWorkflowState.blocks,
|
||||
edges: executionWorkflowState.edges,
|
||||
loops: executionWorkflowState.loops,
|
||||
parallels: executionWorkflowState.parallels,
|
||||
}
|
||||
: undefined,
|
||||
workflowStateOverride: {
|
||||
blocks: clientWorkflowState.blocks,
|
||||
edges: clientWorkflowState.edges,
|
||||
loops: clientWorkflowState.loops,
|
||||
parallels: clientWorkflowState.parallels,
|
||||
},
|
||||
callbacks: {
|
||||
onExecutionStarted: (data) => {
|
||||
logger.info('Server execution started:', data)
|
||||
|
||||
@@ -19,11 +19,11 @@ import {
|
||||
import { Input, Skeleton } from '@/components/ui'
|
||||
import { signOut, useSession } from '@/lib/auth/auth-client'
|
||||
import { ANONYMOUS_USER_ID } from '@/lib/auth/constants'
|
||||
import { useBrandConfig } from '@/lib/branding/branding'
|
||||
import { getEnv, isTruthy } from '@/lib/core/config/env'
|
||||
import { isHosted } from '@/lib/core/config/feature-flags'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { useProfilePictureUpload } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/hooks/use-profile-picture-upload'
|
||||
import { useBrandConfig } from '@/ee/whitelabeling'
|
||||
import { useGeneralSettings, useUpdateGeneralSetting } from '@/hooks/queries/general-settings'
|
||||
import { useUpdateUserProfile, useUserProfile } from '@/hooks/queries/user-profile'
|
||||
import { clearUserData } from '@/stores'
|
||||
|
||||
@@ -9,6 +9,7 @@ export { Files as FileUploads } from './files/files'
|
||||
export { General } from './general/general'
|
||||
export { Integrations } from './integrations/integrations'
|
||||
export { MCP } from './mcp/mcp'
|
||||
export { Skills } from './skills/skills'
|
||||
export { Subscription } from './subscription/subscription'
|
||||
export { TeamManagement } from './team-management/team-management'
|
||||
export { WorkflowMcpServers } from './workflow-mcp-servers/workflow-mcp-servers'
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
'use client'
|
||||
|
||||
import type { ChangeEvent } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Label,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
Textarea,
|
||||
} from '@/components/emcn'
|
||||
import type { SkillDefinition } from '@/hooks/queries/skills'
|
||||
import { useCreateSkill, useUpdateSkill } from '@/hooks/queries/skills'
|
||||
|
||||
interface SkillModalProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSave: () => void
|
||||
onDelete?: (skillId: string) => void
|
||||
initialValues?: SkillDefinition
|
||||
}
|
||||
|
||||
const KEBAB_CASE_REGEX = /^[a-z0-9]+(-[a-z0-9]+)*$/
|
||||
|
||||
export function SkillModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSave,
|
||||
onDelete,
|
||||
initialValues,
|
||||
}: SkillModalProps) {
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
|
||||
const createSkill = useCreateSkill()
|
||||
const updateSkill = useUpdateSkill()
|
||||
|
||||
const [name, setName] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [content, setContent] = useState('')
|
||||
const [formError, setFormError] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
if (initialValues) {
|
||||
setName(initialValues.name)
|
||||
setDescription(initialValues.description)
|
||||
setContent(initialValues.content)
|
||||
} else {
|
||||
setName('')
|
||||
setDescription('')
|
||||
setContent('')
|
||||
}
|
||||
setFormError('')
|
||||
}
|
||||
}, [open, initialValues])
|
||||
|
||||
const hasChanges = useMemo(() => {
|
||||
if (!initialValues) return true
|
||||
return (
|
||||
name !== initialValues.name ||
|
||||
description !== initialValues.description ||
|
||||
content !== initialValues.content
|
||||
)
|
||||
}, [name, description, content, initialValues])
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!name.trim()) {
|
||||
setFormError('Name is required')
|
||||
return
|
||||
}
|
||||
if (name.length > 64) {
|
||||
setFormError('Name must be 64 characters or less')
|
||||
return
|
||||
}
|
||||
if (!KEBAB_CASE_REGEX.test(name)) {
|
||||
setFormError('Name must be kebab-case (e.g. my-skill)')
|
||||
return
|
||||
}
|
||||
if (!description.trim()) {
|
||||
setFormError('Description is required')
|
||||
return
|
||||
}
|
||||
if (!content.trim()) {
|
||||
setFormError('Content is required')
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
|
||||
try {
|
||||
if (initialValues) {
|
||||
await updateSkill.mutateAsync({
|
||||
workspaceId,
|
||||
skillId: initialValues.id,
|
||||
updates: { name, description, content },
|
||||
})
|
||||
} else {
|
||||
await createSkill.mutateAsync({
|
||||
workspaceId,
|
||||
skill: { name, description, content },
|
||||
})
|
||||
}
|
||||
onSave()
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error && error.message.includes('already exists')
|
||||
? error.message
|
||||
: 'Failed to save skill. Please try again.'
|
||||
setFormError(message)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal open={open} onOpenChange={onOpenChange}>
|
||||
<ModalContent size='xl'>
|
||||
<ModalHeader>{initialValues ? 'Edit Skill' : 'Create Skill'}</ModalHeader>
|
||||
<ModalBody>
|
||||
<div className='flex flex-col gap-[16px]'>
|
||||
<div className='flex flex-col gap-[4px]'>
|
||||
<Label htmlFor='skill-name' className='font-medium text-[13px]'>
|
||||
Name
|
||||
</Label>
|
||||
<Input
|
||||
id='skill-name'
|
||||
placeholder='my-skill-name'
|
||||
value={name}
|
||||
onChange={(e) => {
|
||||
setName(e.target.value)
|
||||
if (formError) setFormError('')
|
||||
}}
|
||||
/>
|
||||
<span className='text-[11px] text-[var(--text-muted)]'>
|
||||
Lowercase letters, numbers, and hyphens (e.g. my-skill)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-[4px]'>
|
||||
<Label htmlFor='skill-description' className='font-medium text-[13px]'>
|
||||
Description
|
||||
</Label>
|
||||
<Input
|
||||
id='skill-description'
|
||||
placeholder='What this skill does and when to use it...'
|
||||
value={description}
|
||||
onChange={(e) => {
|
||||
setDescription(e.target.value)
|
||||
if (formError) setFormError('')
|
||||
}}
|
||||
maxLength={1024}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-[4px]'>
|
||||
<Label htmlFor='skill-content' className='font-medium text-[13px]'>
|
||||
Content
|
||||
</Label>
|
||||
<Textarea
|
||||
id='skill-content'
|
||||
placeholder='Skill instructions in markdown...'
|
||||
value={content}
|
||||
onChange={(e: ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setContent(e.target.value)
|
||||
if (formError) setFormError('')
|
||||
}}
|
||||
className='min-h-[200px] resize-y font-mono text-[13px]'
|
||||
/>
|
||||
</div>
|
||||
|
||||
{formError && <span className='text-[11px] text-[var(--text-error)]'>{formError}</span>}
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter className='items-center justify-between'>
|
||||
{initialValues && onDelete ? (
|
||||
<Button variant='destructive' onClick={() => onDelete(initialValues.id)}>
|
||||
Delete
|
||||
</Button>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
<div className='flex gap-2'>
|
||||
<Button variant='default' onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant='tertiary' onClick={handleSave} disabled={saving || !hasChanges}>
|
||||
{saving ? 'Saving...' : initialValues ? 'Update' : 'Create'}
|
||||
</Button>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { Plus, Search } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
} from '@/components/emcn'
|
||||
import { Skeleton } from '@/components/ui'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { SkillModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/skills/components/skill-modal'
|
||||
import type { SkillDefinition } from '@/hooks/queries/skills'
|
||||
import { useDeleteSkill, useSkills } from '@/hooks/queries/skills'
|
||||
|
||||
const logger = createLogger('SkillsSettings')
|
||||
|
||||
function SkillSkeleton() {
|
||||
return (
|
||||
<div className='flex items-center justify-between gap-[12px]'>
|
||||
<div className='flex min-w-0 flex-col justify-center gap-[1px]'>
|
||||
<Skeleton className='h-[14px] w-[100px]' />
|
||||
<Skeleton className='h-[13px] w-[200px]' />
|
||||
</div>
|
||||
<div className='flex flex-shrink-0 items-center gap-[8px]'>
|
||||
<Skeleton className='h-[30px] w-[40px] rounded-[4px]' />
|
||||
<Skeleton className='h-[30px] w-[54px] rounded-[4px]' />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Skills() {
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
|
||||
const { data: skills = [], isLoading, error, refetch: refetchSkills } = useSkills(workspaceId)
|
||||
const deleteSkillMutation = useDeleteSkill()
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [deletingSkills, setDeletingSkills] = useState<Set<string>>(new Set())
|
||||
const [editingSkill, setEditingSkill] = useState<SkillDefinition | null>(null)
|
||||
const [showAddForm, setShowAddForm] = useState(false)
|
||||
const [skillToDelete, setSkillToDelete] = useState<{ id: string; name: string } | null>(null)
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||
|
||||
const filteredSkills = skills.filter((s) => {
|
||||
if (!searchTerm.trim()) return true
|
||||
const searchLower = searchTerm.toLowerCase()
|
||||
return (
|
||||
s.name.toLowerCase().includes(searchLower) ||
|
||||
s.description.toLowerCase().includes(searchLower)
|
||||
)
|
||||
})
|
||||
|
||||
const handleDeleteClick = (skillId: string) => {
|
||||
const s = skills.find((sk) => sk.id === skillId)
|
||||
if (!s) return
|
||||
|
||||
setSkillToDelete({ id: skillId, name: s.name })
|
||||
setShowDeleteDialog(true)
|
||||
}
|
||||
|
||||
const handleDeleteSkill = async () => {
|
||||
if (!skillToDelete) return
|
||||
|
||||
setDeletingSkills((prev) => new Set(prev).add(skillToDelete.id))
|
||||
setShowDeleteDialog(false)
|
||||
|
||||
try {
|
||||
await deleteSkillMutation.mutateAsync({
|
||||
workspaceId,
|
||||
skillId: skillToDelete.id,
|
||||
})
|
||||
logger.info(`Deleted skill: ${skillToDelete.id}`)
|
||||
} catch (error) {
|
||||
logger.error('Error deleting skill:', error)
|
||||
} finally {
|
||||
setDeletingSkills((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(skillToDelete.id)
|
||||
return next
|
||||
})
|
||||
setSkillToDelete(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSkillSaved = () => {
|
||||
setShowAddForm(false)
|
||||
setEditingSkill(null)
|
||||
refetchSkills()
|
||||
}
|
||||
|
||||
const hasSkills = skills && skills.length > 0
|
||||
const showEmptyState = !hasSkills && !showAddForm && !editingSkill
|
||||
const showNoResults = searchTerm.trim() && filteredSkills.length === 0 && skills.length > 0
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='flex h-full flex-col gap-[16px]'>
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-1 items-center gap-[8px] rounded-[8px] border border-[var(--border)] bg-transparent px-[8px] py-[5px] transition-colors duration-100 dark:bg-[var(--surface-4)] dark:hover:border-[var(--border-1)] dark:hover:bg-[var(--surface-5)]',
|
||||
isLoading && 'opacity-50'
|
||||
)}
|
||||
>
|
||||
<Search
|
||||
className='h-[14px] w-[14px] flex-shrink-0 text-[var(--text-tertiary)]'
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<Input
|
||||
placeholder='Search skills...'
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
disabled={isLoading}
|
||||
className='h-auto flex-1 border-0 bg-transparent p-0 font-base leading-none placeholder:text-[var(--text-tertiary)] focus-visible:ring-0 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-100'
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={() => setShowAddForm(true)} disabled={isLoading} variant='tertiary'>
|
||||
<Plus className='mr-[6px] h-[13px] w-[13px]' />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className='min-h-0 flex-1 overflow-y-auto'>
|
||||
{error ? (
|
||||
<div className='flex h-full flex-col items-center justify-center gap-[8px]'>
|
||||
<p className='text-[#DC2626] text-[11px] leading-tight dark:text-[#F87171]'>
|
||||
{error instanceof Error ? error.message : 'Failed to load skills'}
|
||||
</p>
|
||||
</div>
|
||||
) : isLoading ? (
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<SkillSkeleton />
|
||||
<SkillSkeleton />
|
||||
<SkillSkeleton />
|
||||
</div>
|
||||
) : showEmptyState ? (
|
||||
<div className='flex h-full items-center justify-center text-[13px] text-[var(--text-muted)]'>
|
||||
Click "Add" above to get started
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
{filteredSkills.map((s) => (
|
||||
<div key={s.id} className='flex items-center justify-between gap-[12px]'>
|
||||
<div className='flex min-w-0 flex-col justify-center gap-[1px]'>
|
||||
<span className='truncate font-medium text-[14px]'>{s.name}</span>
|
||||
<p className='truncate text-[13px] text-[var(--text-muted)]'>{s.description}</p>
|
||||
</div>
|
||||
<div className='flex flex-shrink-0 items-center gap-[8px]'>
|
||||
<Button variant='default' onClick={() => setEditingSkill(s)}>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={() => handleDeleteClick(s.id)}
|
||||
disabled={deletingSkills.has(s.id)}
|
||||
>
|
||||
{deletingSkills.has(s.id) ? 'Deleting...' : 'Delete'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{showNoResults && (
|
||||
<div className='py-[16px] text-center text-[13px] text-[var(--text-muted)]'>
|
||||
No skills found matching "{searchTerm}"
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SkillModal
|
||||
open={showAddForm || !!editingSkill}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setShowAddForm(false)
|
||||
setEditingSkill(null)
|
||||
}
|
||||
}}
|
||||
onSave={handleSkillSaved}
|
||||
onDelete={(skillId) => {
|
||||
setEditingSkill(null)
|
||||
handleDeleteClick(skillId)
|
||||
}}
|
||||
initialValues={editingSkill ?? undefined}
|
||||
/>
|
||||
|
||||
<Modal open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>Delete Skill</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className='text-[12px] text-[var(--text-secondary)]'>
|
||||
Are you sure you want to delete{' '}
|
||||
<span className='font-medium text-[var(--text-primary)]'>{skillToDelete?.name}</span>?{' '}
|
||||
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
|
||||
</p>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant='default' onClick={() => setShowDeleteDialog(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant='destructive' onClick={handleDeleteSkill}>
|
||||
Delete
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -34,7 +34,7 @@ import {
|
||||
SModalSidebarSection,
|
||||
SModalSidebarSectionTitle,
|
||||
} from '@/components/emcn'
|
||||
import { McpIcon } from '@/components/icons'
|
||||
import { AgentSkillsIcon, McpIcon } from '@/components/icons'
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
import { getSubscriptionStatus } from '@/lib/billing/client'
|
||||
import { getEnv, isTruthy } from '@/lib/core/config/env'
|
||||
@@ -52,6 +52,7 @@ import {
|
||||
General,
|
||||
Integrations,
|
||||
MCP,
|
||||
Skills,
|
||||
Subscription,
|
||||
TeamManagement,
|
||||
WorkflowMcpServers,
|
||||
@@ -93,6 +94,7 @@ type SettingsSection =
|
||||
| 'copilot'
|
||||
| 'mcp'
|
||||
| 'custom-tools'
|
||||
| 'skills'
|
||||
| 'workflow-mcp-servers'
|
||||
| 'debug'
|
||||
|
||||
@@ -156,6 +158,7 @@ const allNavigationItems: NavigationItem[] = [
|
||||
},
|
||||
{ id: 'integrations', label: 'Integrations', icon: Connections, section: 'tools' },
|
||||
{ id: 'custom-tools', label: 'Custom Tools', icon: Wrench, section: 'tools' },
|
||||
{ id: 'skills', label: 'Skills', icon: AgentSkillsIcon, section: 'tools' },
|
||||
{ id: 'mcp', label: 'MCP Tools', icon: McpIcon, section: 'tools' },
|
||||
{ id: 'environment', label: 'Environment', icon: FolderCode, section: 'system' },
|
||||
{ id: 'apikeys', label: 'API Keys', icon: Key, section: 'system' },
|
||||
@@ -265,6 +268,9 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
||||
if (item.id === 'custom-tools' && permissionConfig.disableCustomTools) {
|
||||
return false
|
||||
}
|
||||
if (item.id === 'skills' && permissionConfig.disableSkills) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Self-hosted override allows showing the item when not on hosted
|
||||
if (item.selfHostedOverride && !isHosted) {
|
||||
@@ -556,6 +562,7 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
||||
{effectiveActiveSection === 'copilot' && <Copilot />}
|
||||
{effectiveActiveSection === 'mcp' && <MCP initialServerId={pendingMcpServerId} />}
|
||||
{effectiveActiveSection === 'custom-tools' && <CustomTools />}
|
||||
{effectiveActiveSection === 'skills' && <Skills />}
|
||||
{effectiveActiveSection === 'workflow-mcp-servers' && <WorkflowMcpServers />}
|
||||
{effectiveActiveSection === 'debug' && <Debug />}
|
||||
</SModalMainBody>
|
||||
|
||||
@@ -397,7 +397,7 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) {
|
||||
return () => window.clearInterval(interval)
|
||||
}, [isHovered, pillCount, startAnimationIndex])
|
||||
|
||||
if (isLoading) {
|
||||
if (isLoading && !subscriptionData) {
|
||||
return (
|
||||
<div className='flex flex-shrink-0 flex-col gap-[8px] border-t px-[13.5px] pt-[8px] pb-[10px]'>
|
||||
<div className='flex h-[18px] items-center justify-between'>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// Use the real registry module, not the global mock from vitest.setup.ts
|
||||
vi.unmock('@/blocks/registry')
|
||||
|
||||
import { generateRouterPrompt } from '@/blocks/blocks/router'
|
||||
@@ -15,7 +14,7 @@ import {
|
||||
} from '@/blocks/registry'
|
||||
import { AuthMode } from '@/blocks/types'
|
||||
|
||||
describe('Blocks Module', () => {
|
||||
describe.concurrent('Blocks Module', () => {
|
||||
describe('Registry', () => {
|
||||
it('should have a non-empty registry of blocks', () => {
|
||||
expect(Object.keys(registry).length).toBeGreaterThan(0)
|
||||
@@ -409,6 +408,7 @@ describe('Blocks Module', () => {
|
||||
'workflow-input-mapper',
|
||||
'text',
|
||||
'router-input',
|
||||
'skill-input',
|
||||
]
|
||||
|
||||
const blocks = getAllBlocks()
|
||||
@@ -649,4 +649,394 @@ describe('Blocks Module', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('Canonical Param Validation', () => {
|
||||
/**
|
||||
* Helper to serialize a condition for comparison
|
||||
*/
|
||||
function serializeCondition(condition: unknown): string {
|
||||
if (!condition) return ''
|
||||
return JSON.stringify(condition)
|
||||
}
|
||||
|
||||
it('should not have canonicalParamId that matches any subBlock id within the same block', () => {
|
||||
const blocks = getAllBlocks()
|
||||
const errors: string[] = []
|
||||
|
||||
for (const block of blocks) {
|
||||
const allSubBlockIds = new Set(block.subBlocks.map((sb) => sb.id))
|
||||
const canonicalParamIds = new Set(
|
||||
block.subBlocks.filter((sb) => sb.canonicalParamId).map((sb) => sb.canonicalParamId)
|
||||
)
|
||||
|
||||
for (const canonicalId of canonicalParamIds) {
|
||||
if (allSubBlockIds.has(canonicalId!)) {
|
||||
// Check if the matching subBlock also has a canonicalParamId pointing to itself
|
||||
const matchingSubBlock = block.subBlocks.find(
|
||||
(sb) => sb.id === canonicalId && !sb.canonicalParamId
|
||||
)
|
||||
if (matchingSubBlock) {
|
||||
errors.push(
|
||||
`Block "${block.type}": canonicalParamId "${canonicalId}" clashes with subBlock id "${canonicalId}"`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
throw new Error(`Canonical param ID clashes detected:\n${errors.join('\n')}`)
|
||||
}
|
||||
})
|
||||
|
||||
it('should have unique subBlock IDs within the same condition context', () => {
|
||||
const blocks = getAllBlocks()
|
||||
const errors: string[] = []
|
||||
|
||||
for (const block of blocks) {
|
||||
// Group subBlocks by their condition (only for static/JSON conditions, not functions)
|
||||
const subBlocksByCondition = new Map<
|
||||
string,
|
||||
Array<{ id: string; mode?: string; hasCanonical: boolean }>
|
||||
>()
|
||||
|
||||
for (const subBlock of block.subBlocks) {
|
||||
// Skip subBlocks with function conditions - we can't evaluate them statically
|
||||
// These are valid when the function returns different conditions at runtime
|
||||
if (typeof subBlock.condition === 'function') {
|
||||
continue
|
||||
}
|
||||
|
||||
const conditionKey = serializeCondition(subBlock.condition)
|
||||
if (!subBlocksByCondition.has(conditionKey)) {
|
||||
subBlocksByCondition.set(conditionKey, [])
|
||||
}
|
||||
subBlocksByCondition.get(conditionKey)!.push({
|
||||
id: subBlock.id,
|
||||
mode: subBlock.mode,
|
||||
hasCanonical: Boolean(subBlock.canonicalParamId),
|
||||
})
|
||||
}
|
||||
|
||||
// Check for duplicate IDs within the same condition (excluding canonical pairs and mode swaps)
|
||||
for (const [conditionKey, subBlocks] of subBlocksByCondition) {
|
||||
const idCounts = new Map<string, number>()
|
||||
for (const sb of subBlocks) {
|
||||
idCounts.set(sb.id, (idCounts.get(sb.id) || 0) + 1)
|
||||
}
|
||||
|
||||
for (const [id, count] of idCounts) {
|
||||
if (count > 1) {
|
||||
const duplicates = subBlocks.filter((sb) => sb.id === id)
|
||||
|
||||
// Categorize modes
|
||||
const basicModes = duplicates.filter(
|
||||
(sb) => !sb.mode || sb.mode === 'basic' || sb.mode === 'both'
|
||||
)
|
||||
const advancedModes = duplicates.filter((sb) => sb.mode === 'advanced')
|
||||
const triggerModes = duplicates.filter((sb) => sb.mode === 'trigger')
|
||||
|
||||
// Valid pattern 1: basic/advanced mode swap (with or without canonicalParamId)
|
||||
if (
|
||||
basicModes.length === 1 &&
|
||||
advancedModes.length === 1 &&
|
||||
triggerModes.length === 0
|
||||
) {
|
||||
continue // This is a valid basic/advanced mode swap pair
|
||||
}
|
||||
|
||||
// Valid pattern 2: basic/trigger mode separation (trigger version for trigger mode)
|
||||
// One basic/both + one or more trigger versions is valid
|
||||
if (
|
||||
basicModes.length <= 1 &&
|
||||
advancedModes.length === 0 &&
|
||||
triggerModes.length >= 1
|
||||
) {
|
||||
continue // This is a valid pattern where trigger mode has its own subBlock
|
||||
}
|
||||
|
||||
// Valid pattern 3: All duplicates have canonicalParamId (they form a canonical group)
|
||||
const allHaveCanonical = duplicates.every((sb) => sb.hasCanonical)
|
||||
if (allHaveCanonical) {
|
||||
continue // Validated separately by canonical pair tests
|
||||
}
|
||||
|
||||
// Invalid: duplicates without proper pairing
|
||||
const condition = conditionKey || '(no condition)'
|
||||
const modeBreakdown = duplicates.map((d) => d.mode || 'basic/both').join(', ')
|
||||
errors.push(
|
||||
`Block "${block.type}": Duplicate subBlock id "${id}" with condition ${condition} (count: ${count}, modes: ${modeBreakdown})`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
throw new Error(`Duplicate subBlock IDs detected:\n${errors.join('\n')}`)
|
||||
}
|
||||
})
|
||||
|
||||
it('should have properly formed canonical pairs (matching conditions)', () => {
|
||||
const blocks = getAllBlocks()
|
||||
const errors: string[] = []
|
||||
|
||||
for (const block of blocks) {
|
||||
// Group subBlocks by canonicalParamId
|
||||
const canonicalGroups = new Map<
|
||||
string,
|
||||
Array<{ id: string; mode?: string; condition: unknown; isStaticCondition: boolean }>
|
||||
>()
|
||||
|
||||
for (const subBlock of block.subBlocks) {
|
||||
if (subBlock.canonicalParamId) {
|
||||
if (!canonicalGroups.has(subBlock.canonicalParamId)) {
|
||||
canonicalGroups.set(subBlock.canonicalParamId, [])
|
||||
}
|
||||
canonicalGroups.get(subBlock.canonicalParamId)!.push({
|
||||
id: subBlock.id,
|
||||
mode: subBlock.mode,
|
||||
condition: subBlock.condition,
|
||||
isStaticCondition: typeof subBlock.condition !== 'function',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Validate each canonical group
|
||||
for (const [canonicalId, members] of canonicalGroups) {
|
||||
// Only validate condition matching for static conditions
|
||||
const staticMembers = members.filter((m) => m.isStaticCondition)
|
||||
if (staticMembers.length > 1) {
|
||||
const conditions = staticMembers.map((m) => serializeCondition(m.condition))
|
||||
const uniqueConditions = new Set(conditions)
|
||||
|
||||
if (uniqueConditions.size > 1) {
|
||||
errors.push(
|
||||
`Block "${block.type}": Canonical param "${canonicalId}" has members with different conditions: ${[...uniqueConditions].join(' vs ')}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Check for proper basic/advanced pairing
|
||||
const basicMembers = members.filter((m) => !m.mode || m.mode === 'basic')
|
||||
const advancedMembers = members.filter((m) => m.mode === 'advanced')
|
||||
|
||||
if (basicMembers.length > 1) {
|
||||
errors.push(
|
||||
`Block "${block.type}": Canonical param "${canonicalId}" has ${basicMembers.length} basic mode members (should have at most 1)`
|
||||
)
|
||||
}
|
||||
|
||||
if (basicMembers.length === 0 && advancedMembers.length === 0) {
|
||||
errors.push(
|
||||
`Block "${block.type}": Canonical param "${canonicalId}" has no basic or advanced mode members`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
throw new Error(`Canonical pair validation errors:\n${errors.join('\n')}`)
|
||||
}
|
||||
})
|
||||
|
||||
it('should have unique canonicalParamIds per operation/condition context', () => {
|
||||
const blocks = getAllBlocks()
|
||||
const errors: string[] = []
|
||||
|
||||
for (const block of blocks) {
|
||||
// Group by condition + canonicalParamId to detect same canonical used for different operations
|
||||
const canonicalByCondition = new Map<string, Set<string>>()
|
||||
|
||||
for (const subBlock of block.subBlocks) {
|
||||
if (subBlock.canonicalParamId) {
|
||||
// Skip function conditions - we can't evaluate them statically
|
||||
if (typeof subBlock.condition === 'function') {
|
||||
continue
|
||||
}
|
||||
const conditionKey = serializeCondition(subBlock.condition)
|
||||
if (!canonicalByCondition.has(subBlock.canonicalParamId)) {
|
||||
canonicalByCondition.set(subBlock.canonicalParamId, new Set())
|
||||
}
|
||||
canonicalByCondition.get(subBlock.canonicalParamId)!.add(conditionKey)
|
||||
}
|
||||
}
|
||||
|
||||
// Check that each canonicalParamId is only used for one condition
|
||||
for (const [canonicalId, conditions] of canonicalByCondition) {
|
||||
if (conditions.size > 1) {
|
||||
errors.push(
|
||||
`Block "${block.type}": Canonical param "${canonicalId}" is used across ${conditions.size} different conditions. Each operation should have its own unique canonicalParamId.`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
throw new Error(`Canonical param reuse across conditions:\n${errors.join('\n')}`)
|
||||
}
|
||||
})
|
||||
|
||||
it('should have inputs containing canonical param IDs instead of raw subBlock IDs', () => {
|
||||
const blocks = getAllBlocks()
|
||||
const errors: string[] = []
|
||||
|
||||
for (const block of blocks) {
|
||||
if (!block.inputs) continue
|
||||
|
||||
// Find all canonical groups (subBlocks with canonicalParamId)
|
||||
const canonicalGroups = new Map<string, string[]>()
|
||||
for (const subBlock of block.subBlocks) {
|
||||
if (subBlock.canonicalParamId) {
|
||||
if (!canonicalGroups.has(subBlock.canonicalParamId)) {
|
||||
canonicalGroups.set(subBlock.canonicalParamId, [])
|
||||
}
|
||||
canonicalGroups.get(subBlock.canonicalParamId)!.push(subBlock.id)
|
||||
}
|
||||
}
|
||||
|
||||
const inputKeys = Object.keys(block.inputs)
|
||||
|
||||
for (const [canonicalId, rawSubBlockIds] of canonicalGroups) {
|
||||
// Check that the canonical param ID is in inputs
|
||||
if (!inputKeys.includes(canonicalId)) {
|
||||
errors.push(
|
||||
`Block "${block.type}": inputs section is missing canonical param "${canonicalId}"`
|
||||
)
|
||||
}
|
||||
|
||||
// Check that raw subBlock IDs are NOT in inputs (they get deleted after transformation)
|
||||
for (const rawId of rawSubBlockIds) {
|
||||
if (rawId !== canonicalId && inputKeys.includes(rawId)) {
|
||||
errors.push(
|
||||
`Block "${block.type}": inputs section contains raw subBlock id "${rawId}" which should be replaced by canonical param "${canonicalId}"`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
throw new Error(`Inputs section validation errors:\n${errors.join('\n')}`)
|
||||
}
|
||||
})
|
||||
|
||||
it('should have params function using canonical IDs instead of raw subBlock IDs', () => {
|
||||
const blocks = getAllBlocks()
|
||||
const errors: string[] = []
|
||||
|
||||
for (const block of blocks) {
|
||||
// Check if block has a params function
|
||||
const paramsFunc = block.tools?.config?.params
|
||||
if (!paramsFunc || typeof paramsFunc !== 'function') continue
|
||||
|
||||
// Get the function source code, stripping comments to avoid false positives
|
||||
const rawFuncSource = paramsFunc.toString()
|
||||
// Remove single-line comments (// ...) and multi-line comments (/* ... */)
|
||||
const funcSource = rawFuncSource
|
||||
.replace(/\/\/[^\n]*/g, '') // Remove single-line comments
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '') // Remove multi-line comments
|
||||
|
||||
// Find all canonical groups (subBlocks with canonicalParamId)
|
||||
const canonicalGroups = new Map<string, string[]>()
|
||||
for (const subBlock of block.subBlocks) {
|
||||
if (subBlock.canonicalParamId) {
|
||||
if (!canonicalGroups.has(subBlock.canonicalParamId)) {
|
||||
canonicalGroups.set(subBlock.canonicalParamId, [])
|
||||
}
|
||||
canonicalGroups.get(subBlock.canonicalParamId)!.push(subBlock.id)
|
||||
}
|
||||
}
|
||||
|
||||
// Check for raw subBlock IDs being used in the params function
|
||||
for (const [canonicalId, rawSubBlockIds] of canonicalGroups) {
|
||||
for (const rawId of rawSubBlockIds) {
|
||||
// Skip if the rawId is the same as the canonicalId (self-referential, which is allowed in some cases)
|
||||
if (rawId === canonicalId) continue
|
||||
|
||||
// Check if the params function references the raw subBlock ID
|
||||
// Look for patterns like: params.rawId, { rawId }, destructuring rawId
|
||||
const patterns = [
|
||||
new RegExp(`params\\.${rawId}\\b`), // params.rawId
|
||||
new RegExp(`\\{[^}]*\\b${rawId}\\b[^}]*\\}\\s*=\\s*params`), // { rawId } = params
|
||||
new RegExp(`\\b${rawId}\\s*[,}]`), // rawId in destructuring
|
||||
]
|
||||
|
||||
for (const pattern of patterns) {
|
||||
if (pattern.test(funcSource)) {
|
||||
errors.push(
|
||||
`Block "${block.type}": params function references raw subBlock id "${rawId}" which is deleted after canonical transformation. Use canonical param "${canonicalId}" instead.`
|
||||
)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
throw new Error(`Params function validation errors:\n${errors.join('\n')}`)
|
||||
}
|
||||
})
|
||||
|
||||
it('should have consistent required status across canonical param groups', () => {
|
||||
const blocks = getAllBlocks()
|
||||
const errors: string[] = []
|
||||
|
||||
for (const block of blocks) {
|
||||
// Find all canonical groups (subBlocks with canonicalParamId)
|
||||
const canonicalGroups = new Map<string, typeof block.subBlocks>()
|
||||
for (const subBlock of block.subBlocks) {
|
||||
if (subBlock.canonicalParamId) {
|
||||
if (!canonicalGroups.has(subBlock.canonicalParamId)) {
|
||||
canonicalGroups.set(subBlock.canonicalParamId, [])
|
||||
}
|
||||
canonicalGroups.get(subBlock.canonicalParamId)!.push(subBlock)
|
||||
}
|
||||
}
|
||||
|
||||
// For each canonical group, check that required status is consistent
|
||||
for (const [canonicalId, subBlocks] of canonicalGroups) {
|
||||
if (subBlocks.length < 2) continue // Single subblock, no consistency check needed
|
||||
|
||||
// Get required status for each subblock (handling both boolean and condition object)
|
||||
const requiredStatuses = subBlocks.map((sb) => {
|
||||
// If required is a condition object or function, we can't statically determine it
|
||||
// so we skip those cases
|
||||
if (typeof sb.required === 'object' || typeof sb.required === 'function') {
|
||||
return 'dynamic'
|
||||
}
|
||||
return sb.required === true ? 'required' : 'optional'
|
||||
})
|
||||
|
||||
// Filter out dynamic cases
|
||||
const staticStatuses = requiredStatuses.filter((s) => s !== 'dynamic')
|
||||
if (staticStatuses.length < 2) continue // Not enough static statuses to compare
|
||||
|
||||
// Check if all static statuses are the same
|
||||
const hasRequired = staticStatuses.includes('required')
|
||||
const hasOptional = staticStatuses.includes('optional')
|
||||
|
||||
if (hasRequired && hasOptional) {
|
||||
const requiredSubBlocks = subBlocks
|
||||
.filter((sb, i) => requiredStatuses[i] === 'required')
|
||||
.map((sb) => `${sb.id} (${sb.mode || 'both'})`)
|
||||
const optionalSubBlocks = subBlocks
|
||||
.filter((sb, i) => requiredStatuses[i] === 'optional')
|
||||
.map((sb) => `${sb.id} (${sb.mode || 'both'})`)
|
||||
|
||||
errors.push(
|
||||
`Block "${block.type}": canonical param "${canonicalId}" has inconsistent required status. ` +
|
||||
`Required: [${requiredSubBlocks.join(', ')}], Optional: [${optionalSubBlocks.join(', ')}]. ` +
|
||||
`All subBlocks in a canonical group should have the same required status.`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
throw new Error(`Required status consistency errors:\n${errors.join('\n')}`)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -216,8 +216,8 @@ export const A2ABlock: BlockConfig<A2AResponse> = {
|
||||
config: {
|
||||
tool: (params) => params.operation as string,
|
||||
params: (params) => {
|
||||
const { fileUpload, fileReference, ...rest } = params
|
||||
const normalizedFiles = normalizeFileInput(fileUpload || fileReference || params.files)
|
||||
const { files, ...rest } = params
|
||||
const normalizedFiles = normalizeFileInput(files)
|
||||
return {
|
||||
...rest,
|
||||
...(normalizedFiles && { files: normalizedFiles }),
|
||||
@@ -252,15 +252,7 @@ export const A2ABlock: BlockConfig<A2AResponse> = {
|
||||
},
|
||||
files: {
|
||||
type: 'array',
|
||||
description: 'Files to include with the message',
|
||||
},
|
||||
fileUpload: {
|
||||
type: 'array',
|
||||
description: 'Uploaded files (basic mode)',
|
||||
},
|
||||
fileReference: {
|
||||
type: 'json',
|
||||
description: 'File reference from previous blocks (advanced mode)',
|
||||
description: 'Files to include with the message (canonical param)',
|
||||
},
|
||||
historyLength: {
|
||||
type: 'number',
|
||||
|
||||
@@ -274,6 +274,7 @@ Return ONLY the JSON array.`,
|
||||
{ label: 'low', id: 'low' },
|
||||
{ label: 'medium', id: 'medium' },
|
||||
{ label: 'high', id: 'high' },
|
||||
{ label: 'max', id: 'max' },
|
||||
],
|
||||
dependsOn: ['model'],
|
||||
fetchOptions: async (blockId: string) => {
|
||||
@@ -318,14 +319,14 @@ Return ONLY the JSON array.`,
|
||||
|
||||
{
|
||||
id: 'azureEndpoint',
|
||||
title: 'Azure OpenAI Endpoint',
|
||||
title: 'Azure Endpoint',
|
||||
type: 'short-input',
|
||||
password: true,
|
||||
placeholder: 'https://your-resource.openai.azure.com',
|
||||
placeholder: 'https://your-resource.services.ai.azure.com',
|
||||
connectionDroppable: false,
|
||||
condition: {
|
||||
field: 'model',
|
||||
value: providers['azure-openai'].models,
|
||||
value: [...providers['azure-openai'].models, ...providers['azure-anthropic'].models],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -406,6 +407,12 @@ Return ONLY the JSON array.`,
|
||||
type: 'tool-input',
|
||||
defaultValue: [],
|
||||
},
|
||||
{
|
||||
id: 'skills',
|
||||
title: 'Skills',
|
||||
type: 'skill-input',
|
||||
defaultValue: [],
|
||||
},
|
||||
{
|
||||
id: 'apiKey',
|
||||
title: 'API Key',
|
||||
@@ -763,8 +770,12 @@ Example 3 (Array Input):
|
||||
maxTokens: { type: 'number', description: 'Maximum number of tokens in the response' },
|
||||
reasoningEffort: { type: 'string', description: 'Reasoning effort level for GPT-5 models' },
|
||||
verbosity: { type: 'string', description: 'Verbosity level for GPT-5 models' },
|
||||
thinkingLevel: { type: 'string', description: 'Thinking level for Gemini 3 models' },
|
||||
thinkingLevel: {
|
||||
type: 'string',
|
||||
description: 'Thinking level for models with extended thinking (Anthropic Claude, Gemini 3)',
|
||||
},
|
||||
tools: { type: 'json', description: 'Available tools configuration' },
|
||||
skills: { type: 'json', description: 'Selected skills configuration' },
|
||||
},
|
||||
outputs: {
|
||||
content: { type: 'string', description: 'Generated response content' },
|
||||
|
||||
@@ -75,6 +75,12 @@ export const ConfluenceBlock: BlockConfig<ConfluenceResponse> = {
|
||||
'search:confluence',
|
||||
'read:me',
|
||||
'offline_access',
|
||||
'read:blogpost:confluence',
|
||||
'write:blogpost:confluence',
|
||||
'read:content.property:confluence',
|
||||
'write:content.property:confluence',
|
||||
'read:hierarchical-content:confluence',
|
||||
'read:content.metadata:confluence',
|
||||
],
|
||||
placeholder: 'Select Confluence account',
|
||||
required: true,
|
||||
@@ -88,6 +94,19 @@ export const ConfluenceBlock: BlockConfig<ConfluenceResponse> = {
|
||||
placeholder: 'Select Confluence page',
|
||||
dependsOn: ['credential', 'domain'],
|
||||
mode: 'basic',
|
||||
required: {
|
||||
field: 'operation',
|
||||
value: [
|
||||
'read',
|
||||
'update',
|
||||
'delete',
|
||||
'create_comment',
|
||||
'list_comments',
|
||||
'list_attachments',
|
||||
'list_labels',
|
||||
'upload_attachment',
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'manualPageId',
|
||||
@@ -96,14 +115,26 @@ export const ConfluenceBlock: BlockConfig<ConfluenceResponse> = {
|
||||
canonicalParamId: 'pageId',
|
||||
placeholder: 'Enter Confluence page ID',
|
||||
mode: 'advanced',
|
||||
required: {
|
||||
field: 'operation',
|
||||
value: [
|
||||
'read',
|
||||
'update',
|
||||
'delete',
|
||||
'create_comment',
|
||||
'list_comments',
|
||||
'list_attachments',
|
||||
'list_labels',
|
||||
'upload_attachment',
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'spaceId',
|
||||
title: 'Space ID',
|
||||
type: 'short-input',
|
||||
placeholder: 'Enter Confluence space ID',
|
||||
required: true,
|
||||
condition: { field: 'operation', value: ['create', 'get_space'] },
|
||||
required: { field: 'operation', value: ['create', 'get_space'] },
|
||||
},
|
||||
{
|
||||
id: 'title',
|
||||
@@ -258,7 +289,6 @@ export const ConfluenceBlock: BlockConfig<ConfluenceResponse> = {
|
||||
const {
|
||||
credential,
|
||||
pageId,
|
||||
manualPageId,
|
||||
operation,
|
||||
attachmentFile,
|
||||
attachmentFileName,
|
||||
@@ -266,28 +296,7 @@ export const ConfluenceBlock: BlockConfig<ConfluenceResponse> = {
|
||||
...rest
|
||||
} = params
|
||||
|
||||
const effectivePageId = (pageId || manualPageId || '').trim()
|
||||
|
||||
const requiresPageId = [
|
||||
'read',
|
||||
'update',
|
||||
'delete',
|
||||
'create_comment',
|
||||
'list_comments',
|
||||
'list_attachments',
|
||||
'list_labels',
|
||||
'upload_attachment',
|
||||
]
|
||||
|
||||
const requiresSpaceId = ['create', 'get_space']
|
||||
|
||||
if (requiresPageId.includes(operation) && !effectivePageId) {
|
||||
throw new Error('Page ID is required. Please select a page or enter a page ID manually.')
|
||||
}
|
||||
|
||||
if (requiresSpaceId.includes(operation) && !rest.spaceId) {
|
||||
throw new Error('Space ID is required for this operation.')
|
||||
}
|
||||
const effectivePageId = pageId ? String(pageId).trim() : ''
|
||||
|
||||
if (operation === 'upload_attachment') {
|
||||
return {
|
||||
@@ -314,8 +323,7 @@ export const ConfluenceBlock: BlockConfig<ConfluenceResponse> = {
|
||||
operation: { type: 'string', description: 'Operation to perform' },
|
||||
domain: { type: 'string', description: 'Confluence domain' },
|
||||
credential: { type: 'string', description: 'Confluence access token' },
|
||||
pageId: { type: 'string', description: 'Page identifier' },
|
||||
manualPageId: { type: 'string', description: 'Manual page identifier' },
|
||||
pageId: { type: 'string', description: 'Page identifier (canonical param)' },
|
||||
spaceId: { type: 'string', description: 'Space identifier' },
|
||||
title: { type: 'string', description: 'Page title' },
|
||||
content: { type: 'string', description: 'Page content' },
|
||||
@@ -324,7 +332,7 @@ export const ConfluenceBlock: BlockConfig<ConfluenceResponse> = {
|
||||
comment: { type: 'string', description: 'Comment text' },
|
||||
commentId: { type: 'string', description: 'Comment identifier' },
|
||||
attachmentId: { type: 'string', description: 'Attachment identifier' },
|
||||
attachmentFile: { type: 'json', description: 'File to upload as attachment' },
|
||||
attachmentFile: { type: 'json', description: 'File to upload as attachment (canonical param)' },
|
||||
attachmentFileName: { type: 'string', description: 'Custom file name for attachment' },
|
||||
attachmentComment: { type: 'string', description: 'Comment for the attachment' },
|
||||
labelName: { type: 'string', description: 'Label name' },
|
||||
@@ -334,6 +342,7 @@ export const ConfluenceBlock: BlockConfig<ConfluenceResponse> = {
|
||||
ts: { type: 'string', description: 'Timestamp' },
|
||||
pageId: { type: 'string', description: 'Page identifier' },
|
||||
content: { type: 'string', description: 'Page content' },
|
||||
body: { type: 'json', description: 'Page body with storage format' },
|
||||
title: { type: 'string', description: 'Page title' },
|
||||
url: { type: 'string', description: 'Page or resource URL' },
|
||||
success: { type: 'boolean', description: 'Operation success status' },
|
||||
@@ -371,31 +380,46 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
|
||||
title: 'Operation',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
// Page Operations
|
||||
{ label: 'Read Page', id: 'read' },
|
||||
{ label: 'Create Page', id: 'create' },
|
||||
{ label: 'Update Page', id: 'update' },
|
||||
{ label: 'Delete Page', id: 'delete' },
|
||||
{ label: 'List Pages in Space', id: 'list_pages_in_space' },
|
||||
{ label: 'Get Page Children', id: 'get_page_children' },
|
||||
{ label: 'Get Page Ancestors', id: 'get_page_ancestors' },
|
||||
// Version Operations
|
||||
{ label: 'List Page Versions', id: 'list_page_versions' },
|
||||
{ label: 'Get Page Version', id: 'get_page_version' },
|
||||
// Page Property Operations
|
||||
{ label: 'List Page Properties', id: 'list_page_properties' },
|
||||
{ label: 'Create Page Property', id: 'create_page_property' },
|
||||
// Search Operations
|
||||
{ label: 'Search Content', id: 'search' },
|
||||
{ label: 'Search in Space', id: 'search_in_space' },
|
||||
// Blog Post Operations
|
||||
{ label: 'List Blog Posts', id: 'list_blogposts' },
|
||||
{ label: 'Get Blog Post', id: 'get_blogpost' },
|
||||
{ label: 'Create Blog Post', id: 'create_blogpost' },
|
||||
{ label: 'List Blog Posts in Space', id: 'list_blogposts_in_space' },
|
||||
// Comment Operations
|
||||
{ label: 'Create Comment', id: 'create_comment' },
|
||||
{ label: 'List Comments', id: 'list_comments' },
|
||||
{ label: 'Update Comment', id: 'update_comment' },
|
||||
{ label: 'Delete Comment', id: 'delete_comment' },
|
||||
// Attachment Operations
|
||||
{ label: 'Upload Attachment', id: 'upload_attachment' },
|
||||
{ label: 'List Attachments', id: 'list_attachments' },
|
||||
{ label: 'Delete Attachment', id: 'delete_attachment' },
|
||||
// Label Operations
|
||||
{ label: 'List Labels', id: 'list_labels' },
|
||||
{ label: 'Add Label', id: 'add_label' },
|
||||
// Space Operations
|
||||
{ label: 'Get Space', id: 'get_space' },
|
||||
{ label: 'List Spaces', id: 'list_spaces' },
|
||||
],
|
||||
value: () => 'read',
|
||||
},
|
||||
{
|
||||
id: 'domain',
|
||||
title: 'Domain',
|
||||
type: 'short-input',
|
||||
placeholder: 'Enter Confluence domain (e.g., simstudio.atlassian.net)',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'credential',
|
||||
title: 'Confluence Account',
|
||||
@@ -424,10 +448,23 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
|
||||
'search:confluence',
|
||||
'read:me',
|
||||
'offline_access',
|
||||
'read:blogpost:confluence',
|
||||
'write:blogpost:confluence',
|
||||
'read:content.property:confluence',
|
||||
'write:content.property:confluence',
|
||||
'read:hierarchical-content:confluence',
|
||||
'read:content.metadata:confluence',
|
||||
],
|
||||
placeholder: 'Select Confluence account',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'domain',
|
||||
title: 'Domain',
|
||||
type: 'short-input',
|
||||
placeholder: 'Enter Confluence domain (e.g., simstudio.atlassian.net)',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'pageId',
|
||||
title: 'Select Page',
|
||||
@@ -437,6 +474,40 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
|
||||
placeholder: 'Select Confluence page',
|
||||
dependsOn: ['credential', 'domain'],
|
||||
mode: 'basic',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: [
|
||||
'list_pages_in_space',
|
||||
'list_blogposts',
|
||||
'get_blogpost',
|
||||
'list_blogposts_in_space',
|
||||
'search',
|
||||
'search_in_space',
|
||||
'get_space',
|
||||
'list_spaces',
|
||||
],
|
||||
not: true,
|
||||
},
|
||||
required: {
|
||||
field: 'operation',
|
||||
value: [
|
||||
'read',
|
||||
'update',
|
||||
'delete',
|
||||
'create_comment',
|
||||
'list_comments',
|
||||
'list_attachments',
|
||||
'list_labels',
|
||||
'upload_attachment',
|
||||
'add_label',
|
||||
'get_page_children',
|
||||
'get_page_ancestors',
|
||||
'list_page_versions',
|
||||
'get_page_version',
|
||||
'list_page_properties',
|
||||
'create_page_property',
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'manualPageId',
|
||||
@@ -445,6 +516,40 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
|
||||
canonicalParamId: 'pageId',
|
||||
placeholder: 'Enter Confluence page ID',
|
||||
mode: 'advanced',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: [
|
||||
'list_pages_in_space',
|
||||
'list_blogposts',
|
||||
'get_blogpost',
|
||||
'list_blogposts_in_space',
|
||||
'search',
|
||||
'search_in_space',
|
||||
'get_space',
|
||||
'list_spaces',
|
||||
],
|
||||
not: true,
|
||||
},
|
||||
required: {
|
||||
field: 'operation',
|
||||
value: [
|
||||
'read',
|
||||
'update',
|
||||
'delete',
|
||||
'create_comment',
|
||||
'list_comments',
|
||||
'list_attachments',
|
||||
'list_labels',
|
||||
'upload_attachment',
|
||||
'add_label',
|
||||
'get_page_children',
|
||||
'get_page_ancestors',
|
||||
'list_page_versions',
|
||||
'get_page_version',
|
||||
'list_page_properties',
|
||||
'create_page_property',
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'spaceId',
|
||||
@@ -452,21 +557,63 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
|
||||
type: 'short-input',
|
||||
placeholder: 'Enter Confluence space ID',
|
||||
required: true,
|
||||
condition: { field: 'operation', value: ['create', 'get_space'] },
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: [
|
||||
'create',
|
||||
'get_space',
|
||||
'list_pages_in_space',
|
||||
'search_in_space',
|
||||
'create_blogpost',
|
||||
'list_blogposts_in_space',
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'blogPostId',
|
||||
title: 'Blog Post ID',
|
||||
type: 'short-input',
|
||||
placeholder: 'Enter blog post ID',
|
||||
required: true,
|
||||
condition: { field: 'operation', value: 'get_blogpost' },
|
||||
},
|
||||
{
|
||||
id: 'versionNumber',
|
||||
title: 'Version Number',
|
||||
type: 'short-input',
|
||||
placeholder: 'Enter version number',
|
||||
required: true,
|
||||
condition: { field: 'operation', value: 'get_page_version' },
|
||||
},
|
||||
{
|
||||
id: 'propertyKey',
|
||||
title: 'Property Key',
|
||||
type: 'short-input',
|
||||
placeholder: 'Enter property key/name',
|
||||
required: true,
|
||||
condition: { field: 'operation', value: 'create_page_property' },
|
||||
},
|
||||
{
|
||||
id: 'propertyValue',
|
||||
title: 'Property Value',
|
||||
type: 'long-input',
|
||||
placeholder: 'Enter property value (JSON supported)',
|
||||
required: true,
|
||||
condition: { field: 'operation', value: 'create_page_property' },
|
||||
},
|
||||
{
|
||||
id: 'title',
|
||||
title: 'Title',
|
||||
type: 'short-input',
|
||||
placeholder: 'Enter title for the page',
|
||||
condition: { field: 'operation', value: ['create', 'update'] },
|
||||
placeholder: 'Enter title',
|
||||
condition: { field: 'operation', value: ['create', 'update', 'create_blogpost'] },
|
||||
},
|
||||
{
|
||||
id: 'content',
|
||||
title: 'Content',
|
||||
type: 'long-input',
|
||||
placeholder: 'Enter content for the page',
|
||||
condition: { field: 'operation', value: ['create', 'update'] },
|
||||
placeholder: 'Enter content',
|
||||
condition: { field: 'operation', value: ['create', 'update', 'create_blogpost'] },
|
||||
},
|
||||
{
|
||||
id: 'parentId',
|
||||
@@ -481,7 +628,7 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
|
||||
type: 'short-input',
|
||||
placeholder: 'Enter search query',
|
||||
required: true,
|
||||
condition: { field: 'operation', value: 'search' },
|
||||
condition: { field: 'operation', value: ['search', 'search_in_space'] },
|
||||
},
|
||||
{
|
||||
id: 'comment',
|
||||
@@ -515,6 +662,7 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
|
||||
placeholder: 'Select file to upload',
|
||||
condition: { field: 'operation', value: 'upload_attachment' },
|
||||
mode: 'basic',
|
||||
required: { field: 'operation', value: 'upload_attachment' },
|
||||
},
|
||||
{
|
||||
id: 'attachmentFileReference',
|
||||
@@ -524,6 +672,7 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
|
||||
placeholder: 'Reference file from previous blocks',
|
||||
condition: { field: 'operation', value: 'upload_attachment' },
|
||||
mode: 'advanced',
|
||||
required: { field: 'operation', value: 'upload_attachment' },
|
||||
},
|
||||
{
|
||||
id: 'attachmentFileName',
|
||||
@@ -545,40 +694,140 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
|
||||
type: 'short-input',
|
||||
placeholder: 'Enter label name',
|
||||
required: true,
|
||||
condition: { field: 'operation', value: ['add_label', 'remove_label'] },
|
||||
condition: { field: 'operation', value: 'add_label' },
|
||||
},
|
||||
{
|
||||
id: 'labelPrefix',
|
||||
title: 'Label Prefix',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'Global (default)', id: 'global' },
|
||||
{ label: 'My', id: 'my' },
|
||||
{ label: 'Team', id: 'team' },
|
||||
{ label: 'System', id: 'system' },
|
||||
],
|
||||
value: () => 'global',
|
||||
condition: { field: 'operation', value: 'add_label' },
|
||||
},
|
||||
{
|
||||
id: 'blogPostStatus',
|
||||
title: 'Status',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'Published (current)', id: 'current' },
|
||||
{ label: 'Draft', id: 'draft' },
|
||||
],
|
||||
value: () => 'current',
|
||||
condition: { field: 'operation', value: 'create_blogpost' },
|
||||
},
|
||||
{
|
||||
id: 'purge',
|
||||
title: 'Permanently Delete',
|
||||
type: 'switch',
|
||||
condition: { field: 'operation', value: 'delete' },
|
||||
},
|
||||
{
|
||||
id: 'bodyFormat',
|
||||
title: 'Body Format',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'Storage (default)', id: 'storage' },
|
||||
{ label: 'Atlas Doc Format', id: 'atlas_doc_format' },
|
||||
{ label: 'View', id: 'view' },
|
||||
{ label: 'Export View', id: 'export_view' },
|
||||
],
|
||||
value: () => 'storage',
|
||||
condition: { field: 'operation', value: 'list_comments' },
|
||||
},
|
||||
{
|
||||
id: 'limit',
|
||||
title: 'Limit',
|
||||
type: 'short-input',
|
||||
placeholder: 'Enter maximum number of results (default: 25)',
|
||||
placeholder: 'Enter maximum number of results (default: 50, max: 250)',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['search', 'list_comments', 'list_attachments', 'list_spaces'],
|
||||
value: [
|
||||
'search',
|
||||
'search_in_space',
|
||||
'list_comments',
|
||||
'list_attachments',
|
||||
'list_spaces',
|
||||
'list_pages_in_space',
|
||||
'list_blogposts',
|
||||
'list_blogposts_in_space',
|
||||
'get_page_children',
|
||||
'list_page_versions',
|
||||
'list_page_properties',
|
||||
'list_labels',
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'cursor',
|
||||
title: 'Pagination Cursor',
|
||||
type: 'short-input',
|
||||
placeholder: 'Enter cursor from previous response (optional)',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: [
|
||||
'list_comments',
|
||||
'list_attachments',
|
||||
'list_spaces',
|
||||
'list_pages_in_space',
|
||||
'list_blogposts',
|
||||
'list_blogposts_in_space',
|
||||
'get_page_children',
|
||||
'list_page_versions',
|
||||
'list_page_properties',
|
||||
'list_labels',
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
tools: {
|
||||
access: [
|
||||
// Page Tools
|
||||
'confluence_retrieve',
|
||||
'confluence_update',
|
||||
'confluence_create_page',
|
||||
'confluence_delete_page',
|
||||
'confluence_list_pages_in_space',
|
||||
'confluence_get_page_children',
|
||||
'confluence_get_page_ancestors',
|
||||
// Version Tools
|
||||
'confluence_list_page_versions',
|
||||
'confluence_get_page_version',
|
||||
// Property Tools
|
||||
'confluence_list_page_properties',
|
||||
'confluence_create_page_property',
|
||||
// Search Tools
|
||||
'confluence_search',
|
||||
'confluence_search_in_space',
|
||||
// Blog Post Tools
|
||||
'confluence_list_blogposts',
|
||||
'confluence_get_blogpost',
|
||||
'confluence_create_blogpost',
|
||||
'confluence_list_blogposts_in_space',
|
||||
// Comment Tools
|
||||
'confluence_create_comment',
|
||||
'confluence_list_comments',
|
||||
'confluence_update_comment',
|
||||
'confluence_delete_comment',
|
||||
// Attachment Tools
|
||||
'confluence_upload_attachment',
|
||||
'confluence_list_attachments',
|
||||
'confluence_delete_attachment',
|
||||
// Label Tools
|
||||
'confluence_list_labels',
|
||||
'confluence_add_label',
|
||||
// Space Tools
|
||||
'confluence_get_space',
|
||||
'confluence_list_spaces',
|
||||
],
|
||||
config: {
|
||||
tool: (params) => {
|
||||
switch (params.operation) {
|
||||
// Page Operations
|
||||
case 'read':
|
||||
return 'confluence_retrieve'
|
||||
case 'create':
|
||||
@@ -587,8 +836,37 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
|
||||
return 'confluence_update'
|
||||
case 'delete':
|
||||
return 'confluence_delete_page'
|
||||
case 'list_pages_in_space':
|
||||
return 'confluence_list_pages_in_space'
|
||||
case 'get_page_children':
|
||||
return 'confluence_get_page_children'
|
||||
case 'get_page_ancestors':
|
||||
return 'confluence_get_page_ancestors'
|
||||
// Version Operations
|
||||
case 'list_page_versions':
|
||||
return 'confluence_list_page_versions'
|
||||
case 'get_page_version':
|
||||
return 'confluence_get_page_version'
|
||||
// Property Operations
|
||||
case 'list_page_properties':
|
||||
return 'confluence_list_page_properties'
|
||||
case 'create_page_property':
|
||||
return 'confluence_create_page_property'
|
||||
// Search Operations
|
||||
case 'search':
|
||||
return 'confluence_search'
|
||||
case 'search_in_space':
|
||||
return 'confluence_search_in_space'
|
||||
// Blog Post Operations
|
||||
case 'list_blogposts':
|
||||
return 'confluence_list_blogposts'
|
||||
case 'get_blogpost':
|
||||
return 'confluence_get_blogpost'
|
||||
case 'create_blogpost':
|
||||
return 'confluence_create_blogpost'
|
||||
case 'list_blogposts_in_space':
|
||||
return 'confluence_list_blogposts_in_space'
|
||||
// Comment Operations
|
||||
case 'create_comment':
|
||||
return 'confluence_create_comment'
|
||||
case 'list_comments':
|
||||
@@ -597,14 +875,19 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
|
||||
return 'confluence_update_comment'
|
||||
case 'delete_comment':
|
||||
return 'confluence_delete_comment'
|
||||
// Attachment Operations
|
||||
case 'upload_attachment':
|
||||
return 'confluence_upload_attachment'
|
||||
case 'list_attachments':
|
||||
return 'confluence_list_attachments'
|
||||
case 'delete_attachment':
|
||||
return 'confluence_delete_attachment'
|
||||
// Label Operations
|
||||
case 'list_labels':
|
||||
return 'confluence_list_labels'
|
||||
case 'add_label':
|
||||
return 'confluence_add_label'
|
||||
// Space Operations
|
||||
case 'get_space':
|
||||
return 'confluence_get_space'
|
||||
case 'list_spaces':
|
||||
@@ -617,42 +900,104 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
|
||||
const {
|
||||
credential,
|
||||
pageId,
|
||||
manualPageId,
|
||||
operation,
|
||||
attachmentFileUpload,
|
||||
attachmentFileReference,
|
||||
attachmentFile,
|
||||
attachmentFileName,
|
||||
attachmentComment,
|
||||
blogPostId,
|
||||
versionNumber,
|
||||
propertyKey,
|
||||
propertyValue,
|
||||
labelPrefix,
|
||||
blogPostStatus,
|
||||
purge,
|
||||
bodyFormat,
|
||||
cursor,
|
||||
...rest
|
||||
} = params
|
||||
|
||||
const effectivePageId = (pageId || manualPageId || '').trim()
|
||||
// Use canonical param (serializer already handles basic/advanced mode)
|
||||
const effectivePageId = pageId ? String(pageId).trim() : ''
|
||||
|
||||
const requiresPageId = [
|
||||
'read',
|
||||
'update',
|
||||
'delete',
|
||||
'create_comment',
|
||||
'list_comments',
|
||||
'list_attachments',
|
||||
'list_labels',
|
||||
'upload_attachment',
|
||||
]
|
||||
|
||||
const requiresSpaceId = ['create', 'get_space']
|
||||
|
||||
if (requiresPageId.includes(operation) && !effectivePageId) {
|
||||
throw new Error('Page ID is required. Please select a page or enter a page ID manually.')
|
||||
if (operation === 'add_label') {
|
||||
return {
|
||||
credential,
|
||||
pageId: effectivePageId,
|
||||
operation,
|
||||
prefix: labelPrefix || 'global',
|
||||
...rest,
|
||||
}
|
||||
}
|
||||
|
||||
if (requiresSpaceId.includes(operation) && !rest.spaceId) {
|
||||
throw new Error('Space ID is required for this operation.')
|
||||
if (operation === 'create_blogpost') {
|
||||
return {
|
||||
credential,
|
||||
operation,
|
||||
status: blogPostStatus || 'current',
|
||||
...rest,
|
||||
}
|
||||
}
|
||||
|
||||
if (operation === 'delete') {
|
||||
return {
|
||||
credential,
|
||||
pageId: effectivePageId,
|
||||
operation,
|
||||
purge: purge || false,
|
||||
...rest,
|
||||
}
|
||||
}
|
||||
|
||||
if (operation === 'list_comments') {
|
||||
return {
|
||||
credential,
|
||||
pageId: effectivePageId,
|
||||
operation,
|
||||
bodyFormat: bodyFormat || 'storage',
|
||||
cursor: cursor || undefined,
|
||||
...rest,
|
||||
}
|
||||
}
|
||||
|
||||
// Operations that support cursor pagination
|
||||
const supportsCursor = [
|
||||
'list_attachments',
|
||||
'list_spaces',
|
||||
'list_pages_in_space',
|
||||
'list_blogposts',
|
||||
'list_blogposts_in_space',
|
||||
'get_page_children',
|
||||
'list_page_versions',
|
||||
'list_page_properties',
|
||||
'list_labels',
|
||||
]
|
||||
|
||||
if (supportsCursor.includes(operation) && cursor) {
|
||||
return {
|
||||
credential,
|
||||
pageId: effectivePageId || undefined,
|
||||
operation,
|
||||
cursor,
|
||||
...rest,
|
||||
}
|
||||
}
|
||||
|
||||
if (operation === 'create_page_property') {
|
||||
if (!propertyKey) {
|
||||
throw new Error('Property key is required for this operation.')
|
||||
}
|
||||
return {
|
||||
credential,
|
||||
pageId: effectivePageId,
|
||||
operation,
|
||||
key: propertyKey,
|
||||
value: propertyValue,
|
||||
...rest,
|
||||
}
|
||||
}
|
||||
|
||||
if (operation === 'upload_attachment') {
|
||||
const fileInput = attachmentFileUpload || attachmentFileReference || attachmentFile
|
||||
const normalizedFile = normalizeFileInput(fileInput, { single: true })
|
||||
const normalizedFile = normalizeFileInput(attachmentFile, { single: true })
|
||||
if (!normalizedFile) {
|
||||
throw new Error('File is required for upload attachment operation.')
|
||||
}
|
||||
@@ -670,6 +1015,8 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
|
||||
return {
|
||||
credential,
|
||||
pageId: effectivePageId || undefined,
|
||||
blogPostId: blogPostId || undefined,
|
||||
versionNumber: versionNumber ? Number.parseInt(String(versionNumber), 10) : undefined,
|
||||
operation,
|
||||
...rest,
|
||||
}
|
||||
@@ -680,22 +1027,79 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
|
||||
operation: { type: 'string', description: 'Operation to perform' },
|
||||
domain: { type: 'string', description: 'Confluence domain' },
|
||||
credential: { type: 'string', description: 'Confluence access token' },
|
||||
pageId: { type: 'string', description: 'Page identifier' },
|
||||
manualPageId: { type: 'string', description: 'Manual page identifier' },
|
||||
pageId: { type: 'string', description: 'Page identifier (canonical param)' },
|
||||
spaceId: { type: 'string', description: 'Space identifier' },
|
||||
title: { type: 'string', description: 'Page title' },
|
||||
content: { type: 'string', description: 'Page content' },
|
||||
blogPostId: { type: 'string', description: 'Blog post identifier' },
|
||||
versionNumber: { type: 'number', description: 'Page version number' },
|
||||
propertyKey: { type: 'string', description: 'Property key/name' },
|
||||
propertyValue: { type: 'json', description: 'Property value (JSON)' },
|
||||
title: { type: 'string', description: 'Page or blog post title' },
|
||||
content: { type: 'string', description: 'Page or blog post content' },
|
||||
parentId: { type: 'string', description: 'Parent page identifier' },
|
||||
query: { type: 'string', description: 'Search query' },
|
||||
comment: { type: 'string', description: 'Comment text' },
|
||||
commentId: { type: 'string', description: 'Comment identifier' },
|
||||
attachmentId: { type: 'string', description: 'Attachment identifier' },
|
||||
attachmentFile: { type: 'json', description: 'File to upload as attachment' },
|
||||
attachmentFileUpload: { type: 'json', description: 'Uploaded file (basic mode)' },
|
||||
attachmentFileReference: { type: 'json', description: 'File reference (advanced mode)' },
|
||||
attachmentFile: { type: 'json', description: 'File to upload as attachment (canonical param)' },
|
||||
attachmentFileName: { type: 'string', description: 'Custom file name for attachment' },
|
||||
attachmentComment: { type: 'string', description: 'Comment for the attachment' },
|
||||
labelName: { type: 'string', description: 'Label name' },
|
||||
labelPrefix: { type: 'string', description: 'Label prefix (global, my, team, system)' },
|
||||
blogPostStatus: { type: 'string', description: 'Blog post status (current or draft)' },
|
||||
purge: { type: 'boolean', description: 'Permanently delete instead of moving to trash' },
|
||||
bodyFormat: { type: 'string', description: 'Body format for comments' },
|
||||
limit: { type: 'number', description: 'Maximum number of results' },
|
||||
cursor: { type: 'string', description: 'Pagination cursor from previous response' },
|
||||
},
|
||||
outputs: {
|
||||
ts: { type: 'string', description: 'Timestamp' },
|
||||
pageId: { type: 'string', description: 'Page identifier' },
|
||||
content: { type: 'string', description: 'Page content' },
|
||||
body: { type: 'json', description: 'Page body with storage format' },
|
||||
title: { type: 'string', description: 'Page title' },
|
||||
url: { type: 'string', description: 'Page or resource URL' },
|
||||
success: { type: 'boolean', description: 'Operation success status' },
|
||||
deleted: { type: 'boolean', description: 'Deletion status' },
|
||||
added: { type: 'boolean', description: 'Addition status' },
|
||||
removed: { type: 'boolean', description: 'Removal status' },
|
||||
updated: { type: 'boolean', description: 'Update status' },
|
||||
// Search & List Results
|
||||
results: { type: 'array', description: 'Search results' },
|
||||
pages: { type: 'array', description: 'List of pages' },
|
||||
children: { type: 'array', description: 'List of child pages' },
|
||||
ancestors: { type: 'array', description: 'List of ancestor pages' },
|
||||
// Comment Results
|
||||
comments: { type: 'array', description: 'List of comments' },
|
||||
commentId: { type: 'string', description: 'Comment identifier' },
|
||||
// Attachment Results
|
||||
attachments: { type: 'array', description: 'List of attachments' },
|
||||
attachmentId: { type: 'string', description: 'Attachment identifier' },
|
||||
fileSize: { type: 'number', description: 'Attachment file size in bytes' },
|
||||
mediaType: { type: 'string', description: 'Attachment MIME type' },
|
||||
downloadUrl: { type: 'string', description: 'Attachment download URL' },
|
||||
// Label Results
|
||||
labels: { type: 'array', description: 'List of labels' },
|
||||
labelName: { type: 'string', description: 'Label name' },
|
||||
// Space Results
|
||||
spaces: { type: 'array', description: 'List of spaces' },
|
||||
spaceId: { type: 'string', description: 'Space identifier' },
|
||||
name: { type: 'string', description: 'Space name' },
|
||||
key: { type: 'string', description: 'Space key' },
|
||||
type: { type: 'string', description: 'Space or content type' },
|
||||
status: { type: 'string', description: 'Space status' },
|
||||
// Blog Post Results
|
||||
blogPosts: { type: 'array', description: 'List of blog posts' },
|
||||
blogPostId: { type: 'string', description: 'Blog post identifier' },
|
||||
// Version Results
|
||||
versions: { type: 'array', description: 'List of page versions' },
|
||||
version: { type: 'json', description: 'Version information' },
|
||||
versionNumber: { type: 'number', description: 'Version number' },
|
||||
// Property Results
|
||||
properties: { type: 'array', description: 'List of page properties' },
|
||||
propertyId: { type: 'string', description: 'Property identifier' },
|
||||
propertyKey: { type: 'string', description: 'Property key' },
|
||||
propertyValue: { type: 'json', description: 'Property value' },
|
||||
// Pagination
|
||||
nextCursor: { type: 'string', description: 'Cursor for fetching next page of results' },
|
||||
},
|
||||
}
|
||||
|
||||
@@ -584,7 +584,7 @@ export const DiscordBlock: BlockConfig<DiscordResponse> = {
|
||||
...commonParams,
|
||||
channelId: params.channelId,
|
||||
content: params.content,
|
||||
files: normalizeFileInput(params.attachmentFiles || params.files),
|
||||
files: normalizeFileInput(params.files),
|
||||
}
|
||||
}
|
||||
case 'discord_get_messages':
|
||||
@@ -773,8 +773,7 @@ export const DiscordBlock: BlockConfig<DiscordResponse> = {
|
||||
nick: { type: 'string', description: 'Member nickname' },
|
||||
reason: { type: 'string', description: 'Reason for moderation action' },
|
||||
archived: { type: 'string', description: 'Archive status (true/false)' },
|
||||
attachmentFiles: { type: 'json', description: 'Files to attach (UI upload)' },
|
||||
files: { type: 'array', description: 'Files to attach (UserFile array)' },
|
||||
files: { type: 'array', description: 'Files to attach (canonical param)' },
|
||||
limit: { type: 'number', description: 'Message limit' },
|
||||
autoArchiveDuration: { type: 'number', description: 'Thread auto-archive duration in minutes' },
|
||||
channelType: { type: 'number', description: 'Discord channel type (0=text, 2=voice, etc.)' },
|
||||
|
||||
@@ -317,12 +317,8 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
|
||||
params.maxResults = Number(params.maxResults)
|
||||
}
|
||||
|
||||
// Normalize file input for upload operation
|
||||
// Check all possible field IDs: uploadFile (basic), fileRef (advanced), fileContent (legacy)
|
||||
const normalizedFile = normalizeFileInput(
|
||||
params.uploadFile || params.fileRef || params.fileContent,
|
||||
{ single: true }
|
||||
)
|
||||
// Normalize file input for upload operation - use canonical 'file' param
|
||||
const normalizedFile = normalizeFileInput(params.file, { single: true })
|
||||
if (normalizedFile) {
|
||||
params.file = normalizedFile
|
||||
}
|
||||
@@ -361,10 +357,7 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
|
||||
path: { type: 'string', description: 'Path in Dropbox' },
|
||||
autorename: { type: 'boolean', description: 'Auto-rename on conflict' },
|
||||
// Upload inputs
|
||||
uploadFile: { type: 'json', description: 'Uploaded file (UserFile)' },
|
||||
file: { type: 'json', description: 'File to upload (UserFile object)' },
|
||||
fileRef: { type: 'json', description: 'File reference from previous block' },
|
||||
fileContent: { type: 'string', description: 'Legacy: base64 encoded file content' },
|
||||
file: { type: 'json', description: 'File to upload (canonical param)' },
|
||||
fileName: { type: 'string', description: 'Optional filename' },
|
||||
mode: { type: 'string', description: 'Write mode: add or overwrite' },
|
||||
mute: { type: 'boolean', description: 'Mute notifications' },
|
||||
|
||||
@@ -194,7 +194,8 @@ export const FileV2Block: BlockConfig<FileParserOutput> = {
|
||||
fallbackToolId: 'file_parser_v2',
|
||||
}),
|
||||
params: (params) => {
|
||||
const fileInput = params.file || params.filePath || params.fileInput
|
||||
// Use canonical 'fileInput' param directly
|
||||
const fileInput = params.fileInput
|
||||
if (!fileInput) {
|
||||
logger.error('No file input provided')
|
||||
throw new Error('File is required')
|
||||
@@ -228,9 +229,7 @@ export const FileV2Block: BlockConfig<FileParserOutput> = {
|
||||
},
|
||||
},
|
||||
inputs: {
|
||||
fileInput: { type: 'json', description: 'File input (upload or URL reference)' },
|
||||
filePath: { type: 'string', description: 'File URL (advanced mode)' },
|
||||
file: { type: 'json', description: 'Uploaded file data (basic mode)' },
|
||||
fileInput: { type: 'json', description: 'File input (canonical param)' },
|
||||
fileType: { type: 'string', description: 'File type' },
|
||||
},
|
||||
outputs: {
|
||||
@@ -283,7 +282,8 @@ export const FileV3Block: BlockConfig<FileParserV3Output> = {
|
||||
config: {
|
||||
tool: () => 'file_parser_v3',
|
||||
params: (params) => {
|
||||
const fileInput = params.fileInput ?? params.file ?? params.fileUrl ?? params.filePath
|
||||
// Use canonical 'fileInput' param directly
|
||||
const fileInput = params.fileInput
|
||||
if (!fileInput) {
|
||||
logger.error('No file input provided')
|
||||
throw new Error('File input is required')
|
||||
@@ -321,9 +321,7 @@ export const FileV3Block: BlockConfig<FileParserV3Output> = {
|
||||
},
|
||||
},
|
||||
inputs: {
|
||||
fileInput: { type: 'json', description: 'File input (upload or URL)' },
|
||||
fileUrl: { type: 'string', description: 'External file URL (advanced mode)' },
|
||||
file: { type: 'json', description: 'Uploaded file data (basic mode)' },
|
||||
fileInput: { type: 'json', description: 'File input (canonical param)' },
|
||||
fileType: { type: 'string', description: 'File type' },
|
||||
},
|
||||
outputs: {
|
||||
|
||||
@@ -461,12 +461,11 @@ Return ONLY the summary text - no quotes, no labels.`,
|
||||
return baseParams
|
||||
|
||||
case 'fireflies_upload_audio': {
|
||||
// Support both file upload and URL
|
||||
// Support both file upload and URL - use canonical 'audioFile' param
|
||||
const audioUrl = params.audioUrl?.trim()
|
||||
const audioFile = params.audioFile
|
||||
const audioFileReference = params.audioFileReference
|
||||
|
||||
if (!audioUrl && !audioFile && !audioFileReference) {
|
||||
if (!audioUrl && !audioFile) {
|
||||
throw new Error('Either audio file or audio URL is required.')
|
||||
}
|
||||
|
||||
@@ -474,7 +473,6 @@ Return ONLY the summary text - no quotes, no labels.`,
|
||||
...baseParams,
|
||||
audioUrl: audioUrl || undefined,
|
||||
audioFile: audioFile || undefined,
|
||||
audioFileReference: audioFileReference || undefined,
|
||||
title: params.title?.trim() || undefined,
|
||||
language: params.language?.trim() || undefined,
|
||||
attendees: params.attendees?.trim() || undefined,
|
||||
@@ -548,8 +546,7 @@ Return ONLY the summary text - no quotes, no labels.`,
|
||||
hostEmail: { type: 'string', description: 'Filter by host email' },
|
||||
participants: { type: 'string', description: 'Filter by participants (comma-separated)' },
|
||||
limit: { type: 'number', description: 'Maximum results to return' },
|
||||
audioFile: { type: 'json', description: 'Audio/video file (UserFile)' },
|
||||
audioFileReference: { type: 'json', description: 'Audio/video file reference' },
|
||||
audioFile: { type: 'json', description: 'Audio/video file (canonical param)' },
|
||||
audioUrl: { type: 'string', description: 'Public URL to audio file' },
|
||||
title: { type: 'string', description: 'Meeting title' },
|
||||
language: { type: 'string', description: 'Language code for transcription' },
|
||||
@@ -620,9 +617,8 @@ export const FirefliesV2Block: BlockConfig<FirefliesResponse> = {
|
||||
}
|
||||
|
||||
if (params.operation === 'fireflies_upload_audio') {
|
||||
const audioFile = normalizeFileInput(params.audioFile || params.audioFileReference, {
|
||||
single: true,
|
||||
})
|
||||
// Use canonical 'audioFile' param directly
|
||||
const audioFile = normalizeFileInput(params.audioFile, { single: true })
|
||||
if (!audioFile) {
|
||||
throw new Error('Audio file is required.')
|
||||
}
|
||||
@@ -635,7 +631,6 @@ export const FirefliesV2Block: BlockConfig<FirefliesResponse> = {
|
||||
...params,
|
||||
audioUrl,
|
||||
audioFile: undefined,
|
||||
audioFileReference: undefined,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -643,8 +638,5 @@ export const FirefliesV2Block: BlockConfig<FirefliesResponse> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
inputs: {
|
||||
...firefliesV2Inputs,
|
||||
audioFileReference: { type: 'json', description: 'Audio/video file reference' },
|
||||
},
|
||||
inputs: firefliesV2Inputs,
|
||||
}
|
||||
|
||||
@@ -362,10 +362,10 @@ Return ONLY the search query - no explanations, no extra text.`,
|
||||
},
|
||||
// Add/Remove Label - Label selector (basic mode)
|
||||
{
|
||||
id: 'labelManagement',
|
||||
id: 'labelSelector',
|
||||
title: 'Label',
|
||||
type: 'folder-selector',
|
||||
canonicalParamId: 'labelIds',
|
||||
canonicalParamId: 'manageLabelId',
|
||||
serviceId: 'gmail',
|
||||
requiredScopes: ['https://www.googleapis.com/auth/gmail.labels'],
|
||||
placeholder: 'Select label',
|
||||
@@ -376,10 +376,10 @@ Return ONLY the search query - no explanations, no extra text.`,
|
||||
},
|
||||
// Add/Remove Label - Manual label input (advanced mode)
|
||||
{
|
||||
id: 'manualLabelManagement',
|
||||
id: 'manualLabelId',
|
||||
title: 'Label',
|
||||
type: 'short-input',
|
||||
canonicalParamId: 'labelIds',
|
||||
canonicalParamId: 'manageLabelId',
|
||||
placeholder: 'Enter label ID (e.g., INBOX, Label_123)',
|
||||
mode: 'advanced',
|
||||
condition: { field: 'operation', value: ['add_label_gmail', 'remove_label_gmail'] },
|
||||
@@ -408,38 +408,33 @@ Return ONLY the search query - no explanations, no extra text.`,
|
||||
const {
|
||||
credential,
|
||||
folder,
|
||||
manualFolder,
|
||||
destinationLabel,
|
||||
manualDestinationLabel,
|
||||
sourceLabel,
|
||||
manualSourceLabel,
|
||||
addLabelIds,
|
||||
removeLabelIds,
|
||||
moveMessageId,
|
||||
actionMessageId,
|
||||
labelActionMessageId,
|
||||
labelManagement,
|
||||
manualLabelManagement,
|
||||
attachmentFiles,
|
||||
manageLabelId,
|
||||
attachments,
|
||||
...rest
|
||||
} = params
|
||||
|
||||
// Handle both selector and manual folder input
|
||||
const effectiveFolder = (folder || manualFolder || '').trim()
|
||||
// Use canonical 'folder' param directly
|
||||
const effectiveFolder = folder ? String(folder).trim() : ''
|
||||
|
||||
if (rest.operation === 'read_gmail') {
|
||||
rest.folder = effectiveFolder || 'INBOX'
|
||||
}
|
||||
|
||||
// Handle move operation
|
||||
// Handle move operation - use canonical params addLabelIds and removeLabelIds
|
||||
if (rest.operation === 'move_gmail') {
|
||||
if (moveMessageId) {
|
||||
rest.messageId = moveMessageId
|
||||
}
|
||||
if (!rest.addLabelIds) {
|
||||
rest.addLabelIds = (destinationLabel || manualDestinationLabel || '').trim()
|
||||
if (addLabelIds) {
|
||||
rest.addLabelIds = String(addLabelIds).trim()
|
||||
}
|
||||
if (!rest.removeLabelIds) {
|
||||
rest.removeLabelIds = (sourceLabel || manualSourceLabel || '').trim()
|
||||
if (removeLabelIds) {
|
||||
rest.removeLabelIds = String(removeLabelIds).trim()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -462,13 +457,13 @@ Return ONLY the search query - no explanations, no extra text.`,
|
||||
if (labelActionMessageId) {
|
||||
rest.messageId = labelActionMessageId
|
||||
}
|
||||
if (!rest.labelIds) {
|
||||
rest.labelIds = (labelManagement || manualLabelManagement || '').trim()
|
||||
if (manageLabelId) {
|
||||
rest.labelIds = String(manageLabelId).trim()
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize attachments for send/draft operations
|
||||
const normalizedAttachments = normalizeFileInput(attachmentFiles || attachments)
|
||||
// Normalize attachments for send/draft operations - use canonical 'attachments' param
|
||||
const normalizedAttachments = normalizeFileInput(attachments)
|
||||
|
||||
return {
|
||||
...rest,
|
||||
@@ -493,10 +488,9 @@ Return ONLY the search query - no explanations, no extra text.`,
|
||||
},
|
||||
cc: { type: 'string', description: 'CC recipients (comma-separated)' },
|
||||
bcc: { type: 'string', description: 'BCC recipients (comma-separated)' },
|
||||
attachments: { type: 'array', description: 'Files to attach (UserFile array)' },
|
||||
attachments: { type: 'array', description: 'Files to attach (canonical param)' },
|
||||
// Read operation inputs
|
||||
folder: { type: 'string', description: 'Gmail folder' },
|
||||
manualFolder: { type: 'string', description: 'Manual folder name' },
|
||||
folder: { type: 'string', description: 'Gmail folder (canonical param)' },
|
||||
readMessageId: { type: 'string', description: 'Message identifier for reading specific email' },
|
||||
unreadOnly: { type: 'boolean', description: 'Unread messages only' },
|
||||
includeAttachments: { type: 'boolean', description: 'Include email attachments' },
|
||||
@@ -505,18 +499,16 @@ Return ONLY the search query - no explanations, no extra text.`,
|
||||
maxResults: { type: 'number', description: 'Maximum results' },
|
||||
// Move operation inputs
|
||||
moveMessageId: { type: 'string', description: 'Message ID to move' },
|
||||
destinationLabel: { type: 'string', description: 'Destination label ID' },
|
||||
manualDestinationLabel: { type: 'string', description: 'Manual destination label ID' },
|
||||
sourceLabel: { type: 'string', description: 'Source label ID to remove' },
|
||||
manualSourceLabel: { type: 'string', description: 'Manual source label ID' },
|
||||
addLabelIds: { type: 'string', description: 'Label IDs to add' },
|
||||
removeLabelIds: { type: 'string', description: 'Label IDs to remove' },
|
||||
addLabelIds: { type: 'string', description: 'Label IDs to add (canonical param)' },
|
||||
removeLabelIds: { type: 'string', description: 'Label IDs to remove (canonical param)' },
|
||||
// Action operation inputs
|
||||
actionMessageId: { type: 'string', description: 'Message ID for actions' },
|
||||
labelActionMessageId: { type: 'string', description: 'Message ID for label actions' },
|
||||
labelManagement: { type: 'string', description: 'Label ID for management' },
|
||||
manualLabelManagement: { type: 'string', description: 'Manual label ID' },
|
||||
labelIds: { type: 'string', description: 'Label IDs for add/remove operations' },
|
||||
manageLabelId: {
|
||||
type: 'string',
|
||||
description: 'Label ID for add/remove operations (canonical param)',
|
||||
},
|
||||
labelIds: { type: 'string', description: 'Label IDs to monitor (trigger)' },
|
||||
},
|
||||
outputs: {
|
||||
// Tool outputs
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user