Compare commits

..

9 Commits

Author SHA1 Message Date
Waleed
da46a387c9 v0.5.92: shortlinks, copilot scrolling stickiness, pagination 2026-02-17 15:13:21 -08:00
Waleed
a0afb5d03e feat(pipedrive): added sort order to endpoints that support it, upgraded turborepo (#3237)
* feat(pipedrive): added sort order to endpoints that support it

* upgraded turborepo

* fix
2026-02-17 14:58:54 -08:00
Waleed
cdacb796a8 improvement(providers): replace @ts-ignore with typed ProviderError class (#3235) 2026-02-17 14:20:31 -08:00
Waleed
3ce54147e6 fix(pagination): add missing next_page to response interfaces and operator comments (#3236) 2026-02-17 14:13:45 -08:00
Waleed
08690b2906 feat(pagination): update pagination for remaining integrations that support it (#3233)
* feat(pagination): update pagination for remaining integrations that support it

* fixed remaining

* ack comments
2026-02-17 13:34:46 -08:00
Waleed
299cc26694 improvement(lint): fix react-doctor errors and warnings (#3232)
* improvement(lint): fix react-doctor errors and warnings

* remove separators
2026-02-17 11:40:47 -08:00
Emir Karabeg
48715ff013 improvement(copilot): scrolling stickiness (#3218)
- Changed default stickinessThreshold from 100 to 30 in use-scroll-management.ts
- Removed explicit stickinessThreshold override (40) from copilot.tsx
- Both copilot and chat now use the same default value of 30
- This makes scrolling less sticky across all copilot message interactions

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Emir Karabeg <emir-karabeg@users.noreply.github.com>
2026-02-17 10:33:10 -08:00
Waleed
ad0d0ed1f1 feat(shortlink): add Beluga short link rewrite for hosted campaigns (#3231) 2026-02-17 10:32:32 -08:00
Waleed
b7e377ec4b v0.5.91: docs i18n, turborepo upgrade 2026-02-16 00:36:05 -08:00
175 changed files with 1076 additions and 22605 deletions

View File

@@ -1,261 +0,0 @@
---
description: Add a knowledge base connector for syncing documents from an external source
argument-hint: <service-name> [api-docs-url]
---
# Add Connector Skill
You are an expert at adding knowledge base connectors to Sim. A connector syncs documents from an external source (Confluence, Google Drive, Notion, etc.) into a knowledge base.
## Your Task
When the user asks you to create a connector:
1. Use Context7 or WebFetch to read the service's API documentation
2. Create the connector directory and config
3. Register it in the connector registry
## Directory Structure
Create files in `apps/sim/connectors/{service}/`:
```
connectors/{service}/
├── index.ts # Barrel export
└── {service}.ts # ConnectorConfig definition
```
## ConnectorConfig Structure
```typescript
import { createLogger } from '@sim/logger'
import { {Service}Icon } from '@/components/icons'
import { fetchWithRetry } from '@/lib/knowledge/documents/utils'
import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types'
const logger = createLogger('{Service}Connector')
export const {service}Connector: ConnectorConfig = {
id: '{service}',
name: '{Service}',
description: 'Sync documents from {Service} into your knowledge base',
version: '1.0.0',
icon: {Service}Icon,
oauth: {
required: true,
provider: '{service}', // Must match OAuthService in lib/oauth/types.ts
requiredScopes: ['read:...'],
},
configFields: [
// Rendered dynamically by the add-connector modal UI
// Supports 'short-input' and 'dropdown' types
],
listDocuments: async (accessToken, sourceConfig, cursor) => {
// Paginate via cursor, extract text, compute SHA-256 hash
// Return { documents: ExternalDocument[], nextCursor?, hasMore }
},
getDocument: async (accessToken, sourceConfig, externalId) => {
// Return ExternalDocument or null
},
validateConfig: async (accessToken, sourceConfig) => {
// Return { valid: true } or { valid: false, error: 'message' }
},
// Optional: map source metadata to semantic tag keys (translated to slots by sync engine)
mapTags: (metadata) => {
// Return Record<string, unknown> with keys matching tagDefinitions[].id
},
}
```
## ConfigField Types
The add-connector modal renders these automatically — no custom UI needed.
```typescript
// Text input
{
id: 'domain',
title: 'Domain',
type: 'short-input',
placeholder: 'yoursite.example.com',
required: true,
}
// Dropdown (static options)
{
id: 'contentType',
title: 'Content Type',
type: 'dropdown',
required: false,
options: [
{ label: 'Pages only', id: 'page' },
{ label: 'Blog posts only', id: 'blogpost' },
{ label: 'All content', id: 'all' },
],
}
```
## ExternalDocument Shape
Every document returned from `listDocuments`/`getDocument` must include:
```typescript
{
externalId: string // Source-specific unique ID
title: string // Document title
content: string // Extracted plain text
mimeType: 'text/plain' // Always text/plain (content is extracted)
contentHash: string // SHA-256 of content (change detection)
sourceUrl?: string // Link back to original (stored on document record)
metadata?: Record<string, unknown> // Source-specific data (fed to mapTags)
}
```
## Content Hashing (Required)
The sync engine uses content hashes for change detection:
```typescript
async function computeContentHash(content: string): Promise<string> {
const data = new TextEncoder().encode(content)
const hashBuffer = await crypto.subtle.digest('SHA-256', data)
return Array.from(new Uint8Array(hashBuffer)).map(b => b.toString(16).padStart(2, '0')).join('')
}
```
## tagDefinitions — Declared Tag Definitions
Declare which tags the connector populates using semantic IDs. Shown in the add-connector modal as opt-out checkboxes.
On connector creation, slots are **dynamically assigned** via `getNextAvailableSlot` — connectors never hardcode slot names.
```typescript
tagDefinitions: [
{ id: 'labels', displayName: 'Labels', fieldType: 'text' },
{ id: 'version', displayName: 'Version', fieldType: 'number' },
{ id: 'lastModified', displayName: 'Last Modified', fieldType: 'date' },
],
```
Each entry has:
- `id`: Semantic key matching a key returned by `mapTags` (e.g. `'labels'`, `'version'`)
- `displayName`: Human-readable name shown in the UI (e.g. "Labels", "Last Modified")
- `fieldType`: `'text'` | `'number'` | `'date'` | `'boolean'` — determines which slot pool to draw from
Users can opt out of specific tags in the modal. Disabled IDs are stored in `sourceConfig.disabledTagIds`.
The assigned mapping (`semantic id → slot`) is stored in `sourceConfig.tagSlotMapping`.
## mapTags — Metadata to Semantic Keys
Maps source metadata to semantic tag keys. Required if `tagDefinitions` is set.
The sync engine calls this automatically and translates semantic keys to actual DB slots
using the `tagSlotMapping` stored on the connector.
Return keys must match the `id` values declared in `tagDefinitions`.
```typescript
mapTags: (metadata: Record<string, unknown>): Record<string, unknown> => {
const result: Record<string, unknown> = {}
// Validate arrays before casting — metadata may be malformed
const labels = Array.isArray(metadata.labels) ? (metadata.labels as string[]) : []
if (labels.length > 0) result.labels = labels.join(', ')
// Validate numbers — guard against NaN
if (metadata.version != null) {
const num = Number(metadata.version)
if (!Number.isNaN(num)) result.version = num
}
// Validate dates — guard against Invalid Date
if (typeof metadata.lastModified === 'string') {
const date = new Date(metadata.lastModified)
if (!Number.isNaN(date.getTime())) result.lastModified = date
}
return result
}
```
## External API Calls — Use `fetchWithRetry`
All external API calls must use `fetchWithRetry` from `@/lib/knowledge/documents/utils` instead of raw `fetch()`. This provides exponential backoff with retries on 429/502/503/504 errors. It returns a standard `Response` — all `.ok`, `.json()`, `.text()` checks work unchanged.
For `validateConfig` (user-facing, called on save), pass `VALIDATE_RETRY_OPTIONS` to cap wait time at ~7s. Background operations (`listDocuments`, `getDocument`) use the built-in defaults (5 retries, ~31s max).
```typescript
import { VALIDATE_RETRY_OPTIONS, fetchWithRetry } from '@/lib/knowledge/documents/utils'
// Background sync — use defaults
const response = await fetchWithRetry(url, {
method: 'GET',
headers: { Authorization: `Bearer ${accessToken}` },
})
// validateConfig — tighter retry budget
const response = await fetchWithRetry(url, { ... }, VALIDATE_RETRY_OPTIONS)
```
## sourceUrl
If `ExternalDocument.sourceUrl` is set, the sync engine stores it on the document record. Always construct the full URL (not a relative path).
## Sync Engine Behavior (Do Not Modify)
The sync engine (`lib/knowledge/connectors/sync-engine.ts`) is connector-agnostic. It:
1. Calls `listDocuments` with pagination until `hasMore` is false
2. Compares `contentHash` to detect new/changed/unchanged documents
3. Stores `sourceUrl` and calls `mapTags` on insert/update automatically
4. Handles soft-delete of removed documents
You never need to modify the sync engine when adding a connector.
## OAuth Credential Reuse
Connectors reuse the existing OAuth infrastructure. The `oauth.provider` must match an `OAuthService` from `apps/sim/lib/oauth/types.ts`. Check existing providers before adding a new one.
## Icon
The `icon` field on `ConnectorConfig` is used throughout the UI — in the connector list, the add-connector modal, and as the document icon in the knowledge base table (replacing the generic file type icon for connector-sourced documents). The icon is read from `CONNECTOR_REGISTRY[connectorType].icon` at runtime — no separate icon map to maintain.
If the service already has an icon in `apps/sim/components/icons.tsx` (from a tool integration), reuse it. Otherwise, ask the user to provide the SVG.
## Registering
Add one line to `apps/sim/connectors/registry.ts`:
```typescript
import { {service}Connector } from '@/connectors/{service}'
export const CONNECTOR_REGISTRY: ConnectorRegistry = {
// ... existing connectors ...
{service}: {service}Connector,
}
```
## Reference Implementation
See `apps/sim/connectors/confluence/confluence.ts` for a complete example with:
- Multiple config field types (text + dropdown)
- Label fetching and CQL search filtering
- Blogpost + page content types
- `mapTags` mapping labels, version, and dates to semantic keys
## Checklist
- [ ] Created `connectors/{service}/{service}.ts` with full ConnectorConfig
- [ ] Created `connectors/{service}/index.ts` barrel export
- [ ] `oauth.provider` matches an existing OAuthService in `lib/oauth/types.ts`
- [ ] `listDocuments` handles pagination and computes content hashes
- [ ] `sourceUrl` set on each ExternalDocument (full URL, not relative)
- [ ] `metadata` includes source-specific data for tag mapping
- [ ] `tagDefinitions` declared for each semantic key returned by `mapTags`
- [ ] `mapTags` implemented if source has useful metadata (labels, dates, versions)
- [ ] `validateConfig` verifies the source is accessible
- [ ] All external API calls use `fetchWithRetry` (not raw `fetch`)
- [ ] All optional config fields validated in `validateConfig`
- [ ] Icon exists in `components/icons.tsx` (or asked user to provide SVG)
- [ ] Registered in `connectors/registry.ts`

View File

@@ -59,12 +59,6 @@ body {
--content-gap: 1.75rem; --content-gap: 1.75rem;
} }
/* Remove custom layout variable overrides to fallback to fumadocs defaults */
/* ============================================
Navbar Light Mode Styling
============================================ */
/* Light mode navbar and search styling */ /* Light mode navbar and search styling */
:root:not(.dark) nav { :root:not(.dark) nav {
background-color: hsla(0, 0%, 96%, 0.85) !important; background-color: hsla(0, 0%, 96%, 0.85) !important;
@@ -88,10 +82,6 @@ body {
-webkit-backdrop-filter: blur(25px) saturate(180%) brightness(0.6) !important; -webkit-backdrop-filter: blur(25px) saturate(180%) brightness(0.6) !important;
} }
/* ============================================
Custom Sidebar Styling (Turborepo-inspired)
============================================ */
/* Floating sidebar appearance - remove background */ /* Floating sidebar appearance - remove background */
[data-sidebar-container], [data-sidebar-container],
#nd-sidebar { #nd-sidebar {
@@ -468,10 +458,6 @@ aside[data-sidebar],
writing-mode: horizontal-tb !important; writing-mode: horizontal-tb !important;
} }
/* ============================================
Code Block Styling (Improved)
============================================ */
/* Apply Geist Mono to code elements */ /* Apply Geist Mono to code elements */
code, code,
pre, pre,
@@ -532,10 +518,6 @@ pre code .line {
color: var(--color-fd-primary); color: var(--color-fd-primary);
} }
/* ============================================
TOC (Table of Contents) Styling
============================================ */
/* Remove the thin border-left on nested TOC items (keeps main indicator only) */ /* Remove the thin border-left on nested TOC items (keeps main indicator only) */
#nd-toc a[style*="padding-inline-start"] { #nd-toc a[style*="padding-inline-start"] {
border-left: none !important; border-left: none !important;
@@ -554,10 +536,6 @@ main article,
padding-bottom: 4rem; padding-bottom: 4rem;
} }
/* ============================================
Center and Constrain Main Content Width
============================================ */
/* Main content area - center and constrain like turborepo/raindrop */ /* Main content area - center and constrain like turborepo/raindrop */
/* Note: --sidebar-offset and --toc-offset are now applied at #nd-docs-layout level */ /* Note: --sidebar-offset and --toc-offset are now applied at #nd-docs-layout level */
main[data-main] { main[data-main] {

View File

@@ -130,37 +130,4 @@ Update multiple existing records in an Airtable table
| `records` | json | Array of updated Airtable records | | `records` | json | Array of updated Airtable records |
| `metadata` | json | Operation metadata including record count and updated record IDs | | `metadata` | json | Operation metadata including record count and updated record IDs |
### `airtable_list_bases`
List all bases the authenticated user has access to
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `bases` | json | Array of Airtable bases with id, name, and permissionLevel |
| `metadata` | json | Operation metadata including total bases count |
### `airtable_get_base_schema`
Get the schema of all tables, fields, and views in an Airtable base
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `baseId` | string | Yes | Airtable base ID \(starts with "app", e.g., "appXXXXXXXXXXXXXX"\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `tables` | json | Array of table schemas with fields and views |
| `metadata` | json | Operation metadata including total tables count |

View File

@@ -234,7 +234,6 @@ List actions from incident.io. Optionally filter by incident ID.
| --------- | ---- | -------- | ----------- | | --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | incident.io API Key | | `apiKey` | string | Yes | incident.io API Key |
| `incident_id` | string | No | Filter actions by incident ID \(e.g., "01FCNDV6P870EA6S7TK1DSYDG0"\) | | `incident_id` | string | No | Filter actions by incident ID \(e.g., "01FCNDV6P870EA6S7TK1DSYDG0"\) |
| `page_size` | number | No | Number of actions to return per page \(e.g., 10, 25, 50\) |
#### Output #### Output
@@ -309,7 +308,6 @@ List follow-ups from incident.io. Optionally filter by incident ID.
| --------- | ---- | -------- | ----------- | | --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | incident.io API Key | | `apiKey` | string | Yes | incident.io API Key |
| `incident_id` | string | No | Filter follow-ups by incident ID \(e.g., "01FCNDV6P870EA6S7TK1DSYDG0"\) | | `incident_id` | string | No | Filter follow-ups by incident ID \(e.g., "01FCNDV6P870EA6S7TK1DSYDG0"\) |
| `page_size` | number | No | Number of follow-ups to return per page \(e.g., 10, 25, 50\) |
#### Output #### Output
@@ -396,6 +394,7 @@ List all users in your Incident.io workspace. Returns user details including id,
| --------- | ---- | -------- | ----------- | | --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Incident.io API Key | | `apiKey` | string | Yes | Incident.io API Key |
| `page_size` | number | No | Number of results to return per page \(e.g., 10, 25, 50\). Default: 25 | | `page_size` | number | No | Number of results to return per page \(e.g., 10, 25, 50\). Default: 25 |
| `after` | string | No | Pagination cursor to fetch the next page of results |
#### Output #### Output
@@ -406,6 +405,10 @@ List all users in your Incident.io workspace. Returns user details including id,
| ↳ `name` | string | Full name of the user | | ↳ `name` | string | Full name of the user |
| ↳ `email` | string | Email address of the user | | ↳ `email` | string | Email address of the user |
| ↳ `role` | string | Role of the user in the workspace | | ↳ `role` | string | Role of the user in the workspace |
| `pagination_meta` | object | Pagination metadata |
| ↳ `after` | string | Cursor for next page |
| ↳ `page_size` | number | Number of items per page |
| ↳ `total_record_count` | number | Total number of records |
### `incidentio_users_show` ### `incidentio_users_show`
@@ -644,7 +647,6 @@ List all escalation policies in incident.io
| Parameter | Type | Required | Description | | Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- | | --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | incident.io API Key | | `apiKey` | string | Yes | incident.io API Key |
| `page_size` | number | No | Number of results per page \(e.g., 10, 25, 50\). Default: 25 |
#### Output #### Output

View File

@@ -29,7 +29,7 @@ In Sim, the Knowledge Base block enables your agents to perform intelligent sema
## Usage Instructions ## Usage Instructions
Integrate Knowledge into the workflow. Perform full CRUD operations on documents, chunks, and tags. Integrate Knowledge into the workflow. Can search, upload chunks, and create documents.
@@ -126,161 +126,4 @@ Create a new document in a knowledge base
| `message` | string | Success or error message describing the operation result | | `message` | string | Success or error message describing the operation result |
| `documentId` | string | ID of the created document | | `documentId` | string | ID of the created document |
### `knowledge_list_tags`
List all tag definitions for a knowledge base
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `knowledgeBaseId` | string | Yes | ID of the knowledge base to list tags for |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `knowledgeBaseId` | string | ID of the knowledge base |
| `tags` | array | Array of tag definitions for the knowledge base |
| ↳ `id` | string | Tag definition ID |
| ↳ `tagSlot` | string | Internal tag slot \(e.g. tag1, number1\) |
| ↳ `displayName` | string | Human-readable tag name |
| ↳ `fieldType` | string | Tag field type \(text, number, date, boolean\) |
| ↳ `createdAt` | string | Creation timestamp |
| ↳ `updatedAt` | string | Last update timestamp |
| `totalTags` | number | Total number of tag definitions |
### `knowledge_list_documents`
List documents in a knowledge base with optional filtering, search, and pagination
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `knowledgeBaseId` | string | Yes | ID of the knowledge base to list documents from |
| `search` | string | No | Search query to filter documents by filename |
| `enabledFilter` | string | No | Filter by enabled status: "all", "enabled", or "disabled" |
| `limit` | number | No | Maximum number of documents to return \(default: 50\) |
| `offset` | number | No | Number of documents to skip for pagination \(default: 0\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `knowledgeBaseId` | string | ID of the knowledge base |
| `documents` | array | Array of documents in the knowledge base |
| ↳ `id` | string | Document ID |
| ↳ `filename` | string | Document filename |
| ↳ `fileSize` | number | File size in bytes |
| ↳ `mimeType` | string | MIME type of the document |
| ↳ `enabled` | boolean | Whether the document is enabled |
| ↳ `processingStatus` | string | Processing status \(pending, processing, completed, failed\) |
| ↳ `chunkCount` | number | Number of chunks in the document |
| ↳ `tokenCount` | number | Total token count across chunks |
| ↳ `uploadedAt` | string | Upload timestamp |
| ↳ `updatedAt` | string | Last update timestamp |
| `totalDocuments` | number | Total number of documents matching the filter |
| `limit` | number | Page size used |
| `offset` | number | Offset used for pagination |
### `knowledge_delete_document`
Delete a document from a knowledge base
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `knowledgeBaseId` | string | Yes | ID of the knowledge base containing the document |
| `documentId` | string | Yes | ID of the document to delete |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `documentId` | string | ID of the deleted document |
| `message` | string | Confirmation message |
### `knowledge_list_chunks`
List chunks for a document in a knowledge base with optional filtering and pagination
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `knowledgeBaseId` | string | Yes | ID of the knowledge base |
| `documentId` | string | Yes | ID of the document to list chunks from |
| `search` | string | No | Search query to filter chunks by content |
| `enabled` | string | No | Filter by enabled status: "true", "false", or "all" \(default: "all"\) |
| `limit` | number | No | Maximum number of chunks to return \(1-100, default: 50\) |
| `offset` | number | No | Number of chunks to skip for pagination \(default: 0\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `knowledgeBaseId` | string | ID of the knowledge base |
| `documentId` | string | ID of the document |
| `chunks` | array | Array of chunks in the document |
| ↳ `id` | string | Chunk ID |
| ↳ `chunkIndex` | number | Index of the chunk within the document |
| ↳ `content` | string | Chunk text content |
| ↳ `contentLength` | number | Content length in characters |
| ↳ `tokenCount` | number | Token count for the chunk |
| ↳ `enabled` | boolean | Whether the chunk is enabled |
| ↳ `createdAt` | string | Creation timestamp |
| ↳ `updatedAt` | string | Last update timestamp |
| `totalChunks` | number | Total number of chunks matching the filter |
| `limit` | number | Page size used |
| `offset` | number | Offset used for pagination |
### `knowledge_update_chunk`
Update the content or enabled status of a chunk in a knowledge base
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `knowledgeBaseId` | string | Yes | ID of the knowledge base |
| `documentId` | string | Yes | ID of the document containing the chunk |
| `chunkId` | string | Yes | ID of the chunk to update |
| `content` | string | No | New content for the chunk |
| `enabled` | boolean | No | Whether the chunk should be enabled or disabled |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `documentId` | string | ID of the parent document |
| `id` | string | Chunk ID |
| `chunkIndex` | number | Index of the chunk within the document |
| `content` | string | Updated chunk content |
| `contentLength` | number | Content length in characters |
| `tokenCount` | number | Token count for the chunk |
| `enabled` | boolean | Whether the chunk is enabled |
| `updatedAt` | string | Last update timestamp |
### `knowledge_delete_chunk`
Delete a chunk from a document in a knowledge base
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `knowledgeBaseId` | string | Yes | ID of the knowledge base |
| `documentId` | string | Yes | ID of the document containing the chunk |
| `chunkId` | string | Yes | ID of the chunk to delete |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `chunkId` | string | ID of the deleted chunk |
| `documentId` | string | ID of the parent document |
| `message` | string | Confirmation message |

View File

@@ -49,6 +49,7 @@ Retrieve all deals from Pipedrive with optional filters
| `pipeline_id` | string | No | If supplied, only deals in the specified pipeline are returned \(e.g., "1"\) | | `pipeline_id` | string | No | If supplied, only deals in the specified pipeline are returned \(e.g., "1"\) |
| `updated_since` | string | No | If set, only deals updated after this time are returned. Format: 2025-01-01T10:20:00Z | | `updated_since` | string | No | If set, only deals updated after this time are returned. Format: 2025-01-01T10:20:00Z |
| `limit` | string | No | Number of results to return \(e.g., "50", default: 100, max: 500\) | | `limit` | string | No | Number of results to return \(e.g., "50", default: 100, max: 500\) |
| `cursor` | string | No | For pagination, the marker representing the first item on the next page |
#### Output #### Output
@@ -74,6 +75,8 @@ Retrieve all deals from Pipedrive with optional filters
| `metadata` | object | Pagination metadata for the response | | `metadata` | object | Pagination metadata for the response |
| ↳ `total_items` | number | Total number of items | | ↳ `total_items` | number | Total number of items |
| ↳ `has_more` | boolean | Whether more items are available | | ↳ `has_more` | boolean | Whether more items are available |
| ↳ `next_cursor` | string | Cursor for fetching the next page \(v2 endpoints\) |
| ↳ `next_start` | number | Offset for fetching the next page \(v1 endpoints\) |
| `success` | boolean | Operation success status | | `success` | boolean | Operation success status |
### `pipedrive_get_deal` ### `pipedrive_get_deal`
@@ -148,10 +151,9 @@ Retrieve files from Pipedrive with optional filters
| Parameter | Type | Required | Description | | Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- | | --------- | ---- | -------- | ----------- |
| `deal_id` | string | No | Filter files by deal ID \(e.g., "123"\) | | `sort` | string | No | Sort files by field \(supported: "id", "update_time"\) |
| `person_id` | string | No | Filter files by person ID \(e.g., "456"\) | | `limit` | string | No | Number of results to return \(e.g., "50", default: 100, max: 100\) |
| `org_id` | string | No | Filter files by organization ID \(e.g., "789"\) | | `start` | string | No | Pagination start offset \(0-based index of the first item to return\) |
| `limit` | string | No | Number of results to return \(e.g., "50", default: 100, max: 500\) |
| `downloadFiles` | boolean | No | Download file contents into file outputs | | `downloadFiles` | boolean | No | Download file contents into file outputs |
#### Output #### Output
@@ -171,6 +173,8 @@ Retrieve files from Pipedrive with optional filters
| ↳ `url` | string | File download URL | | ↳ `url` | string | File download URL |
| `downloadedFiles` | file[] | Downloaded files from Pipedrive | | `downloadedFiles` | file[] | Downloaded files from Pipedrive |
| `total_items` | number | Total number of files returned | | `total_items` | number | Total number of files returned |
| `has_more` | boolean | Whether more files are available |
| `next_start` | number | Offset for fetching the next page |
| `success` | boolean | Operation success status | | `success` | boolean | Operation success status |
### `pipedrive_get_mail_messages` ### `pipedrive_get_mail_messages`
@@ -183,6 +187,7 @@ Retrieve mail threads from Pipedrive mailbox
| --------- | ---- | -------- | ----------- | | --------- | ---- | -------- | ----------- |
| `folder` | string | No | Filter by folder: inbox, drafts, sent, archive \(default: inbox\) | | `folder` | string | No | Filter by folder: inbox, drafts, sent, archive \(default: inbox\) |
| `limit` | string | No | Number of results to return \(e.g., "25", default: 50\) | | `limit` | string | No | Number of results to return \(e.g., "25", default: 50\) |
| `start` | string | No | Pagination start offset \(0-based index of the first item to return\) |
#### Output #### Output
@@ -190,6 +195,8 @@ Retrieve mail threads from Pipedrive mailbox
| --------- | ---- | ----------- | | --------- | ---- | ----------- |
| `messages` | array | Array of mail thread objects from Pipedrive mailbox | | `messages` | array | Array of mail thread objects from Pipedrive mailbox |
| `total_items` | number | Total number of mail threads returned | | `total_items` | number | Total number of mail threads returned |
| `has_more` | boolean | Whether more messages are available |
| `next_start` | number | Offset for fetching the next page |
| `success` | boolean | Operation success status | | `success` | boolean | Operation success status |
### `pipedrive_get_mail_thread` ### `pipedrive_get_mail_thread`
@@ -221,7 +228,7 @@ Retrieve all pipelines from Pipedrive
| `sort_by` | string | No | Field to sort by: id, update_time, add_time \(default: id\) | | `sort_by` | string | No | Field to sort by: id, update_time, add_time \(default: id\) |
| `sort_direction` | string | No | Sorting direction: asc, desc \(default: asc\) | | `sort_direction` | string | No | Sorting direction: asc, desc \(default: asc\) |
| `limit` | string | No | Number of results to return \(e.g., "50", default: 100, max: 500\) | | `limit` | string | No | Number of results to return \(e.g., "50", default: 100, max: 500\) |
| `cursor` | string | No | For pagination, the marker representing the first item on the next page | | `start` | string | No | Pagination start offset \(0-based index of the first item to return\) |
#### Output #### Output
@@ -237,6 +244,8 @@ Retrieve all pipelines from Pipedrive
| ↳ `add_time` | string | When the pipeline was created | | ↳ `add_time` | string | When the pipeline was created |
| ↳ `update_time` | string | When the pipeline was last updated | | ↳ `update_time` | string | When the pipeline was last updated |
| `total_items` | number | Total number of pipelines returned | | `total_items` | number | Total number of pipelines returned |
| `has_more` | boolean | Whether more pipelines are available |
| `next_start` | number | Offset for fetching the next page |
| `success` | boolean | Operation success status | | `success` | boolean | Operation success status |
### `pipedrive_get_pipeline_deals` ### `pipedrive_get_pipeline_deals`
@@ -249,8 +258,8 @@ Retrieve all deals in a specific pipeline
| --------- | ---- | -------- | ----------- | | --------- | ---- | -------- | ----------- |
| `pipeline_id` | string | Yes | The ID of the pipeline \(e.g., "1"\) | | `pipeline_id` | string | Yes | The ID of the pipeline \(e.g., "1"\) |
| `stage_id` | string | No | Filter by specific stage within the pipeline \(e.g., "2"\) | | `stage_id` | string | No | Filter by specific stage within the pipeline \(e.g., "2"\) |
| `status` | string | No | Filter by deal status: open, won, lost |
| `limit` | string | No | Number of results to return \(e.g., "50", default: 100, max: 500\) | | `limit` | string | No | Number of results to return \(e.g., "50", default: 100, max: 500\) |
| `start` | string | No | Pagination start offset \(0-based index of the first item to return\) |
#### Output #### Output
@@ -271,6 +280,7 @@ Retrieve all projects or a specific project from Pipedrive
| `project_id` | string | No | Optional: ID of a specific project to retrieve \(e.g., "123"\) | | `project_id` | string | No | Optional: ID of a specific project to retrieve \(e.g., "123"\) |
| `status` | string | No | Filter by project status: open, completed, deleted \(only for listing all\) | | `status` | string | No | Filter by project status: open, completed, deleted \(only for listing all\) |
| `limit` | string | No | Number of results to return \(e.g., "50", default: 100, max: 500, only for listing all\) | | `limit` | string | No | Number of results to return \(e.g., "50", default: 100, max: 500, only for listing all\) |
| `cursor` | string | No | For pagination, the marker representing the first item on the next page |
#### Output #### Output
@@ -279,6 +289,8 @@ Retrieve all projects or a specific project from Pipedrive
| `projects` | array | Array of project objects \(when listing all\) | | `projects` | array | Array of project objects \(when listing all\) |
| `project` | object | Single project object \(when project_id is provided\) | | `project` | object | Single project object \(when project_id is provided\) |
| `total_items` | number | Total number of projects returned | | `total_items` | number | Total number of projects returned |
| `has_more` | boolean | Whether more projects are available |
| `next_cursor` | string | Cursor for fetching the next page |
| `success` | boolean | Operation success status | | `success` | boolean | Operation success status |
### `pipedrive_create_project` ### `pipedrive_create_project`
@@ -309,12 +321,11 @@ Retrieve activities (tasks) from Pipedrive with optional filters
| Parameter | Type | Required | Description | | Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- | | --------- | ---- | -------- | ----------- |
| `deal_id` | string | No | Filter activities by deal ID \(e.g., "123"\) | | `user_id` | string | No | Filter activities by user ID \(e.g., "123"\) |
| `person_id` | string | No | Filter activities by person ID \(e.g., "456"\) |
| `org_id` | string | No | Filter activities by organization ID \(e.g., "789"\) |
| `type` | string | No | Filter by activity type \(call, meeting, task, deadline, email, lunch\) | | `type` | string | No | Filter by activity type \(call, meeting, task, deadline, email, lunch\) |
| `done` | string | No | Filter by completion status: 0 for not done, 1 for done | | `done` | string | No | Filter by completion status: 0 for not done, 1 for done |
| `limit` | string | No | Number of results to return \(e.g., "50", default: 100, max: 500\) | | `limit` | string | No | Number of results to return \(e.g., "50", default: 100, max: 500\) |
| `start` | string | No | Pagination start offset \(0-based index of the first item to return\) |
#### Output #### Output
@@ -335,6 +346,8 @@ Retrieve activities (tasks) from Pipedrive with optional filters
| ↳ `add_time` | string | When the activity was created | | ↳ `add_time` | string | When the activity was created |
| ↳ `update_time` | string | When the activity was last updated | | ↳ `update_time` | string | When the activity was last updated |
| `total_items` | number | Total number of activities returned | | `total_items` | number | Total number of activities returned |
| `has_more` | boolean | Whether more activities are available |
| `next_start` | number | Offset for fetching the next page |
| `success` | boolean | Operation success status | | `success` | boolean | Operation success status |
### `pipedrive_create_activity` ### `pipedrive_create_activity`
@@ -399,6 +412,7 @@ Retrieve all leads or a specific lead from Pipedrive
| `person_id` | string | No | Filter by person ID \(e.g., "456"\) | | `person_id` | string | No | Filter by person ID \(e.g., "456"\) |
| `organization_id` | string | No | Filter by organization ID \(e.g., "789"\) | | `organization_id` | string | No | Filter by organization ID \(e.g., "789"\) |
| `limit` | string | No | Number of results to return \(e.g., "50", default: 100, max: 500\) | | `limit` | string | No | Number of results to return \(e.g., "50", default: 100, max: 500\) |
| `start` | string | No | Pagination start offset \(0-based index of the first item to return\) |
#### Output #### Output
@@ -433,6 +447,8 @@ Retrieve all leads or a specific lead from Pipedrive
| ↳ `add_time` | string | When the lead was created \(ISO 8601\) | | ↳ `add_time` | string | When the lead was created \(ISO 8601\) |
| ↳ `update_time` | string | When the lead was last updated \(ISO 8601\) | | ↳ `update_time` | string | When the lead was last updated \(ISO 8601\) |
| `total_items` | number | Total number of leads returned | | `total_items` | number | Total number of leads returned |
| `has_more` | boolean | Whether more leads are available |
| `next_start` | number | Offset for fetching the next page |
| `success` | boolean | Operation success status | | `success` | boolean | Operation success status |
### `pipedrive_create_lead` ### `pipedrive_create_lead`

View File

@@ -57,6 +57,7 @@ Query data from a Supabase table
| `filter` | string | No | PostgREST filter \(e.g., "id=eq.123"\) | | `filter` | string | No | PostgREST filter \(e.g., "id=eq.123"\) |
| `orderBy` | string | No | Column to order by \(add DESC for descending\) | | `orderBy` | string | No | Column to order by \(add DESC for descending\) |
| `limit` | number | No | Maximum number of rows to return | | `limit` | number | No | Maximum number of rows to return |
| `offset` | number | No | Number of rows to skip \(for pagination\) |
| `apiKey` | string | Yes | Your Supabase service role secret key | | `apiKey` | string | Yes | Your Supabase service role secret key |
#### Output #### Output
@@ -211,6 +212,7 @@ Perform full-text search on a Supabase table
| `searchType` | string | No | Search type: plain, phrase, or websearch \(default: websearch\) | | `searchType` | string | No | Search type: plain, phrase, or websearch \(default: websearch\) |
| `language` | string | No | Language for text search configuration \(default: english\) | | `language` | string | No | Language for text search configuration \(default: english\) |
| `limit` | number | No | Maximum number of rows to return | | `limit` | number | No | Maximum number of rows to return |
| `offset` | number | No | Number of rows to skip \(for pagination\) |
| `apiKey` | string | Yes | Your Supabase service role secret key | | `apiKey` | string | Yes | Your Supabase service role secret key |
#### Output #### Output

View File

@@ -43,6 +43,8 @@ Retrieve form responses from Typeform
| `formId` | string | Yes | Typeform form ID \(e.g., "abc123XYZ"\) | | `formId` | string | Yes | Typeform form ID \(e.g., "abc123XYZ"\) |
| `apiKey` | string | Yes | Typeform Personal Access Token | | `apiKey` | string | Yes | Typeform Personal Access Token |
| `pageSize` | number | No | Number of responses to retrieve \(e.g., 10, 25, 50\) | | `pageSize` | number | No | Number of responses to retrieve \(e.g., 10, 25, 50\) |
| `before` | string | No | Cursor token for fetching the next page of older responses |
| `after` | string | No | Cursor token for fetching the next page of newer responses |
| `since` | string | No | Retrieve responses submitted after this date \(e.g., "2024-01-01T00:00:00Z"\) | | `since` | string | No | Retrieve responses submitted after this date \(e.g., "2024-01-01T00:00:00Z"\) |
| `until` | string | No | Retrieve responses submitted before this date \(e.g., "2024-12-31T23:59:59Z"\) | | `until` | string | No | Retrieve responses submitted before this date \(e.g., "2024-12-31T23:59:59Z"\) |
| `completed` | string | No | Filter by completion status \(e.g., "true", "false", "all"\) | | `completed` | string | No | Filter by completion status \(e.g., "true", "false", "all"\) |

View File

@@ -67,10 +67,9 @@ Retrieve a list of tickets from Zendesk with optional filtering
| `type` | string | No | Filter by type: "problem", "incident", "question", or "task" | | `type` | string | No | Filter by type: "problem", "incident", "question", or "task" |
| `assigneeId` | string | No | Filter by assignee user ID as a numeric string \(e.g., "12345"\) | | `assigneeId` | string | No | Filter by assignee user ID as a numeric string \(e.g., "12345"\) |
| `organizationId` | string | No | Filter by organization ID as a numeric string \(e.g., "67890"\) | | `organizationId` | string | No | Filter by organization ID as a numeric string \(e.g., "67890"\) |
| `sortBy` | string | No | Sort field: "created_at", "updated_at", "priority", or "status" | | `sort` | string | No | Sort field for ticket listing \(only applies without filters\): "updated_at", "id", or "status". Prefix with "-" for descending \(e.g., "-updated_at"\) |
| `sortOrder` | string | No | Sort order: "asc" or "desc" |
| `perPage` | string | No | Results per page as a number string \(default: "100", max: "100"\) | | `perPage` | string | No | Results per page as a number string \(default: "100", max: "100"\) |
| `page` | string | No | Page number as a string \(e.g., "1", "2"\) | | `pageAfter` | string | No | Cursor from a previous response to fetch the next page of results |
#### Output #### Output
@@ -129,10 +128,10 @@ Retrieve a list of tickets from Zendesk with optional filtering
| ↳ `from_messaging_channel` | boolean | Whether the ticket originated from a messaging channel | | ↳ `from_messaging_channel` | boolean | Whether the ticket originated from a messaging channel |
| ↳ `ticket_form_id` | number | Ticket form ID | | ↳ `ticket_form_id` | number | Ticket form ID |
| ↳ `generated_timestamp` | number | Unix timestamp of the ticket generation | | ↳ `generated_timestamp` | number | Unix timestamp of the ticket generation |
| `paging` | object | Pagination information | | `paging` | object | Cursor-based pagination information |
| ↳ `after_cursor` | string | Cursor for fetching the next page of results |
| ↳ `has_more` | boolean | Whether more results are available |
| ↳ `next_page` | string | URL for next page of results | | ↳ `next_page` | string | URL for next page of results |
| ↳ `previous_page` | string | URL for previous page of results |
| ↳ `count` | number | Total count of items |
| `metadata` | object | Response metadata | | `metadata` | object | Response metadata |
| ↳ `total_returned` | number | Number of items returned in this response | | ↳ `total_returned` | number | Number of items returned in this response |
| ↳ `has_more` | boolean | Whether more items are available | | ↳ `has_more` | boolean | Whether more items are available |
@@ -515,7 +514,7 @@ Retrieve a list of users from Zendesk with optional filtering
| `role` | string | No | Filter by role: "end-user", "agent", or "admin" | | `role` | string | No | Filter by role: "end-user", "agent", or "admin" |
| `permissionSet` | string | No | Filter by permission set ID as a numeric string \(e.g., "12345"\) | | `permissionSet` | string | No | Filter by permission set ID as a numeric string \(e.g., "12345"\) |
| `perPage` | string | No | Results per page as a number string \(default: "100", max: "100"\) | | `perPage` | string | No | Results per page as a number string \(default: "100", max: "100"\) |
| `page` | string | No | Page number as a string \(e.g., "1", "2"\) | | `pageAfter` | string | No | Cursor from a previous response to fetch the next page of results |
#### Output #### Output
@@ -563,10 +562,10 @@ Retrieve a list of users from Zendesk with optional filtering
| ↳ `shared` | boolean | Whether the user is shared from a different Zendesk | | ↳ `shared` | boolean | Whether the user is shared from a different Zendesk |
| ↳ `shared_agent` | boolean | Whether the agent is shared from a different Zendesk | | ↳ `shared_agent` | boolean | Whether the agent is shared from a different Zendesk |
| ↳ `remote_photo_url` | string | URL to a remote photo | | ↳ `remote_photo_url` | string | URL to a remote photo |
| `paging` | object | Pagination information | | `paging` | object | Cursor-based pagination information |
| ↳ `after_cursor` | string | Cursor for fetching the next page of results |
| ↳ `has_more` | boolean | Whether more results are available |
| ↳ `next_page` | string | URL for next page of results | | ↳ `next_page` | string | URL for next page of results |
| ↳ `previous_page` | string | URL for previous page of results |
| ↳ `count` | number | Total count of items |
| `metadata` | object | Response metadata | | `metadata` | object | Response metadata |
| ↳ `total_returned` | number | Number of items returned in this response | | ↳ `total_returned` | number | Number of items returned in this response |
| ↳ `has_more` | boolean | Whether more items are available | | ↳ `has_more` | boolean | Whether more items are available |
@@ -706,7 +705,7 @@ Search for users in Zendesk using a query string
| `query` | string | No | Search query string \(e.g., user name or email\) | | `query` | string | No | Search query string \(e.g., user name or email\) |
| `externalId` | string | No | External ID to search by \(your system identifier\) | | `externalId` | string | No | External ID to search by \(your system identifier\) |
| `perPage` | string | No | Results per page as a number string \(default: "100", max: "100"\) | | `perPage` | string | No | Results per page as a number string \(default: "100", max: "100"\) |
| `page` | string | No | Page number as a string \(e.g., "1", "2"\) | | `page` | string | No | Page number for pagination \(1-based\) |
#### Output #### Output
@@ -754,10 +753,10 @@ Search for users in Zendesk using a query string
| ↳ `shared` | boolean | Whether the user is shared from a different Zendesk | | ↳ `shared` | boolean | Whether the user is shared from a different Zendesk |
| ↳ `shared_agent` | boolean | Whether the agent is shared from a different Zendesk | | ↳ `shared_agent` | boolean | Whether the agent is shared from a different Zendesk |
| ↳ `remote_photo_url` | string | URL to a remote photo | | ↳ `remote_photo_url` | string | URL to a remote photo |
| `paging` | object | Pagination information | | `paging` | object | Cursor-based pagination information |
| ↳ `after_cursor` | string | Cursor for fetching the next page of results |
| ↳ `has_more` | boolean | Whether more results are available |
| ↳ `next_page` | string | URL for next page of results | | ↳ `next_page` | string | URL for next page of results |
| ↳ `previous_page` | string | URL for previous page of results |
| ↳ `count` | number | Total count of items |
| `metadata` | object | Response metadata | | `metadata` | object | Response metadata |
| ↳ `total_returned` | number | Number of items returned in this response | | ↳ `total_returned` | number | Number of items returned in this response |
| ↳ `has_more` | boolean | Whether more items are available | | ↳ `has_more` | boolean | Whether more items are available |
@@ -999,7 +998,7 @@ Retrieve a list of organizations from Zendesk
| `apiToken` | string | Yes | Zendesk API token | | `apiToken` | string | Yes | Zendesk API token |
| `subdomain` | string | Yes | Your Zendesk subdomain \(e.g., "mycompany" for mycompany.zendesk.com\) | | `subdomain` | string | Yes | Your Zendesk subdomain \(e.g., "mycompany" for mycompany.zendesk.com\) |
| `perPage` | string | No | Results per page as a number string \(default: "100", max: "100"\) | | `perPage` | string | No | Results per page as a number string \(default: "100", max: "100"\) |
| `page` | string | No | Page number as a string \(e.g., "1", "2"\) | | `pageAfter` | string | No | Cursor from a previous response to fetch the next page of results |
#### Output #### Output
@@ -1020,10 +1019,10 @@ Retrieve a list of organizations from Zendesk
| ↳ `created_at` | string | When the organization was created \(ISO 8601 format\) | | ↳ `created_at` | string | When the organization was created \(ISO 8601 format\) |
| ↳ `updated_at` | string | When the organization was last updated \(ISO 8601 format\) | | ↳ `updated_at` | string | When the organization was last updated \(ISO 8601 format\) |
| ↳ `external_id` | string | External ID for linking to external records | | ↳ `external_id` | string | External ID for linking to external records |
| `paging` | object | Pagination information | | `paging` | object | Cursor-based pagination information |
| ↳ `after_cursor` | string | Cursor for fetching the next page of results |
| ↳ `has_more` | boolean | Whether more results are available |
| ↳ `next_page` | string | URL for next page of results | | ↳ `next_page` | string | URL for next page of results |
| ↳ `previous_page` | string | URL for previous page of results |
| ↳ `count` | number | Total count of items |
| `metadata` | object | Response metadata | | `metadata` | object | Response metadata |
| ↳ `total_returned` | number | Number of items returned in this response | | ↳ `total_returned` | number | Number of items returned in this response |
| ↳ `has_more` | boolean | Whether more items are available | | ↳ `has_more` | boolean | Whether more items are available |
@@ -1075,7 +1074,7 @@ Autocomplete organizations in Zendesk by name prefix (for name matching/autocomp
| `subdomain` | string | Yes | Your Zendesk subdomain | | `subdomain` | string | Yes | Your Zendesk subdomain |
| `name` | string | Yes | Organization name prefix to search for \(e.g., "Acme"\) | | `name` | string | Yes | Organization name prefix to search for \(e.g., "Acme"\) |
| `perPage` | string | No | Results per page as a number string \(default: "100", max: "100"\) | | `perPage` | string | No | Results per page as a number string \(default: "100", max: "100"\) |
| `page` | string | No | Page number as a string \(e.g., "1", "2"\) | | `page` | string | No | Page number for pagination \(1-based\) |
#### Output #### Output
@@ -1096,10 +1095,10 @@ Autocomplete organizations in Zendesk by name prefix (for name matching/autocomp
| ↳ `created_at` | string | When the organization was created \(ISO 8601 format\) | | ↳ `created_at` | string | When the organization was created \(ISO 8601 format\) |
| ↳ `updated_at` | string | When the organization was last updated \(ISO 8601 format\) | | ↳ `updated_at` | string | When the organization was last updated \(ISO 8601 format\) |
| ↳ `external_id` | string | External ID for linking to external records | | ↳ `external_id` | string | External ID for linking to external records |
| `paging` | object | Pagination information | | `paging` | object | Cursor-based pagination information |
| ↳ `after_cursor` | string | Cursor for fetching the next page of results |
| ↳ `has_more` | boolean | Whether more results are available |
| ↳ `next_page` | string | URL for next page of results | | ↳ `next_page` | string | URL for next page of results |
| ↳ `previous_page` | string | URL for previous page of results |
| ↳ `count` | number | Total count of items |
| `metadata` | object | Response metadata | | `metadata` | object | Response metadata |
| ↳ `total_returned` | number | Number of items returned in this response | | ↳ `total_returned` | number | Number of items returned in this response |
| ↳ `has_more` | boolean | Whether more items are available | | ↳ `has_more` | boolean | Whether more items are available |
@@ -1249,19 +1248,18 @@ Unified search across tickets, users, and organizations in Zendesk
| `apiToken` | string | Yes | Zendesk API token | | `apiToken` | string | Yes | Zendesk API token |
| `subdomain` | string | Yes | Your Zendesk subdomain | | `subdomain` | string | Yes | Your Zendesk subdomain |
| `query` | string | Yes | Search query string using Zendesk search syntax \(e.g., "type:ticket status:open"\) | | `query` | string | Yes | Search query string using Zendesk search syntax \(e.g., "type:ticket status:open"\) |
| `sortBy` | string | No | Sort field: "relevance", "created_at", "updated_at", "priority", "status", or "ticket_type" | | `filterType` | string | Yes | Resource type to search for: "ticket", "user", "organization", or "group" |
| `sortOrder` | string | No | Sort order: "asc" or "desc" |
| `perPage` | string | No | Results per page as a number string \(default: "100", max: "100"\) | | `perPage` | string | No | Results per page as a number string \(default: "100", max: "100"\) |
| `page` | string | No | Page number as a string \(e.g., "1", "2"\) | | `pageAfter` | string | No | Cursor from a previous response to fetch the next page of results |
#### Output #### Output
| Parameter | Type | Description | | Parameter | Type | Description |
| --------- | ---- | ----------- | | --------- | ---- | ----------- |
| `paging` | object | Pagination information | | `paging` | object | Cursor-based pagination information |
| ↳ `after_cursor` | string | Cursor for fetching the next page of results |
| ↳ `has_more` | boolean | Whether more results are available |
| ↳ `next_page` | string | URL for next page of results | | ↳ `next_page` | string | URL for next page of results |
| ↳ `previous_page` | string | URL for previous page of results |
| ↳ `count` | number | Total count of items |
| `metadata` | object | Response metadata | | `metadata` | object | Response metadata |
| ↳ `total_returned` | number | Number of items returned in this response | | ↳ `total_returned` | number | Number of items returned in this response |
| ↳ `has_more` | boolean | Whether more items are available | | ↳ `has_more` | boolean | Whether more items are available |

View File

@@ -1,5 +1,3 @@
'use server'
import { env } from '@/lib/core/config/env' import { env } from '@/lib/core/config/env'
import { isProd } from '@/lib/core/config/feature-flags' import { isProd } from '@/lib/core/config/feature-flags'

View File

@@ -85,7 +85,7 @@ export const LandingNode = React.memo(function LandingNode({ data }: { data: Lan
transform: isAnimated ? 'translateY(0) scale(1)' : 'translateY(8px) scale(0.98)', transform: isAnimated ? 'translateY(0) scale(1)' : 'translateY(8px) scale(0.98)',
transition: transition:
'opacity 0.6s cubic-bezier(0.22, 1, 0.36, 1), transform 0.6s cubic-bezier(0.22, 1, 0.36, 1)', 'opacity 0.6s cubic-bezier(0.22, 1, 0.36, 1), transform 0.6s cubic-bezier(0.22, 1, 0.36, 1)',
willChange: 'transform, opacity', willChange: isAnimated ? 'auto' : 'transform, opacity',
}} }}
> >
<LandingBlock icon={data.icon} color={data.color} name={data.name} tags={data.tags} /> <LandingBlock icon={data.icon} color={data.color} name={data.name} tags={data.tags} />

View File

@@ -67,7 +67,6 @@ export const LandingEdge = React.memo(function LandingEdge(props: EdgeProps) {
strokeLinejoin: 'round', strokeLinejoin: 'round',
pointerEvents: 'none', pointerEvents: 'none',
animation: `landing-edge-dash-${id} 1s linear infinite`, animation: `landing-edge-dash-${id} 1s linear infinite`,
willChange: 'stroke-dashoffset',
...style, ...style,
}} }}
/> />

View File

@@ -754,3 +754,100 @@ input[type="search"]::-ms-clear {
text-decoration: none !important; text-decoration: none !important;
color: inherit !important; color: inherit !important;
} }
/**
* Respect user's prefers-reduced-motion setting (WCAG 2.3.3)
* Disables animations and transitions for users who prefer reduced motion.
*/
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
/* WandPromptBar status indicator */
@keyframes smoke-pulse {
0%,
100% {
transform: scale(0.8);
opacity: 0.4;
}
50% {
transform: scale(1.1);
opacity: 0.8;
}
}
.status-indicator {
position: relative;
width: 12px;
height: 12px;
border-radius: 50%;
overflow: hidden;
background-color: hsl(var(--muted-foreground) / 0.5);
transition: background-color 0.3s ease;
}
.status-indicator.streaming {
background-color: transparent;
}
.status-indicator.streaming::before {
content: "";
position: absolute;
inset: 0;
border-radius: 50%;
background: radial-gradient(
circle,
hsl(var(--primary) / 0.9) 0%,
hsl(var(--primary) / 0.4) 60%,
transparent 80%
);
animation: smoke-pulse 1.8s ease-in-out infinite;
opacity: 0.9;
}
.dark .status-indicator.streaming::before {
background: #6b7280;
opacity: 0.9;
animation: smoke-pulse 1.8s ease-in-out infinite;
}
/* MessageContainer loading dot */
@keyframes growShrink {
0%,
100% {
transform: scale(0.9);
}
50% {
transform: scale(1.1);
}
}
.loading-dot {
animation: growShrink 1.5s infinite ease-in-out;
}
/* Subflow node z-index and drag-over styles */
.workflow-container .react-flow__node-subflowNode {
z-index: -1 !important;
}
.workflow-container .react-flow__node-subflowNode:has([data-subflow-selected="true"]) {
z-index: 10 !important;
}
.loop-node-drag-over,
.parallel-node-drag-over {
box-shadow: 0 0 0 1.75px var(--brand-secondary) !important;
border-radius: 8px !important;
}
.react-flow__node[data-parent-node-id] .react-flow__handle {
z-index: 30;
}

View File

@@ -1,300 +0,0 @@
/**
* @vitest-environment node
*/
import { createMockRequest, mockConsoleLogger, mockDrizzleOrm } from '@sim/testing'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@sim/db/schema', () => ({
document: {
id: 'id',
connectorId: 'connectorId',
deletedAt: 'deletedAt',
filename: 'filename',
externalId: 'externalId',
sourceUrl: 'sourceUrl',
enabled: 'enabled',
userExcluded: 'userExcluded',
uploadedAt: 'uploadedAt',
processingStatus: 'processingStatus',
},
knowledgeConnector: {
id: 'id',
knowledgeBaseId: 'knowledgeBaseId',
deletedAt: 'deletedAt',
},
}))
vi.mock('@/app/api/knowledge/utils', () => ({
checkKnowledgeBaseAccess: vi.fn(),
checkKnowledgeBaseWriteAccess: vi.fn(),
}))
vi.mock('@/lib/auth/hybrid', () => ({
checkSessionOrInternalAuth: vi.fn(),
}))
vi.mock('@/lib/core/utils/request', () => ({
generateRequestId: vi.fn().mockReturnValue('test-req-id'),
}))
mockDrizzleOrm()
mockConsoleLogger()
describe('Connector Documents API Route', () => {
/**
* The route chains db calls in sequence. We track call order
* to return different values for connector lookup vs document queries.
*/
let limitCallCount: number
let orderByCallCount: number
const mockDbChain = {
select: vi.fn().mockReturnThis(),
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
orderBy: vi.fn(() => {
orderByCallCount++
return Promise.resolve([])
}),
limit: vi.fn(() => {
limitCallCount++
return Promise.resolve([])
}),
update: vi.fn().mockReturnThis(),
set: vi.fn().mockReturnThis(),
returning: vi.fn().mockResolvedValue([]),
}
const mockParams = Promise.resolve({ id: 'kb-123', connectorId: 'conn-456' })
beforeEach(() => {
vi.clearAllMocks()
limitCallCount = 0
orderByCallCount = 0
mockDbChain.select.mockReturnThis()
mockDbChain.from.mockReturnThis()
mockDbChain.where.mockReturnThis()
mockDbChain.orderBy.mockImplementation(() => {
orderByCallCount++
return Promise.resolve([])
})
mockDbChain.limit.mockImplementation(() => {
limitCallCount++
return Promise.resolve([])
})
mockDbChain.update.mockReturnThis()
mockDbChain.set.mockReturnThis()
mockDbChain.returning.mockResolvedValue([])
vi.doMock('@sim/db', () => ({ db: mockDbChain }))
})
afterEach(() => {
vi.clearAllMocks()
})
describe('GET', () => {
it('returns 401 when unauthenticated', async () => {
const { checkSessionOrInternalAuth } = await import('@/lib/auth/hybrid')
vi.mocked(checkSessionOrInternalAuth).mockResolvedValue({
success: false,
userId: null,
} as never)
const req = createMockRequest('GET')
const { GET } = await import(
'@/app/api/knowledge/[id]/connectors/[connectorId]/documents/route'
)
const response = await GET(req as never, { params: mockParams })
expect(response.status).toBe(401)
})
it('returns 404 when connector not found', async () => {
const { checkSessionOrInternalAuth } = await import('@/lib/auth/hybrid')
const { checkKnowledgeBaseAccess } = await import('@/app/api/knowledge/utils')
vi.mocked(checkSessionOrInternalAuth).mockResolvedValue({
success: true,
userId: 'user-1',
} as never)
vi.mocked(checkKnowledgeBaseAccess).mockResolvedValue({ hasAccess: true } as never)
mockDbChain.limit.mockResolvedValueOnce([])
const req = createMockRequest('GET')
const { GET } = await import(
'@/app/api/knowledge/[id]/connectors/[connectorId]/documents/route'
)
const response = await GET(req as never, { params: mockParams })
expect(response.status).toBe(404)
})
it('returns documents list on success', async () => {
const { checkSessionOrInternalAuth } = await import('@/lib/auth/hybrid')
const { checkKnowledgeBaseAccess } = await import('@/app/api/knowledge/utils')
vi.mocked(checkSessionOrInternalAuth).mockResolvedValue({
success: true,
userId: 'user-1',
} as never)
vi.mocked(checkKnowledgeBaseAccess).mockResolvedValue({ hasAccess: true } as never)
const doc = { id: 'doc-1', filename: 'test.txt', userExcluded: false }
mockDbChain.limit.mockResolvedValueOnce([{ id: 'conn-456' }])
mockDbChain.orderBy.mockResolvedValueOnce([doc])
const url = 'http://localhost/api/knowledge/kb-123/connectors/conn-456/documents'
const req = createMockRequest('GET', undefined, undefined, url)
Object.assign(req, { nextUrl: new URL(url) })
const { GET } = await import(
'@/app/api/knowledge/[id]/connectors/[connectorId]/documents/route'
)
const response = await GET(req as never, { params: mockParams })
const data = await response.json()
expect(response.status).toBe(200)
expect(data.data.documents).toHaveLength(1)
expect(data.data.counts.active).toBe(1)
expect(data.data.counts.excluded).toBe(0)
})
it('includes excluded documents when includeExcluded=true', async () => {
const { checkSessionOrInternalAuth } = await import('@/lib/auth/hybrid')
const { checkKnowledgeBaseAccess } = await import('@/app/api/knowledge/utils')
vi.mocked(checkSessionOrInternalAuth).mockResolvedValue({
success: true,
userId: 'user-1',
} as never)
vi.mocked(checkKnowledgeBaseAccess).mockResolvedValue({ hasAccess: true } as never)
mockDbChain.limit.mockResolvedValueOnce([{ id: 'conn-456' }])
mockDbChain.orderBy
.mockResolvedValueOnce([{ id: 'doc-1', userExcluded: false }])
.mockResolvedValueOnce([{ id: 'doc-2', userExcluded: true }])
const url =
'http://localhost/api/knowledge/kb-123/connectors/conn-456/documents?includeExcluded=true'
const req = createMockRequest('GET', undefined, undefined, url)
Object.assign(req, { nextUrl: new URL(url) })
const { GET } = await import(
'@/app/api/knowledge/[id]/connectors/[connectorId]/documents/route'
)
const response = await GET(req as never, { params: mockParams })
const data = await response.json()
expect(response.status).toBe(200)
expect(data.data.documents).toHaveLength(2)
expect(data.data.counts.active).toBe(1)
expect(data.data.counts.excluded).toBe(1)
})
})
describe('PATCH', () => {
it('returns 401 when unauthenticated', async () => {
const { checkSessionOrInternalAuth } = await import('@/lib/auth/hybrid')
vi.mocked(checkSessionOrInternalAuth).mockResolvedValue({
success: false,
userId: null,
} as never)
const req = createMockRequest('PATCH', { operation: 'restore', documentIds: ['doc-1'] })
const { PATCH } = await import(
'@/app/api/knowledge/[id]/connectors/[connectorId]/documents/route'
)
const response = await PATCH(req as never, { params: mockParams })
expect(response.status).toBe(401)
})
it('returns 400 for invalid body', async () => {
const { checkSessionOrInternalAuth } = await import('@/lib/auth/hybrid')
const { checkKnowledgeBaseWriteAccess } = await import('@/app/api/knowledge/utils')
vi.mocked(checkSessionOrInternalAuth).mockResolvedValue({
success: true,
userId: 'user-1',
} as never)
vi.mocked(checkKnowledgeBaseWriteAccess).mockResolvedValue({ hasAccess: true } as never)
mockDbChain.limit.mockResolvedValueOnce([{ id: 'conn-456' }])
const req = createMockRequest('PATCH', { documentIds: [] })
const { PATCH } = await import(
'@/app/api/knowledge/[id]/connectors/[connectorId]/documents/route'
)
const response = await PATCH(req as never, { params: mockParams })
expect(response.status).toBe(400)
})
it('returns 404 when connector not found', async () => {
const { checkSessionOrInternalAuth } = await import('@/lib/auth/hybrid')
const { checkKnowledgeBaseWriteAccess } = await import('@/app/api/knowledge/utils')
vi.mocked(checkSessionOrInternalAuth).mockResolvedValue({
success: true,
userId: 'user-1',
} as never)
vi.mocked(checkKnowledgeBaseWriteAccess).mockResolvedValue({ hasAccess: true } as never)
mockDbChain.limit.mockResolvedValueOnce([])
const req = createMockRequest('PATCH', { operation: 'restore', documentIds: ['doc-1'] })
const { PATCH } = await import(
'@/app/api/knowledge/[id]/connectors/[connectorId]/documents/route'
)
const response = await PATCH(req as never, { params: mockParams })
expect(response.status).toBe(404)
})
it('returns success for restore operation', async () => {
const { checkSessionOrInternalAuth } = await import('@/lib/auth/hybrid')
const { checkKnowledgeBaseWriteAccess } = await import('@/app/api/knowledge/utils')
vi.mocked(checkSessionOrInternalAuth).mockResolvedValue({
success: true,
userId: 'user-1',
} as never)
vi.mocked(checkKnowledgeBaseWriteAccess).mockResolvedValue({ hasAccess: true } as never)
mockDbChain.limit.mockResolvedValueOnce([{ id: 'conn-456' }])
mockDbChain.returning.mockResolvedValueOnce([{ id: 'doc-1' }])
const req = createMockRequest('PATCH', { operation: 'restore', documentIds: ['doc-1'] })
const { PATCH } = await import(
'@/app/api/knowledge/[id]/connectors/[connectorId]/documents/route'
)
const response = await PATCH(req as never, { params: mockParams })
const data = await response.json()
expect(response.status).toBe(200)
expect(data.data.restoredCount).toBe(1)
})
it('returns success for exclude operation', async () => {
const { checkSessionOrInternalAuth } = await import('@/lib/auth/hybrid')
const { checkKnowledgeBaseWriteAccess } = await import('@/app/api/knowledge/utils')
vi.mocked(checkSessionOrInternalAuth).mockResolvedValue({
success: true,
userId: 'user-1',
} as never)
vi.mocked(checkKnowledgeBaseWriteAccess).mockResolvedValue({ hasAccess: true } as never)
mockDbChain.limit.mockResolvedValueOnce([{ id: 'conn-456' }])
mockDbChain.returning.mockResolvedValueOnce([{ id: 'doc-2' }, { id: 'doc-3' }])
const req = createMockRequest('PATCH', {
operation: 'exclude',
documentIds: ['doc-2', 'doc-3'],
})
const { PATCH } = await import(
'@/app/api/knowledge/[id]/connectors/[connectorId]/documents/route'
)
const response = await PATCH(req as never, { params: mockParams })
const data = await response.json()
expect(response.status).toBe(200)
expect(data.data.excludedCount).toBe(2)
expect(data.data.documentIds).toEqual(['doc-2', 'doc-3'])
})
})
})

View File

@@ -1,210 +0,0 @@
import { db } from '@sim/db'
import { document, knowledgeConnector } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, inArray, isNull } 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 { checkKnowledgeBaseAccess, checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils'
const logger = createLogger('ConnectorDocumentsAPI')
type RouteParams = { params: Promise<{ id: string; connectorId: string }> }
/**
* GET /api/knowledge/[id]/connectors/[connectorId]/documents
* Returns documents for a connector, optionally including user-excluded ones.
*/
export async function GET(request: NextRequest, { params }: RouteParams) {
const requestId = generateRequestId()
const { id: knowledgeBaseId, connectorId } = await params
try {
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const accessCheck = await checkKnowledgeBaseAccess(knowledgeBaseId, auth.userId)
if (!accessCheck.hasAccess) {
const status = 'notFound' in accessCheck && accessCheck.notFound ? 404 : 401
return NextResponse.json({ error: status === 404 ? 'Not found' : 'Unauthorized' }, { status })
}
const connectorRows = await db
.select({ id: knowledgeConnector.id })
.from(knowledgeConnector)
.where(
and(
eq(knowledgeConnector.id, connectorId),
eq(knowledgeConnector.knowledgeBaseId, knowledgeBaseId),
isNull(knowledgeConnector.deletedAt)
)
)
.limit(1)
if (connectorRows.length === 0) {
return NextResponse.json({ error: 'Connector not found' }, { status: 404 })
}
const includeExcluded = request.nextUrl.searchParams.get('includeExcluded') === 'true'
const activeDocs = await db
.select({
id: document.id,
filename: document.filename,
externalId: document.externalId,
sourceUrl: document.sourceUrl,
enabled: document.enabled,
userExcluded: document.userExcluded,
uploadedAt: document.uploadedAt,
processingStatus: document.processingStatus,
})
.from(document)
.where(
and(
eq(document.connectorId, connectorId),
isNull(document.deletedAt),
eq(document.userExcluded, false)
)
)
.orderBy(document.filename)
const excludedDocs = includeExcluded
? await db
.select({
id: document.id,
filename: document.filename,
externalId: document.externalId,
sourceUrl: document.sourceUrl,
enabled: document.enabled,
userExcluded: document.userExcluded,
uploadedAt: document.uploadedAt,
processingStatus: document.processingStatus,
})
.from(document)
.where(
and(
eq(document.connectorId, connectorId),
eq(document.userExcluded, true),
isNull(document.deletedAt)
)
)
.orderBy(document.filename)
: []
const docs = [...activeDocs, ...excludedDocs]
const activeCount = activeDocs.length
const excludedCount = excludedDocs.length
return NextResponse.json({
success: true,
data: {
documents: docs,
counts: { active: activeCount, excluded: excludedCount },
},
})
} catch (error) {
logger.error(`[${requestId}] Error fetching connector documents`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
const PatchSchema = z.object({
operation: z.enum(['restore', 'exclude']),
documentIds: z.array(z.string()).min(1),
})
/**
* PATCH /api/knowledge/[id]/connectors/[connectorId]/documents
* Restore or exclude connector documents.
*/
export async function PATCH(request: NextRequest, { params }: RouteParams) {
const requestId = generateRequestId()
const { id: knowledgeBaseId, connectorId } = await params
try {
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const writeCheck = await checkKnowledgeBaseWriteAccess(knowledgeBaseId, auth.userId)
if (!writeCheck.hasAccess) {
const status = 'notFound' in writeCheck && writeCheck.notFound ? 404 : 401
return NextResponse.json({ error: status === 404 ? 'Not found' : 'Unauthorized' }, { status })
}
const connectorRows = await db
.select({ id: knowledgeConnector.id })
.from(knowledgeConnector)
.where(
and(
eq(knowledgeConnector.id, connectorId),
eq(knowledgeConnector.knowledgeBaseId, knowledgeBaseId),
isNull(knowledgeConnector.deletedAt)
)
)
.limit(1)
if (connectorRows.length === 0) {
return NextResponse.json({ error: 'Connector not found' }, { status: 404 })
}
const body = await request.json()
const parsed = PatchSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json(
{ error: 'Invalid request', details: parsed.error.flatten() },
{ status: 400 }
)
}
const { operation, documentIds } = parsed.data
if (operation === 'restore') {
const updated = await db
.update(document)
.set({ userExcluded: false, deletedAt: null, enabled: true })
.where(
and(
eq(document.connectorId, connectorId),
inArray(document.id, documentIds),
eq(document.userExcluded, true)
)
)
.returning({ id: document.id })
logger.info(`[${requestId}] Restored ${updated.length} excluded documents`, { connectorId })
return NextResponse.json({
success: true,
data: { restoredCount: updated.length, documentIds: updated.map((d) => d.id) },
})
}
const updated = await db
.update(document)
.set({ userExcluded: true })
.where(
and(
eq(document.connectorId, connectorId),
inArray(document.id, documentIds),
eq(document.userExcluded, false),
isNull(document.deletedAt)
)
)
.returning({ id: document.id })
logger.info(`[${requestId}] Excluded ${updated.length} documents`, { connectorId })
return NextResponse.json({
success: true,
data: { excludedCount: updated.length, documentIds: updated.map((d) => d.id) },
})
} catch (error) {
logger.error(`[${requestId}] Error updating connector documents`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -1,231 +0,0 @@
/**
* @vitest-environment node
*/
import { createMockRequest, mockConsoleLogger, mockDrizzleOrm } from '@sim/testing'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/app/api/knowledge/utils', () => ({
checkKnowledgeBaseAccess: vi.fn(),
checkKnowledgeBaseWriteAccess: vi.fn(),
}))
vi.mock('@/lib/auth/hybrid', () => ({
checkSessionOrInternalAuth: vi.fn(),
}))
vi.mock('@/lib/core/utils/request', () => ({
generateRequestId: vi.fn().mockReturnValue('test-req-id'),
}))
vi.mock('@/app/api/auth/oauth/utils', () => ({
refreshAccessTokenIfNeeded: vi.fn(),
}))
vi.mock('@/connectors/registry', () => ({
CONNECTOR_REGISTRY: {
jira: { validateConfig: vi.fn() },
},
}))
vi.mock('@sim/db/schema', () => ({
knowledgeBase: { id: 'id', userId: 'userId' },
knowledgeConnector: {
id: 'id',
knowledgeBaseId: 'knowledgeBaseId',
deletedAt: 'deletedAt',
connectorType: 'connectorType',
credentialId: 'credentialId',
},
knowledgeConnectorSyncLog: { connectorId: 'connectorId', startedAt: 'startedAt' },
}))
mockDrizzleOrm()
mockConsoleLogger()
describe('Knowledge Connector By ID API Route', () => {
const mockDbChain = {
select: vi.fn().mockReturnThis(),
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
orderBy: vi.fn().mockReturnThis(),
limit: vi.fn().mockResolvedValue([]),
insert: vi.fn().mockReturnThis(),
values: vi.fn().mockResolvedValue(undefined),
update: vi.fn().mockReturnThis(),
set: vi.fn().mockReturnThis(),
returning: vi.fn().mockResolvedValue([]),
}
const mockParams = Promise.resolve({ id: 'kb-123', connectorId: 'conn-456' })
beforeEach(() => {
vi.clearAllMocks()
vi.resetModules()
mockDbChain.select.mockReturnThis()
mockDbChain.from.mockReturnThis()
mockDbChain.where.mockReturnThis()
mockDbChain.orderBy.mockReturnThis()
mockDbChain.limit.mockResolvedValue([])
mockDbChain.update.mockReturnThis()
mockDbChain.set.mockReturnThis()
vi.doMock('@sim/db', () => ({ db: mockDbChain }))
})
afterEach(() => {
vi.clearAllMocks()
})
describe('GET', () => {
it('returns 401 when unauthenticated', async () => {
const { checkSessionOrInternalAuth } = await import('@/lib/auth/hybrid')
vi.mocked(checkSessionOrInternalAuth).mockResolvedValue({ success: false, userId: null })
const req = createMockRequest('GET')
const { GET } = await import('@/app/api/knowledge/[id]/connectors/[connectorId]/route')
const response = await GET(req, { params: mockParams })
expect(response.status).toBe(401)
})
it('returns 404 when KB not found', async () => {
const { checkSessionOrInternalAuth } = await import('@/lib/auth/hybrid')
const { checkKnowledgeBaseAccess } = await import('@/app/api/knowledge/utils')
vi.mocked(checkSessionOrInternalAuth).mockResolvedValue({ success: true, userId: 'user-1' })
vi.mocked(checkKnowledgeBaseAccess).mockResolvedValue({ hasAccess: false, notFound: true })
const req = createMockRequest('GET')
const { GET } = await import('@/app/api/knowledge/[id]/connectors/[connectorId]/route')
const response = await GET(req, { params: mockParams })
expect(response.status).toBe(404)
})
it('returns 404 when connector not found', async () => {
const { checkSessionOrInternalAuth } = await import('@/lib/auth/hybrid')
const { checkKnowledgeBaseAccess } = await import('@/app/api/knowledge/utils')
vi.mocked(checkSessionOrInternalAuth).mockResolvedValue({ success: true, userId: 'user-1' })
vi.mocked(checkKnowledgeBaseAccess).mockResolvedValue({ hasAccess: true })
mockDbChain.limit.mockResolvedValueOnce([])
const req = createMockRequest('GET')
const { GET } = await import('@/app/api/knowledge/[id]/connectors/[connectorId]/route')
const response = await GET(req, { params: mockParams })
expect(response.status).toBe(404)
})
it('returns connector with sync logs on success', async () => {
const { checkSessionOrInternalAuth } = await import('@/lib/auth/hybrid')
const { checkKnowledgeBaseAccess } = await import('@/app/api/knowledge/utils')
vi.mocked(checkSessionOrInternalAuth).mockResolvedValue({ success: true, userId: 'user-1' })
vi.mocked(checkKnowledgeBaseAccess).mockResolvedValue({ hasAccess: true })
const mockConnector = { id: 'conn-456', connectorType: 'jira', status: 'active' }
const mockLogs = [{ id: 'log-1', status: 'completed' }]
mockDbChain.limit.mockResolvedValueOnce([mockConnector]).mockResolvedValueOnce(mockLogs)
const req = createMockRequest('GET')
const { GET } = await import('@/app/api/knowledge/[id]/connectors/[connectorId]/route')
const response = await GET(req, { params: mockParams })
const data = await response.json()
expect(response.status).toBe(200)
expect(data.success).toBe(true)
expect(data.data.id).toBe('conn-456')
expect(data.data.syncLogs).toHaveLength(1)
})
})
describe('PATCH', () => {
it('returns 401 when unauthenticated', async () => {
const { checkSessionOrInternalAuth } = await import('@/lib/auth/hybrid')
vi.mocked(checkSessionOrInternalAuth).mockResolvedValue({ success: false, userId: null })
const req = createMockRequest('PATCH', { status: 'paused' })
const { PATCH } = await import('@/app/api/knowledge/[id]/connectors/[connectorId]/route')
const response = await PATCH(req, { params: mockParams })
expect(response.status).toBe(401)
})
it('returns 400 for invalid body', async () => {
const { checkSessionOrInternalAuth } = await import('@/lib/auth/hybrid')
const { checkKnowledgeBaseWriteAccess } = await import('@/app/api/knowledge/utils')
vi.mocked(checkSessionOrInternalAuth).mockResolvedValue({ success: true, userId: 'user-1' })
vi.mocked(checkKnowledgeBaseWriteAccess).mockResolvedValue({ hasAccess: true })
const req = createMockRequest('PATCH', { syncIntervalMinutes: 'not a number' })
const { PATCH } = await import('@/app/api/knowledge/[id]/connectors/[connectorId]/route')
const response = await PATCH(req, { params: mockParams })
const data = await response.json()
expect(response.status).toBe(400)
expect(data.error).toBe('Invalid request')
})
it('returns 404 when connector not found during sourceConfig validation', async () => {
const { checkSessionOrInternalAuth } = await import('@/lib/auth/hybrid')
const { checkKnowledgeBaseWriteAccess } = await import('@/app/api/knowledge/utils')
vi.mocked(checkSessionOrInternalAuth).mockResolvedValue({ success: true, userId: 'user-1' })
vi.mocked(checkKnowledgeBaseWriteAccess).mockResolvedValue({ hasAccess: true })
mockDbChain.limit.mockResolvedValueOnce([])
const req = createMockRequest('PATCH', { sourceConfig: { project: 'NEW' } })
const { PATCH } = await import('@/app/api/knowledge/[id]/connectors/[connectorId]/route')
const response = await PATCH(req, { params: mockParams })
expect(response.status).toBe(404)
})
it('returns 200 and updates status', async () => {
const { checkSessionOrInternalAuth } = await import('@/lib/auth/hybrid')
const { checkKnowledgeBaseWriteAccess } = await import('@/app/api/knowledge/utils')
vi.mocked(checkSessionOrInternalAuth).mockResolvedValue({ success: true, userId: 'user-1' })
vi.mocked(checkKnowledgeBaseWriteAccess).mockResolvedValue({ hasAccess: true })
const updatedConnector = { id: 'conn-456', status: 'paused', syncIntervalMinutes: 120 }
mockDbChain.limit.mockResolvedValueOnce([updatedConnector])
const req = createMockRequest('PATCH', { status: 'paused', syncIntervalMinutes: 120 })
const { PATCH } = await import('@/app/api/knowledge/[id]/connectors/[connectorId]/route')
const response = await PATCH(req, { params: mockParams })
const data = await response.json()
expect(response.status).toBe(200)
expect(data.success).toBe(true)
expect(data.data.status).toBe('paused')
})
})
describe('DELETE', () => {
it('returns 401 when unauthenticated', async () => {
const { checkSessionOrInternalAuth } = await import('@/lib/auth/hybrid')
vi.mocked(checkSessionOrInternalAuth).mockResolvedValue({ success: false, userId: null })
const req = createMockRequest('DELETE')
const { DELETE } = await import('@/app/api/knowledge/[id]/connectors/[connectorId]/route')
const response = await DELETE(req, { params: mockParams })
expect(response.status).toBe(401)
})
it('returns 200 on successful soft-delete', async () => {
const { checkSessionOrInternalAuth } = await import('@/lib/auth/hybrid')
const { checkKnowledgeBaseWriteAccess } = await import('@/app/api/knowledge/utils')
vi.mocked(checkSessionOrInternalAuth).mockResolvedValue({ success: true, userId: 'user-1' })
vi.mocked(checkKnowledgeBaseWriteAccess).mockResolvedValue({ hasAccess: true })
const req = createMockRequest('DELETE')
const { DELETE } = await import('@/app/api/knowledge/[id]/connectors/[connectorId]/route')
const response = await DELETE(req, { params: mockParams })
const data = await response.json()
expect(response.status).toBe(200)
expect(data.success).toBe(true)
})
})
})

View File

@@ -1,248 +0,0 @@
import { db } from '@sim/db'
import { knowledgeBase, knowledgeConnector, knowledgeConnectorSyncLog } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, desc, eq, isNull } 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 { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { checkKnowledgeBaseAccess, checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils'
import { CONNECTOR_REGISTRY } from '@/connectors/registry'
const logger = createLogger('KnowledgeConnectorByIdAPI')
type RouteParams = { params: Promise<{ id: string; connectorId: string }> }
const UpdateConnectorSchema = z.object({
sourceConfig: z.record(z.unknown()).optional(),
syncIntervalMinutes: z.number().int().min(0).optional(),
status: z.enum(['active', 'paused']).optional(),
})
/**
* GET /api/knowledge/[id]/connectors/[connectorId] - Get connector details with recent sync logs
*/
export async function GET(request: NextRequest, { params }: RouteParams) {
const requestId = generateRequestId()
const { id: knowledgeBaseId, connectorId } = await params
try {
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const accessCheck = await checkKnowledgeBaseAccess(knowledgeBaseId, auth.userId)
if (!accessCheck.hasAccess) {
const status = 'notFound' in accessCheck && accessCheck.notFound ? 404 : 401
return NextResponse.json({ error: status === 404 ? 'Not found' : 'Unauthorized' }, { status })
}
const connectorRows = await db
.select()
.from(knowledgeConnector)
.where(
and(
eq(knowledgeConnector.id, connectorId),
eq(knowledgeConnector.knowledgeBaseId, knowledgeBaseId),
isNull(knowledgeConnector.deletedAt)
)
)
.limit(1)
if (connectorRows.length === 0) {
return NextResponse.json({ error: 'Connector not found' }, { status: 404 })
}
const syncLogs = await db
.select()
.from(knowledgeConnectorSyncLog)
.where(eq(knowledgeConnectorSyncLog.connectorId, connectorId))
.orderBy(desc(knowledgeConnectorSyncLog.startedAt))
.limit(10)
return NextResponse.json({
success: true,
data: {
...connectorRows[0],
syncLogs,
},
})
} catch (error) {
logger.error(`[${requestId}] Error fetching connector`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
/**
* PATCH /api/knowledge/[id]/connectors/[connectorId] - Update a connector
*/
export async function PATCH(request: NextRequest, { params }: RouteParams) {
const requestId = generateRequestId()
const { id: knowledgeBaseId, connectorId } = await params
try {
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const writeCheck = await checkKnowledgeBaseWriteAccess(knowledgeBaseId, auth.userId)
if (!writeCheck.hasAccess) {
const status = 'notFound' in writeCheck && writeCheck.notFound ? 404 : 401
return NextResponse.json({ error: status === 404 ? 'Not found' : 'Unauthorized' }, { status })
}
const body = await request.json()
const parsed = UpdateConnectorSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json(
{ error: 'Invalid request', details: parsed.error.flatten() },
{ status: 400 }
)
}
if (parsed.data.sourceConfig !== undefined) {
const existingRows = await db
.select()
.from(knowledgeConnector)
.where(
and(
eq(knowledgeConnector.id, connectorId),
eq(knowledgeConnector.knowledgeBaseId, knowledgeBaseId),
isNull(knowledgeConnector.deletedAt)
)
)
.limit(1)
if (existingRows.length === 0) {
return NextResponse.json({ error: 'Connector not found' }, { status: 404 })
}
const existing = existingRows[0]
const connectorConfig = CONNECTOR_REGISTRY[existing.connectorType]
if (!connectorConfig) {
return NextResponse.json(
{ error: `Unknown connector type: ${existing.connectorType}` },
{ status: 400 }
)
}
const kbRows = await db
.select({ userId: knowledgeBase.userId })
.from(knowledgeBase)
.where(eq(knowledgeBase.id, knowledgeBaseId))
.limit(1)
if (kbRows.length === 0) {
return NextResponse.json({ error: 'Knowledge base not found' }, { status: 404 })
}
const accessToken = await refreshAccessTokenIfNeeded(
existing.credentialId,
kbRows[0].userId,
`patch-${connectorId}`
)
if (!accessToken) {
return NextResponse.json(
{ error: 'Failed to refresh access token. Please reconnect your account.' },
{ status: 401 }
)
}
const validation = await connectorConfig.validateConfig(accessToken, parsed.data.sourceConfig)
if (!validation.valid) {
return NextResponse.json(
{ error: validation.error || 'Invalid source configuration' },
{ status: 400 }
)
}
}
const updates: Record<string, unknown> = { updatedAt: new Date() }
if (parsed.data.sourceConfig !== undefined) {
updates.sourceConfig = parsed.data.sourceConfig
}
if (parsed.data.syncIntervalMinutes !== undefined) {
updates.syncIntervalMinutes = parsed.data.syncIntervalMinutes
if (parsed.data.syncIntervalMinutes > 0) {
updates.nextSyncAt = new Date(Date.now() + parsed.data.syncIntervalMinutes * 60 * 1000)
} else {
updates.nextSyncAt = null
}
}
if (parsed.data.status !== undefined) {
updates.status = parsed.data.status
}
await db
.update(knowledgeConnector)
.set(updates)
.where(
and(
eq(knowledgeConnector.id, connectorId),
eq(knowledgeConnector.knowledgeBaseId, knowledgeBaseId),
isNull(knowledgeConnector.deletedAt)
)
)
const updated = await db
.select()
.from(knowledgeConnector)
.where(
and(
eq(knowledgeConnector.id, connectorId),
eq(knowledgeConnector.knowledgeBaseId, knowledgeBaseId),
isNull(knowledgeConnector.deletedAt)
)
)
.limit(1)
return NextResponse.json({ success: true, data: updated[0] })
} catch (error) {
logger.error(`[${requestId}] Error updating connector`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
/**
* DELETE /api/knowledge/[id]/connectors/[connectorId] - Soft-delete a connector
*/
export async function DELETE(request: NextRequest, { params }: RouteParams) {
const requestId = generateRequestId()
const { id: knowledgeBaseId, connectorId } = await params
try {
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const writeCheck = await checkKnowledgeBaseWriteAccess(knowledgeBaseId, auth.userId)
if (!writeCheck.hasAccess) {
const status = 'notFound' in writeCheck && writeCheck.notFound ? 404 : 401
return NextResponse.json({ error: status === 404 ? 'Not found' : 'Unauthorized' }, { status })
}
await db
.update(knowledgeConnector)
.set({ deletedAt: new Date(), status: 'paused', updatedAt: new Date() })
.where(
and(
eq(knowledgeConnector.id, connectorId),
eq(knowledgeConnector.knowledgeBaseId, knowledgeBaseId),
isNull(knowledgeConnector.deletedAt)
)
)
logger.info(`[${requestId}] Soft-deleted connector ${connectorId}`)
return NextResponse.json({ success: true })
} catch (error) {
logger.error(`[${requestId}] Error deleting connector`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -1,133 +0,0 @@
/**
* @vitest-environment node
*/
import { createMockRequest, mockConsoleLogger, mockDrizzleOrm } from '@sim/testing'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@sim/db/schema', () => ({
knowledgeConnector: {
id: 'id',
knowledgeBaseId: 'knowledgeBaseId',
deletedAt: 'deletedAt',
status: 'status',
},
}))
vi.mock('@/app/api/knowledge/utils', () => ({
checkKnowledgeBaseWriteAccess: vi.fn(),
}))
vi.mock('@/lib/auth/hybrid', () => ({
checkSessionOrInternalAuth: vi.fn(),
}))
vi.mock('@/lib/core/utils/request', () => ({
generateRequestId: vi.fn().mockReturnValue('test-req-id'),
}))
vi.mock('@/lib/knowledge/connectors/sync-engine', () => ({
dispatchSync: vi.fn().mockResolvedValue(undefined),
}))
mockDrizzleOrm()
mockConsoleLogger()
describe('Connector Manual Sync API Route', () => {
const mockDbChain = {
select: vi.fn().mockReturnThis(),
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
orderBy: vi.fn().mockResolvedValue([]),
limit: vi.fn().mockResolvedValue([]),
update: vi.fn().mockReturnThis(),
set: vi.fn().mockReturnThis(),
}
const mockParams = Promise.resolve({ id: 'kb-123', connectorId: 'conn-456' })
beforeEach(() => {
vi.clearAllMocks()
mockDbChain.select.mockReturnThis()
mockDbChain.from.mockReturnThis()
mockDbChain.where.mockReturnThis()
mockDbChain.orderBy.mockResolvedValue([])
mockDbChain.limit.mockResolvedValue([])
mockDbChain.update.mockReturnThis()
mockDbChain.set.mockReturnThis()
vi.doMock('@sim/db', () => ({ db: mockDbChain }))
})
afterEach(() => {
vi.clearAllMocks()
})
it('returns 401 when unauthenticated', async () => {
const { checkSessionOrInternalAuth } = await import('@/lib/auth/hybrid')
vi.mocked(checkSessionOrInternalAuth).mockResolvedValue({
success: false,
userId: null,
} as never)
const req = createMockRequest('POST')
const { POST } = await import('@/app/api/knowledge/[id]/connectors/[connectorId]/sync/route')
const response = await POST(req as never, { params: mockParams })
expect(response.status).toBe(401)
})
it('returns 404 when connector not found', async () => {
const { checkSessionOrInternalAuth } = await import('@/lib/auth/hybrid')
const { checkKnowledgeBaseWriteAccess } = await import('@/app/api/knowledge/utils')
vi.mocked(checkSessionOrInternalAuth).mockResolvedValue({
success: true,
userId: 'user-1',
} as never)
vi.mocked(checkKnowledgeBaseWriteAccess).mockResolvedValue({ hasAccess: true } as never)
mockDbChain.limit.mockResolvedValueOnce([])
const req = createMockRequest('POST')
const { POST } = await import('@/app/api/knowledge/[id]/connectors/[connectorId]/sync/route')
const response = await POST(req as never, { params: mockParams })
expect(response.status).toBe(404)
})
it('returns 409 when connector is syncing', async () => {
const { checkSessionOrInternalAuth } = await import('@/lib/auth/hybrid')
const { checkKnowledgeBaseWriteAccess } = await import('@/app/api/knowledge/utils')
vi.mocked(checkSessionOrInternalAuth).mockResolvedValue({
success: true,
userId: 'user-1',
} as never)
vi.mocked(checkKnowledgeBaseWriteAccess).mockResolvedValue({ hasAccess: true } as never)
mockDbChain.limit.mockResolvedValueOnce([{ id: 'conn-456', status: 'syncing' }])
const req = createMockRequest('POST')
const { POST } = await import('@/app/api/knowledge/[id]/connectors/[connectorId]/sync/route')
const response = await POST(req as never, { params: mockParams })
expect(response.status).toBe(409)
})
it('dispatches sync on valid request', async () => {
const { checkSessionOrInternalAuth } = await import('@/lib/auth/hybrid')
const { checkKnowledgeBaseWriteAccess } = await import('@/app/api/knowledge/utils')
const { dispatchSync } = await import('@/lib/knowledge/connectors/sync-engine')
vi.mocked(checkSessionOrInternalAuth).mockResolvedValue({
success: true,
userId: 'user-1',
} as never)
vi.mocked(checkKnowledgeBaseWriteAccess).mockResolvedValue({ hasAccess: true } as never)
mockDbChain.limit.mockResolvedValueOnce([{ id: 'conn-456', status: 'active' }])
const req = createMockRequest('POST')
const { POST } = await import('@/app/api/knowledge/[id]/connectors/[connectorId]/sync/route')
const response = await POST(req as never, { params: mockParams })
const data = await response.json()
expect(response.status).toBe(200)
expect(data.success).toBe(true)
expect(vi.mocked(dispatchSync)).toHaveBeenCalledWith('conn-456', { requestId: 'test-req-id' })
})
})

View File

@@ -1,71 +0,0 @@
import { db } from '@sim/db'
import { knowledgeConnector } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, isNull } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { dispatchSync } from '@/lib/knowledge/connectors/sync-engine'
import { checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils'
const logger = createLogger('ConnectorManualSyncAPI')
type RouteParams = { params: Promise<{ id: string; connectorId: string }> }
/**
* POST /api/knowledge/[id]/connectors/[connectorId]/sync - Trigger a manual sync
*/
export async function POST(request: NextRequest, { params }: RouteParams) {
const requestId = generateRequestId()
const { id: knowledgeBaseId, connectorId } = await params
try {
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const writeCheck = await checkKnowledgeBaseWriteAccess(knowledgeBaseId, auth.userId)
if (!writeCheck.hasAccess) {
const status = 'notFound' in writeCheck && writeCheck.notFound ? 404 : 401
return NextResponse.json({ error: status === 404 ? 'Not found' : 'Unauthorized' }, { status })
}
const connectorRows = await db
.select()
.from(knowledgeConnector)
.where(
and(
eq(knowledgeConnector.id, connectorId),
eq(knowledgeConnector.knowledgeBaseId, knowledgeBaseId),
isNull(knowledgeConnector.deletedAt)
)
)
.limit(1)
if (connectorRows.length === 0) {
return NextResponse.json({ error: 'Connector not found' }, { status: 404 })
}
if (connectorRows[0].status === 'syncing') {
return NextResponse.json({ error: 'Sync already in progress' }, { status: 409 })
}
logger.info(`[${requestId}] Manual sync triggered for connector ${connectorId}`)
dispatchSync(connectorId, { requestId }).catch((error) => {
logger.error(
`[${requestId}] Failed to dispatch manual sync for connector ${connectorId}`,
error
)
})
return NextResponse.json({
success: true,
message: 'Sync triggered',
})
} catch (error) {
logger.error(`[${requestId}] Error triggering manual sync`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -1,204 +0,0 @@
import { db } from '@sim/db'
import { knowledgeBaseTagDefinitions, knowledgeConnector } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, desc, eq, isNull } 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 { dispatchSync } from '@/lib/knowledge/connectors/sync-engine'
import { allocateTagSlots } from '@/lib/knowledge/constants'
import { createTagDefinition } from '@/lib/knowledge/tags/service'
import { getCredential } from '@/app/api/auth/oauth/utils'
import { checkKnowledgeBaseAccess, checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils'
import { CONNECTOR_REGISTRY } from '@/connectors/registry'
const logger = createLogger('KnowledgeConnectorsAPI')
const CreateConnectorSchema = z.object({
connectorType: z.string().min(1),
credentialId: z.string().min(1),
sourceConfig: z.record(z.unknown()),
syncIntervalMinutes: z.number().int().min(0).default(1440),
})
/**
* GET /api/knowledge/[id]/connectors - List connectors for a knowledge base
*/
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const requestId = generateRequestId()
const { id: knowledgeBaseId } = await params
try {
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const accessCheck = await checkKnowledgeBaseAccess(knowledgeBaseId, auth.userId)
if (!accessCheck.hasAccess) {
const status = 'notFound' in accessCheck && accessCheck.notFound ? 404 : 401
return NextResponse.json({ error: status === 404 ? 'Not found' : 'Unauthorized' }, { status })
}
const connectors = await db
.select()
.from(knowledgeConnector)
.where(
and(
eq(knowledgeConnector.knowledgeBaseId, knowledgeBaseId),
isNull(knowledgeConnector.deletedAt)
)
)
.orderBy(desc(knowledgeConnector.createdAt))
return NextResponse.json({ success: true, data: connectors })
} catch (error) {
logger.error(`[${requestId}] Error listing connectors`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
/**
* POST /api/knowledge/[id]/connectors - Create a new connector
*/
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const requestId = generateRequestId()
const { id: knowledgeBaseId } = await params
try {
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const writeCheck = await checkKnowledgeBaseWriteAccess(knowledgeBaseId, auth.userId)
if (!writeCheck.hasAccess) {
const status = 'notFound' in writeCheck && writeCheck.notFound ? 404 : 401
return NextResponse.json({ error: status === 404 ? 'Not found' : 'Unauthorized' }, { status })
}
const body = await request.json()
const parsed = CreateConnectorSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json(
{ error: 'Invalid request', details: parsed.error.flatten() },
{ status: 400 }
)
}
const { connectorType, credentialId, sourceConfig, syncIntervalMinutes } = parsed.data
const connectorConfig = CONNECTOR_REGISTRY[connectorType]
if (!connectorConfig) {
return NextResponse.json(
{ error: `Unknown connector type: ${connectorType}` },
{ status: 400 }
)
}
const credential = await getCredential(requestId, credentialId, auth.userId)
if (!credential) {
return NextResponse.json({ error: 'Credential not found' }, { status: 400 })
}
if (!credential.accessToken) {
return NextResponse.json(
{ error: 'Credential has no access token. Please reconnect your account.' },
{ status: 400 }
)
}
const validation = await connectorConfig.validateConfig(credential.accessToken, sourceConfig)
if (!validation.valid) {
return NextResponse.json(
{ error: validation.error || 'Invalid source configuration' },
{ status: 400 }
)
}
let finalSourceConfig: Record<string, unknown> = sourceConfig
const tagSlotMapping: Record<string, string> = {}
if (connectorConfig.tagDefinitions?.length) {
const disabledIds = new Set((sourceConfig.disabledTagIds as string[] | undefined) ?? [])
const enabledDefs = connectorConfig.tagDefinitions.filter((td) => !disabledIds.has(td.id))
const existingDefs = await db
.select({ tagSlot: knowledgeBaseTagDefinitions.tagSlot })
.from(knowledgeBaseTagDefinitions)
.where(eq(knowledgeBaseTagDefinitions.knowledgeBaseId, knowledgeBaseId))
const usedSlots = new Set<string>(existingDefs.map((d) => d.tagSlot))
const { mapping, skipped: skippedTags } = allocateTagSlots(enabledDefs, usedSlots)
Object.assign(tagSlotMapping, mapping)
for (const name of skippedTags) {
logger.warn(`[${requestId}] No available slots for "${name}"`)
}
if (skippedTags.length > 0 && Object.keys(tagSlotMapping).length === 0) {
return NextResponse.json(
{ error: `No available tag slots. Could not assign: ${skippedTags.join(', ')}` },
{ status: 422 }
)
}
finalSourceConfig = { ...sourceConfig, tagSlotMapping }
}
const now = new Date()
const connectorId = crypto.randomUUID()
const nextSyncAt =
syncIntervalMinutes > 0 ? new Date(now.getTime() + syncIntervalMinutes * 60 * 1000) : null
await db.transaction(async (tx) => {
for (const [semanticId, slot] of Object.entries(tagSlotMapping)) {
const td = connectorConfig.tagDefinitions!.find((d) => d.id === semanticId)!
await createTagDefinition(
{
knowledgeBaseId,
tagSlot: slot,
displayName: td.displayName,
fieldType: td.fieldType,
},
requestId,
tx
)
}
await tx.insert(knowledgeConnector).values({
id: connectorId,
knowledgeBaseId,
connectorType,
credentialId,
sourceConfig: finalSourceConfig,
syncIntervalMinutes,
status: 'active',
nextSyncAt,
createdAt: now,
updatedAt: now,
})
})
logger.info(`[${requestId}] Created connector ${connectorId} for KB ${knowledgeBaseId}`)
dispatchSync(connectorId, { requestId }).catch((error) => {
logger.error(
`[${requestId}] Failed to dispatch initial sync for connector ${connectorId}`,
error
)
})
const created = await db
.select()
.from(knowledgeConnector)
.where(eq(knowledgeConnector.id, connectorId))
.limit(1)
return NextResponse.json({ success: true, data: created[0] }, { status: 201 })
} catch (error) {
logger.error(`[${requestId}] Error creating connector`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -12,7 +12,6 @@ import {
getDocuments, getDocuments,
getProcessingConfig, getProcessingConfig,
processDocumentsWithQueue, processDocumentsWithQueue,
type TagFilterCondition,
} from '@/lib/knowledge/documents/service' } from '@/lib/knowledge/documents/service'
import type { DocumentSortField, SortOrder } from '@/lib/knowledge/documents/types' import type { DocumentSortField, SortOrder } from '@/lib/knowledge/documents/types'
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils' import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
@@ -131,21 +130,6 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id:
? (sortOrderParam as SortOrder) ? (sortOrderParam as SortOrder)
: undefined : undefined
let tagFilters: TagFilterCondition[] | undefined
const tagFiltersParam = url.searchParams.get('tagFilters')
if (tagFiltersParam) {
try {
const parsed = JSON.parse(tagFiltersParam)
if (Array.isArray(parsed)) {
tagFilters = parsed.filter(
(f: TagFilterCondition) => f.tagSlot && f.operator && f.value !== undefined
)
}
} catch {
logger.warn(`[${requestId}] Invalid tagFilters param`)
}
}
const result = await getDocuments( const result = await getDocuments(
knowledgeBaseId, knowledgeBaseId,
{ {
@@ -155,7 +139,6 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id:
offset, offset,
...(sortBy && { sortBy }), ...(sortBy && { sortBy }),
...(sortOrder && { sortOrder }), ...(sortOrder && { sortOrder }),
tagFilters,
}, },
requestId requestId
) )

View File

@@ -1,68 +0,0 @@
import { db } from '@sim/db'
import { knowledgeConnector } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, isNull, lte } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { verifyCronAuth } from '@/lib/auth/internal'
import { generateRequestId } from '@/lib/core/utils/request'
import { dispatchSync } from '@/lib/knowledge/connectors/sync-engine'
export const dynamic = 'force-dynamic'
const logger = createLogger('ConnectorSyncSchedulerAPI')
/**
* Cron endpoint that checks for connectors due for sync and dispatches sync jobs.
* Should be called every 5 minutes by an external cron service.
*/
export async function GET(request: NextRequest) {
const requestId = generateRequestId()
logger.info(`[${requestId}] Connector sync scheduler triggered`)
const authError = verifyCronAuth(request, 'Connector sync scheduler')
if (authError) {
return authError
}
try {
const now = new Date()
const dueConnectors = await db
.select({
id: knowledgeConnector.id,
})
.from(knowledgeConnector)
.where(
and(
eq(knowledgeConnector.status, 'active'),
lte(knowledgeConnector.nextSyncAt, now),
isNull(knowledgeConnector.deletedAt)
)
)
logger.info(`[${requestId}] Found ${dueConnectors.length} connectors due for sync`)
if (dueConnectors.length === 0) {
return NextResponse.json({
success: true,
message: 'No connectors due for sync',
count: 0,
})
}
for (const connector of dueConnectors) {
dispatchSync(connector.id, { requestId }).catch((error) => {
logger.error(`[${requestId}] Failed to dispatch sync for connector ${connector.id}`, error)
})
}
return NextResponse.json({
success: true,
message: `Dispatched ${dueConnectors.length} connector sync(s)`,
count: dueConnectors.length,
})
} catch (error) {
logger.error(`[${requestId}] Connector sync scheduler error`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -22,15 +22,20 @@ interface PipedriveFile {
interface PipedriveApiResponse { interface PipedriveApiResponse {
success: boolean success: boolean
data?: PipedriveFile[] data?: PipedriveFile[]
additional_data?: {
pagination?: {
more_items_in_collection: boolean
next_start: number
}
}
error?: string error?: string
} }
const PipedriveGetFilesSchema = z.object({ const PipedriveGetFilesSchema = z.object({
accessToken: z.string().min(1, 'Access token is required'), accessToken: z.string().min(1, 'Access token is required'),
deal_id: z.string().optional().nullable(), sort: z.enum(['id', 'update_time']).optional().nullable(),
person_id: z.string().optional().nullable(),
org_id: z.string().optional().nullable(),
limit: z.string().optional().nullable(), limit: z.string().optional().nullable(),
start: z.string().optional().nullable(),
downloadFiles: z.boolean().optional().default(false), downloadFiles: z.boolean().optional().default(false),
}) })
@@ -54,20 +59,19 @@ export async function POST(request: NextRequest) {
const body = await request.json() const body = await request.json()
const validatedData = PipedriveGetFilesSchema.parse(body) const validatedData = PipedriveGetFilesSchema.parse(body)
const { accessToken, deal_id, person_id, org_id, limit, downloadFiles } = validatedData const { accessToken, sort, limit, start, downloadFiles } = validatedData
const baseUrl = 'https://api.pipedrive.com/v1/files' const baseUrl = 'https://api.pipedrive.com/v1/files'
const queryParams = new URLSearchParams() const queryParams = new URLSearchParams()
if (deal_id) queryParams.append('deal_id', deal_id) if (sort) queryParams.append('sort', sort)
if (person_id) queryParams.append('person_id', person_id)
if (org_id) queryParams.append('org_id', org_id)
if (limit) queryParams.append('limit', limit) if (limit) queryParams.append('limit', limit)
if (start) queryParams.append('start', start)
const queryString = queryParams.toString() const queryString = queryParams.toString()
const apiUrl = queryString ? `${baseUrl}?${queryString}` : baseUrl const apiUrl = queryString ? `${baseUrl}?${queryString}` : baseUrl
logger.info(`[${requestId}] Fetching files from Pipedrive`, { deal_id, person_id, org_id }) logger.info(`[${requestId}] Fetching files from Pipedrive`)
const urlValidation = await validateUrlWithDNS(apiUrl, 'apiUrl') const urlValidation = await validateUrlWithDNS(apiUrl, 'apiUrl')
if (!urlValidation.isValid) { if (!urlValidation.isValid) {
@@ -93,6 +97,8 @@ export async function POST(request: NextRequest) {
} }
const files = data.data || [] const files = data.data || []
const hasMore = data.additional_data?.pagination?.more_items_in_collection || false
const nextStart = data.additional_data?.pagination?.next_start ?? null
const downloadedFiles: Array<{ const downloadedFiles: Array<{
name: string name: string
mimeType: string mimeType: string
@@ -149,6 +155,8 @@ export async function POST(request: NextRequest) {
files, files,
downloadedFiles: downloadedFiles.length > 0 ? downloadedFiles : undefined, downloadedFiles: downloadedFiles.length > 0 ? downloadedFiles : undefined,
total_items: files.length, total_items: files.length,
has_more: hasMore,
next_start: nextStart,
success: true, success: true,
}, },
}) })

View File

@@ -30,21 +30,6 @@ export const ChatMessageContainer = memo(function ChatMessageContainer({
}: ChatMessageContainerProps) { }: ChatMessageContainerProps) {
return ( return (
<div className='relative flex flex-1 flex-col overflow-hidden bg-white'> <div className='relative flex flex-1 flex-col overflow-hidden bg-white'>
<style jsx>{`
@keyframes growShrink {
0%,
100% {
transform: scale(0.9);
}
50% {
transform: scale(1.1);
}
}
.loading-dot {
animation: growShrink 1.5s infinite ease-in-out;
}
`}</style>
{/* Scrollable Messages Area */} {/* Scrollable Messages Area */}
<div <div
ref={messagesContainerRef} ref={messagesContainerRef}

View File

@@ -71,7 +71,7 @@ export function VoiceInterface({
const [state, setState] = useState<'idle' | 'listening' | 'agent_speaking'>('idle') const [state, setState] = useState<'idle' | 'listening' | 'agent_speaking'>('idle')
const [isInitialized, setIsInitialized] = useState(false) const [isInitialized, setIsInitialized] = useState(false)
const [isMuted, setIsMuted] = useState(false) const [isMuted, setIsMuted] = useState(false)
const [audioLevels, setAudioLevels] = useState<number[]>(new Array(200).fill(0)) const [audioLevels, setAudioLevels] = useState<number[]>(() => new Array(200).fill(0))
const [permissionStatus, setPermissionStatus] = useState<'prompt' | 'granted' | 'denied'>( const [permissionStatus, setPermissionStatus] = useState<'prompt' | 'granted' | 'denied'>(
'prompt' 'prompt'
) )

View File

@@ -1,4 +1,4 @@
import { redirect } from 'next/navigation' import { redirect, unstable_rethrow } from 'next/navigation'
import { getSession } from '@/lib/auth' import { getSession } from '@/lib/auth'
import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace' import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace'
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils' import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
@@ -14,7 +14,6 @@ interface FileViewerPageProps {
export default async function FileViewerPage({ params }: FileViewerPageProps) { export default async function FileViewerPage({ params }: FileViewerPageProps) {
const { workspaceId, fileId } = await params const { workspaceId, fileId } = await params
try {
const session = await getSession() const session = await getSession()
if (!session?.user?.id) { if (!session?.user?.id) {
redirect('/') redirect('/')
@@ -25,13 +24,17 @@ export default async function FileViewerPage({ params }: FileViewerPageProps) {
redirect(`/workspace/${workspaceId}`) redirect(`/workspace/${workspaceId}`)
} }
const fileRecord = await getWorkspaceFile(workspaceId, fileId) let fileRecord: Awaited<ReturnType<typeof getWorkspaceFile>>
try {
fileRecord = await getWorkspaceFile(workspaceId, fileId)
} catch (error) {
unstable_rethrow(error)
redirect(`/workspace/${workspaceId}`)
}
if (!fileRecord) { if (!fileRecord) {
redirect(`/workspace/${workspaceId}`) redirect(`/workspace/${workspaceId}`)
} }
return <FileViewer file={fileRecord} /> return <FileViewer file={fileRecord} />
} catch (error) {
redirect(`/workspace/${workspaceId}`)
}
} }

View File

@@ -13,7 +13,7 @@ import {
Textarea, Textarea,
} from '@/components/emcn' } from '@/components/emcn'
import type { DocumentData } from '@/lib/knowledge/types' import type { DocumentData } from '@/lib/knowledge/types'
import { useCreateChunk } from '@/hooks/queries/kb/knowledge' import { useCreateChunk } from '@/hooks/queries/knowledge'
const logger = createLogger('CreateChunkModal') const logger = createLogger('CreateChunkModal')

View File

@@ -2,7 +2,7 @@
import { Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader } from '@/components/emcn' import { Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader } from '@/components/emcn'
import type { ChunkData } from '@/lib/knowledge/types' import type { ChunkData } from '@/lib/knowledge/types'
import { useDeleteChunk } from '@/hooks/queries/kb/knowledge' import { useDeleteChunk } from '@/hooks/queries/knowledge'
interface DeleteChunkModalProps { interface DeleteChunkModalProps {
chunk: ChunkData | null chunk: ChunkData | null

View File

@@ -25,7 +25,7 @@ import {
} from '@/hooks/kb/use-knowledge-base-tag-definitions' } from '@/hooks/kb/use-knowledge-base-tag-definitions'
import { useNextAvailableSlot } from '@/hooks/kb/use-next-available-slot' import { useNextAvailableSlot } from '@/hooks/kb/use-next-available-slot'
import { type TagDefinitionInput, useTagDefinitions } from '@/hooks/kb/use-tag-definitions' import { type TagDefinitionInput, useTagDefinitions } from '@/hooks/kb/use-tag-definitions'
import { useUpdateDocumentTags } from '@/hooks/queries/kb/knowledge' import { useUpdateDocumentTags } from '@/hooks/queries/knowledge'
const logger = createLogger('DocumentTagsModal') const logger = createLogger('DocumentTagsModal')

View File

@@ -18,7 +18,7 @@ import {
import type { ChunkData, DocumentData } from '@/lib/knowledge/types' import type { ChunkData, DocumentData } from '@/lib/knowledge/types'
import { getAccurateTokenCount, getTokenStrings } from '@/lib/tokenization/estimators' import { getAccurateTokenCount, getTokenStrings } from '@/lib/tokenization/estimators'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { useUpdateChunk } from '@/hooks/queries/kb/knowledge' import { useUpdateChunk } from '@/hooks/queries/knowledge'
const logger = createLogger('EditChunkModal') const logger = createLogger('EditChunkModal')

View File

@@ -4,7 +4,6 @@ import { startTransition, useCallback, useEffect, useRef, useState } from 'react
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { useQueryClient } from '@tanstack/react-query' import { useQueryClient } from '@tanstack/react-query'
import { import {
ChevronDown,
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
Circle, Circle,
@@ -25,10 +24,6 @@ import {
ModalContent, ModalContent,
ModalFooter, ModalFooter,
ModalHeader, ModalHeader,
Popover,
PopoverContent,
PopoverItem,
PopoverTrigger,
Table, Table,
TableBody, TableBody,
TableCell, TableCell,
@@ -60,7 +55,7 @@ import {
useDeleteDocument, useDeleteDocument,
useDocumentChunkSearchQuery, useDocumentChunkSearchQuery,
useUpdateChunk, useUpdateChunk,
} from '@/hooks/queries/kb/knowledge' } from '@/hooks/queries/knowledge'
const logger = createLogger('Document') const logger = createLogger('Document')
@@ -261,8 +256,6 @@ export function Document({
const [searchQuery, setSearchQuery] = useState('') const [searchQuery, setSearchQuery] = useState('')
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState('') const [debouncedSearchQuery, setDebouncedSearchQuery] = useState('')
const [enabledFilter, setEnabledFilter] = useState<'all' | 'enabled' | 'disabled'>('all')
const [isFilterPopoverOpen, setIsFilterPopoverOpen] = useState(false)
const { const {
chunks: initialChunks, chunks: initialChunks,
@@ -275,7 +268,7 @@ export function Document({
refreshChunks: initialRefreshChunks, refreshChunks: initialRefreshChunks,
updateChunk: initialUpdateChunk, updateChunk: initialUpdateChunk,
isFetching: isFetchingChunks, isFetching: isFetchingChunks,
} = useDocumentChunks(knowledgeBaseId, documentId, currentPageFromURL, '', enabledFilter) } = useDocumentChunks(knowledgeBaseId, documentId, currentPageFromURL)
const { const {
data: searchResults = [], data: searchResults = [],
@@ -697,7 +690,6 @@ export function Document({
</div> </div>
<div className='mt-[14px] flex items-center justify-between'> <div className='mt-[14px] flex items-center justify-between'>
<div className='flex items-center gap-[8px]'>
<div className='flex h-[32px] w-[400px] items-center gap-[6px] rounded-[8px] bg-[var(--surface-4)] px-[8px]'> <div className='flex h-[32px] w-[400px] items-center gap-[6px] rounded-[8px] bg-[var(--surface-4)] px-[8px]'>
<Search className='h-[14px] w-[14px] text-[var(--text-subtle)]' /> <Search className='h-[14px] w-[14px] text-[var(--text-subtle)]' />
<Input <Input
@@ -723,66 +715,12 @@ export function Document({
</button> </button>
))} ))}
</div> </div>
</div>
<div className='flex items-center gap-[8px]'>
<Popover open={isFilterPopoverOpen} onOpenChange={setIsFilterPopoverOpen}>
<PopoverTrigger asChild>
<Button variant='default' className='h-[32px] rounded-[6px]'>
{enabledFilter === 'all'
? 'Status'
: enabledFilter === 'enabled'
? 'Enabled'
: 'Disabled'}
<ChevronDown className='ml-2 h-4 w-4 text-muted-foreground' />
</Button>
</PopoverTrigger>
<PopoverContent align='end' side='bottom' sideOffset={4}>
<div className='flex flex-col gap-[2px]'>
<PopoverItem
active={enabledFilter === 'all'}
onClick={() => {
setEnabledFilter('all')
setIsFilterPopoverOpen(false)
setSelectedChunks(new Set())
goToPage(1)
}}
>
All
</PopoverItem>
<PopoverItem
active={enabledFilter === 'enabled'}
onClick={() => {
setEnabledFilter('enabled')
setIsFilterPopoverOpen(false)
setSelectedChunks(new Set())
goToPage(1)
}}
>
Enabled
</PopoverItem>
<PopoverItem
active={enabledFilter === 'disabled'}
onClick={() => {
setEnabledFilter('disabled')
setIsFilterPopoverOpen(false)
setSelectedChunks(new Set())
goToPage(1)
}}
>
Disabled
</PopoverItem>
</div>
</PopoverContent>
</Popover>
<Tooltip.Root> <Tooltip.Root>
<Tooltip.Trigger asChild> <Tooltip.Trigger asChild>
<Button <Button
onClick={() => setIsCreateChunkModalOpen(true)} onClick={() => setIsCreateChunkModalOpen(true)}
disabled={ disabled={documentData?.processingStatus === 'failed' || !userPermissions.canEdit}
documentData?.processingStatus === 'failed' || !userPermissions.canEdit
}
variant='tertiary' variant='tertiary'
className='h-[32px] rounded-[6px]' className='h-[32px] rounded-[6px]'
> >
@@ -794,7 +732,6 @@ export function Document({
)} )}
</Tooltip.Root> </Tooltip.Root>
</div> </div>
</div>
<div <div
className='mt-[12px] flex flex-1 flex-col overflow-hidden' className='mt-[12px] flex flex-1 flex-col overflow-hidden'
@@ -1122,6 +1059,7 @@ export function Document({
isLoading={isBulkOperating} isLoading={isBulkOperating}
/> />
{/* Delete Document Modal */}
<Modal open={showDeleteDocumentDialog} onOpenChange={setShowDeleteDocumentDialog}> <Modal open={showDeleteDocumentDialog} onOpenChange={setShowDeleteDocumentDialog}>
<ModalContent size='sm'> <ModalContent size='sm'>
<ModalHeader>Delete Document</ModalHeader> <ModalHeader>Delete Document</ModalHeader>
@@ -1134,14 +1072,7 @@ export function Document({
? This will permanently delete the document and all {documentData?.chunkCount ?? 0}{' '} ? This will permanently delete the document and all {documentData?.chunkCount ?? 0}{' '}
chunk chunk
{documentData?.chunkCount === 1 ? '' : 's'} within it.{' '} {documentData?.chunkCount === 1 ? '' : 's'} within it.{' '}
{documentData?.connectorId ? (
<span className='text-[var(--text-error)]'>
This document is synced from a connector. Deleting it will permanently exclude it
from future syncs. To temporarily hide it from search, disable it instead.
</span>
) : (
<span className='text-[var(--text-error)]'>This action cannot be undone.</span> <span className='text-[var(--text-error)]'>This action cannot be undone.</span>
)}
</p> </p>
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter>

View File

@@ -1,6 +1,6 @@
'use client' 'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { format } from 'date-fns' import { format } from 'date-fns'
import { import {
@@ -11,9 +11,7 @@ import {
ChevronUp, ChevronUp,
Circle, Circle,
CircleOff, CircleOff,
Filter,
Loader2, Loader2,
Plus,
RotateCcw, RotateCcw,
Search, Search,
X, X,
@@ -24,11 +22,6 @@ import {
Breadcrumb, Breadcrumb,
Button, Button,
Checkbox, Checkbox,
Combobox,
type ComboboxOption,
DatePicker,
Input,
Label,
Modal, Modal,
ModalBody, ModalBody,
ModalContent, ModalContent,
@@ -47,28 +40,25 @@ import {
Tooltip, Tooltip,
Trash, Trash,
} from '@/components/emcn' } from '@/components/emcn'
import { Input } from '@/components/ui/input'
import { SearchHighlight } from '@/components/ui/search-highlight' import { SearchHighlight } from '@/components/ui/search-highlight'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { cn } from '@/lib/core/utils/cn' import { cn } from '@/lib/core/utils/cn'
import { formatAbsoluteDate, formatRelativeTime } from '@/lib/core/utils/formatting' import { formatAbsoluteDate, formatRelativeTime } from '@/lib/core/utils/formatting'
import { ALL_TAG_SLOTS, type AllTagSlot, getFieldTypeForSlot } from '@/lib/knowledge/constants' import { ALL_TAG_SLOTS, type AllTagSlot, getFieldTypeForSlot } from '@/lib/knowledge/constants'
import type { DocumentSortField, SortOrder } from '@/lib/knowledge/documents/types' import type { DocumentSortField, SortOrder } from '@/lib/knowledge/documents/types'
import { type FilterFieldType, getOperatorsForFieldType } from '@/lib/knowledge/filters/types'
import type { DocumentData } from '@/lib/knowledge/types' import type { DocumentData } from '@/lib/knowledge/types'
import { formatFileSize } from '@/lib/uploads/utils/file-utils' import { formatFileSize } from '@/lib/uploads/utils/file-utils'
import { import {
ActionBar, ActionBar,
AddConnectorModal,
AddDocumentsModal, AddDocumentsModal,
BaseTagsModal, BaseTagsModal,
ConnectorsSection,
DocumentContextMenu, DocumentContextMenu,
RenameDocumentModal, RenameDocumentModal,
} from '@/app/workspace/[workspaceId]/knowledge/[id]/components' } from '@/app/workspace/[workspaceId]/knowledge/[id]/components'
import { getDocumentIcon } from '@/app/workspace/[workspaceId]/knowledge/components' import { getDocumentIcon } from '@/app/workspace/[workspaceId]/knowledge/components'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
import { CONNECTOR_REGISTRY } from '@/connectors/registry'
import { import {
useKnowledgeBase, useKnowledgeBase,
useKnowledgeBaseDocuments, useKnowledgeBaseDocuments,
@@ -78,14 +68,12 @@ import {
type TagDefinition, type TagDefinition,
useKnowledgeBaseTagDefinitions, useKnowledgeBaseTagDefinitions,
} from '@/hooks/kb/use-knowledge-base-tag-definitions' } from '@/hooks/kb/use-knowledge-base-tag-definitions'
import { useConnectorList } from '@/hooks/queries/kb/connectors'
import type { DocumentTagFilter } from '@/hooks/queries/kb/knowledge'
import { import {
useBulkDocumentOperation, useBulkDocumentOperation,
useDeleteDocument, useDeleteDocument,
useDeleteKnowledgeBase, useDeleteKnowledgeBase,
useUpdateDocument, useUpdateDocument,
} from '@/hooks/queries/kb/knowledge' } from '@/hooks/queries/knowledge'
const logger = createLogger('KnowledgeBase') const logger = createLogger('KnowledgeBase')
@@ -357,32 +345,6 @@ export function KnowledgeBase({
const [showTagsModal, setShowTagsModal] = useState(false) const [showTagsModal, setShowTagsModal] = useState(false)
const [enabledFilter, setEnabledFilter] = useState<'all' | 'enabled' | 'disabled'>('all') const [enabledFilter, setEnabledFilter] = useState<'all' | 'enabled' | 'disabled'>('all')
const [isFilterPopoverOpen, setIsFilterPopoverOpen] = useState(false) const [isFilterPopoverOpen, setIsFilterPopoverOpen] = useState(false)
const [isTagFilterPopoverOpen, setIsTagFilterPopoverOpen] = useState(false)
const [tagFilterEntries, setTagFilterEntries] = useState<
{
id: string
tagName: string
tagSlot: string
fieldType: FilterFieldType
operator: string
value: string
valueTo: string
}[]
>([])
const activeTagFilters: DocumentTagFilter[] = useMemo(
() =>
tagFilterEntries
.filter((f) => f.tagSlot && f.value.trim())
.map((f) => ({
tagSlot: f.tagSlot,
fieldType: f.fieldType,
operator: f.operator,
value: f.value,
...(f.operator === 'between' && f.valueTo ? { valueTo: f.valueTo } : {}),
})),
[tagFilterEntries]
)
/** /**
* Memoize the search query setter to prevent unnecessary re-renders * Memoize the search query setter to prevent unnecessary re-renders
@@ -405,7 +367,6 @@ export function KnowledgeBase({
const [contextMenuDocument, setContextMenuDocument] = useState<DocumentData | null>(null) const [contextMenuDocument, setContextMenuDocument] = useState<DocumentData | null>(null)
const [showRenameModal, setShowRenameModal] = useState(false) const [showRenameModal, setShowRenameModal] = useState(false)
const [documentToRename, setDocumentToRename] = useState<DocumentData | null>(null) const [documentToRename, setDocumentToRename] = useState<DocumentData | null>(null)
const [showAddConnectorModal, setShowAddConnectorModal] = useState(false)
const { const {
isOpen: isContextMenuOpen, isOpen: isContextMenuOpen,
@@ -446,23 +407,10 @@ export function KnowledgeBase({
return hasPending ? 3000 : false return hasPending ? 3000 : false
}, },
enabledFilter, enabledFilter,
tagFilters: activeTagFilters.length > 0 ? activeTagFilters : undefined,
}) })
const { tagDefinitions } = useKnowledgeBaseTagDefinitions(id) const { tagDefinitions } = useKnowledgeBaseTagDefinitions(id)
const { data: connectors = [], isLoading: isLoadingConnectors } = useConnectorList(id)
const hasSyncingConnectors = connectors.some((c) => c.status === 'syncing')
/** Refresh KB detail when connectors transition from syncing to done */
const prevHadSyncingRef = useRef(false)
useEffect(() => {
if (prevHadSyncingRef.current && !hasSyncingConnectors) {
refreshKnowledgeBase()
}
prevHadSyncingRef.current = hasSyncingConnectors
}, [hasSyncingConnectors, refreshKnowledgeBase])
const router = useRouter() const router = useRouter()
const knowledgeBaseName = knowledgeBase?.name || passedKnowledgeBaseName || 'Knowledge Base' const knowledgeBaseName = knowledgeBase?.name || passedKnowledgeBaseName || 'Knowledge Base'
@@ -1055,14 +1003,6 @@ export function KnowledgeBase({
)} )}
</div> </div>
<ConnectorsSection
knowledgeBaseId={id}
connectors={connectors}
isLoading={isLoadingConnectors}
canEdit={userPermissions.canEdit}
onAddConnector={() => setShowAddConnectorModal(true)}
/>
<div className='mt-[16px] flex items-center gap-[8px]'> <div className='mt-[16px] flex items-center gap-[8px]'>
<span className='text-[14px] text-[var(--text-muted)]'> <span className='text-[14px] text-[var(--text-muted)]'>
{pagination.total} {pagination.total === 1 ? 'document' : 'documents'} {pagination.total} {pagination.total === 1 ? 'document' : 'documents'}
@@ -1109,7 +1049,7 @@ export function KnowledgeBase({
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button variant='default' className='h-[32px] rounded-[6px]'> <Button variant='default' className='h-[32px] rounded-[6px]'>
{enabledFilter === 'all' {enabledFilter === 'all'
? 'Status' ? 'All'
: enabledFilter === 'enabled' : enabledFilter === 'enabled'
? 'Enabled' ? 'Enabled'
: 'Disabled'} : 'Disabled'}
@@ -1158,19 +1098,6 @@ export function KnowledgeBase({
</PopoverContent> </PopoverContent>
</Popover> </Popover>
<TagFilterPopover
tagDefinitions={tagDefinitions}
entries={tagFilterEntries}
isOpen={isTagFilterPopoverOpen}
onOpenChange={setIsTagFilterPopoverOpen}
onChange={(entries) => {
setTagFilterEntries(entries)
setCurrentPage(1)
setSelectedDocuments(new Set())
setIsSelectAllMode(false)
}}
/>
<Tooltip.Root> <Tooltip.Root>
<Tooltip.Trigger asChild> <Tooltip.Trigger asChild>
<Button <Button
@@ -1211,14 +1138,14 @@ export function KnowledgeBase({
<p className='font-medium text-[var(--text-secondary)] text-sm'> <p className='font-medium text-[var(--text-secondary)] text-sm'>
{searchQuery {searchQuery
? 'No documents found' ? 'No documents found'
: enabledFilter !== 'all' || activeTagFilters.length > 0 : enabledFilter !== 'all'
? 'Nothing matches your filter' ? 'Nothing matches your filter'
: 'No documents yet'} : 'No documents yet'}
</p> </p>
<p className='mt-1 text-[var(--text-muted)] text-xs'> <p className='mt-1 text-[var(--text-muted)] text-xs'>
{searchQuery {searchQuery
? 'Try a different search term' ? 'Try a different search term'
: enabledFilter !== 'all' || activeTagFilters.length > 0 : enabledFilter !== 'all'
? 'Try changing the filter' ? 'Try changing the filter'
: userPermissions.canEdit === true : userPermissions.canEdit === true
? 'Add documents to get started' ? 'Add documents to get started'
@@ -1291,12 +1218,6 @@ export function KnowledgeBase({
<TableCell className='w-[180px] max-w-[180px] px-[12px] py-[8px]'> <TableCell className='w-[180px] max-w-[180px] px-[12px] py-[8px]'>
<div className='flex min-w-0 items-center gap-[8px]'> <div className='flex min-w-0 items-center gap-[8px]'>
{(() => { {(() => {
const ConnectorIcon = doc.connectorType
? CONNECTOR_REGISTRY[doc.connectorType]?.icon
: null
if (ConnectorIcon) {
return <ConnectorIcon className='h-5 w-5 flex-shrink-0' />
}
const IconComponent = getDocumentIcon(doc.mimeType, doc.filename) const IconComponent = getDocumentIcon(doc.mimeType, doc.filename)
return <IconComponent className='h-6 w-5 flex-shrink-0' /> return <IconComponent className='h-6 w-5 flex-shrink-0' />
})()} })()}
@@ -1568,26 +1489,13 @@ export function KnowledgeBase({
<ModalContent size='sm'> <ModalContent size='sm'>
<ModalHeader>Delete Document</ModalHeader> <ModalHeader>Delete Document</ModalHeader>
<ModalBody> <ModalBody>
{(() => {
const docToDelete = documents.find((doc) => doc.id === documentToDelete)
return (
<p className='text-[12px] text-[var(--text-secondary)]'> <p className='text-[12px] text-[var(--text-secondary)]'>
Are you sure you want to delete{' '} Are you sure you want to delete{' '}
<span className='font-medium text-[var(--text-primary)]'> <span className='font-medium text-[var(--text-primary)]'>
{docToDelete?.filename ?? 'this document'} {documents.find((doc) => doc.id === documentToDelete)?.filename ?? 'this document'}
</span> </span>
?{' '} ? <span className='text-[var(--text-error)]'>This action cannot be undone.</span>
{docToDelete?.connectorId ? (
<span className='text-[var(--text-error)]'>
This document is synced from a connector. Deleting it will permanently exclude
it from future syncs. To temporarily hide it from search, disable it instead.
</span>
) : (
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
)}
</p> </p>
)
})()}
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter>
<Button <Button
@@ -1637,11 +1545,6 @@ export function KnowledgeBase({
chunkingConfig={knowledgeBase?.chunkingConfig} chunkingConfig={knowledgeBase?.chunkingConfig}
/> />
{/* Add Connector Modal — conditionally rendered so it remounts fresh each open */}
{showAddConnectorModal && (
<AddConnectorModal open onOpenChange={setShowAddConnectorModal} knowledgeBaseId={id} />
)}
{/* Rename Document Modal */} {/* Rename Document Modal */}
{documentToRename && ( {documentToRename && (
<RenameDocumentModal <RenameDocumentModal
@@ -1700,11 +1603,6 @@ export function KnowledgeBase({
} }
: undefined : undefined
} }
onOpenSource={
contextMenuDocument?.sourceUrl && selectedDocuments.size === 1
? () => window.open(contextMenuDocument.sourceUrl!, '_blank', 'noopener,noreferrer')
: undefined
}
onRename={ onRename={
contextMenuDocument && selectedDocuments.size === 1 && userPermissions.canEdit contextMenuDocument && selectedDocuments.size === 1 && userPermissions.canEdit
? () => handleRenameDocument(contextMenuDocument) ? () => handleRenameDocument(contextMenuDocument)
@@ -1757,224 +1655,3 @@ export function KnowledgeBase({
</div> </div>
) )
} }
interface TagFilterEntry {
id: string
tagName: string
tagSlot: string
fieldType: FilterFieldType
operator: string
value: string
valueTo: string
}
const createEmptyEntry = (): TagFilterEntry => ({
id: crypto.randomUUID(),
tagName: '',
tagSlot: '',
fieldType: 'text',
operator: 'eq',
value: '',
valueTo: '',
})
interface TagFilterPopoverProps {
tagDefinitions: TagDefinition[]
entries: TagFilterEntry[]
isOpen: boolean
onOpenChange: (open: boolean) => void
onChange: (entries: TagFilterEntry[]) => void
}
function TagFilterPopover({
tagDefinitions,
entries,
isOpen,
onOpenChange,
onChange,
}: TagFilterPopoverProps) {
const activeCount = entries.filter((f) => f.tagSlot && f.value.trim()).length
const tagOptions: ComboboxOption[] = tagDefinitions.map((t) => ({
value: t.displayName,
label: t.displayName,
}))
const filtersToShow = useMemo(
() => (entries.length > 0 ? entries : [createEmptyEntry()]),
[entries]
)
const updateEntry = (id: string, patch: Partial<TagFilterEntry>) => {
const existing = filtersToShow.find((e) => e.id === id)
if (!existing) return
const updated = filtersToShow.map((e) => (e.id === id ? { ...e, ...patch } : e))
onChange(updated)
}
const handleTagChange = (id: string, tagName: string) => {
const def = tagDefinitions.find((t) => t.displayName === tagName)
const fieldType = (def?.fieldType || 'text') as FilterFieldType
const operators = getOperatorsForFieldType(fieldType)
updateEntry(id, {
tagName,
tagSlot: def?.tagSlot || '',
fieldType,
operator: operators[0]?.value || 'eq',
value: '',
valueTo: '',
})
}
const addFilter = () => {
onChange([...filtersToShow, createEmptyEntry()])
}
const removeFilter = (id: string) => {
const remaining = filtersToShow.filter((e) => e.id !== id)
onChange(remaining.length > 0 ? remaining : [])
}
return (
<Popover open={isOpen} onOpenChange={onOpenChange}>
<PopoverTrigger asChild>
<Button variant='default' className='h-[32px] rounded-[6px]'>
<Filter className='mr-1.5 h-3.5 w-3.5' />
Tags
<ChevronDown className='ml-2 h-4 w-4 text-muted-foreground' />
</Button>
</PopoverTrigger>
<PopoverContent align='end' side='bottom' sideOffset={4} className='w-[320px] p-0'>
<div className='flex flex-col'>
<div className='flex items-center justify-between border-[var(--border-1)] border-b px-[12px] py-[8px]'>
<span className='font-medium text-[12px] text-[var(--text-secondary)]'>
Filter by tags
</span>
<div className='flex items-center gap-[4px]'>
{activeCount > 0 && (
<Button
variant='ghost'
className='h-auto px-[6px] py-[2px] text-[11px] text-[var(--text-muted)]'
onClick={() => onChange([])}
>
Clear all
</Button>
)}
<Button variant='ghost' className='h-auto p-0' onClick={addFilter}>
<Plus className='h-3.5 w-3.5' />
</Button>
</div>
</div>
<div className='flex max-h-[320px] flex-col gap-[8px] overflow-y-auto p-[12px]'>
{filtersToShow.map((entry) => {
const operators = getOperatorsForFieldType(entry.fieldType)
const operatorOptions: ComboboxOption[] = operators.map((op) => ({
value: op.value,
label: op.label,
}))
const isBetween = entry.operator === 'between'
return (
<div
key={entry.id}
className='flex flex-col gap-[6px] rounded-[6px] border border-[var(--border-1)] p-[8px]'
>
<div className='flex items-center justify-between'>
<Label className='text-[11px] text-[var(--text-muted)]'>Tag</Label>
<button
type='button'
onClick={() => removeFilter(entry.id)}
className='text-[var(--text-muted)] transition-colors hover:text-[var(--text-error)]'
>
<X className='h-3 w-3' />
</button>
</div>
<Combobox
options={tagOptions}
value={entry.tagName}
onChange={(v) => handleTagChange(entry.id, v)}
placeholder='Select tag'
/>
{entry.tagSlot && (
<>
<Label className='text-[11px] text-[var(--text-muted)]'>Operator</Label>
<Combobox
options={operatorOptions}
value={entry.operator}
onChange={(v) => updateEntry(entry.id, { operator: v, valueTo: '' })}
placeholder='Select operator'
/>
<Label className='text-[11px] text-[var(--text-muted)]'>Value</Label>
{entry.fieldType === 'date' ? (
isBetween ? (
<div className='flex items-center gap-[6px]'>
<DatePicker
size='sm'
value={entry.value || undefined}
onChange={(v) => updateEntry(entry.id, { value: v })}
placeholder='From'
/>
<span className='flex-shrink-0 text-[11px] text-[var(--text-muted)]'>
to
</span>
<DatePicker
size='sm'
value={entry.valueTo || undefined}
onChange={(v) => updateEntry(entry.id, { valueTo: v })}
placeholder='To'
/>
</div>
) : (
<DatePicker
size='sm'
value={entry.value || undefined}
onChange={(v) => updateEntry(entry.id, { value: v })}
placeholder='Select date'
/>
)
) : isBetween ? (
<div className='flex items-center gap-[6px]'>
<Input
value={entry.value}
onChange={(e) => updateEntry(entry.id, { value: e.target.value })}
placeholder='From'
className='h-[28px] text-[12px]'
/>
<span className='flex-shrink-0 text-[11px] text-[var(--text-muted)]'>
to
</span>
<Input
value={entry.valueTo}
onChange={(e) => updateEntry(entry.id, { valueTo: e.target.value })}
placeholder='To'
className='h-[28px] text-[12px]'
/>
</div>
) : (
<Input
value={entry.value}
onChange={(e) => updateEntry(entry.id, { value: e.target.value })}
placeholder={
entry.fieldType === 'boolean'
? 'true or false'
: entry.fieldType === 'number'
? 'Enter number'
: 'Enter value'
}
className='h-[28px] text-[12px]'
/>
)}
</>
)}
</div>
)
})}
</div>
</div>
</PopoverContent>
</Popover>
)
}

View File

@@ -1,348 +0,0 @@
'use client'
import { useMemo, useState } from 'react'
import { ArrowLeft, Loader2, Plus } from 'lucide-react'
import {
Button,
ButtonGroup,
ButtonGroupItem,
Checkbox,
Combobox,
type ComboboxOption,
Input,
Label,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
} from '@/components/emcn'
import {
getCanonicalScopesForProvider,
getProviderIdFromServiceId,
type OAuthProvider,
} from '@/lib/oauth'
import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal'
import { CONNECTOR_REGISTRY } from '@/connectors/registry'
import type { ConnectorConfig } from '@/connectors/types'
import { useCreateConnector } from '@/hooks/queries/kb/connectors'
import { useOAuthCredentials } from '@/hooks/queries/oauth/oauth-credentials'
const SYNC_INTERVALS = [
{ label: 'Every hour', value: 60 },
{ label: 'Every 6 hours', value: 360 },
{ label: 'Daily', value: 1440 },
{ label: 'Weekly', value: 10080 },
{ label: 'Manual only', value: 0 },
] as const
interface AddConnectorModalProps {
open: boolean
onOpenChange: (open: boolean) => void
knowledgeBaseId: string
}
type Step = 'select-type' | 'configure'
export function AddConnectorModal({ open, onOpenChange, knowledgeBaseId }: AddConnectorModalProps) {
const [step, setStep] = useState<Step>('select-type')
const [selectedType, setSelectedType] = useState<string | null>(null)
const [sourceConfig, setSourceConfig] = useState<Record<string, string>>({})
const [syncInterval, setSyncInterval] = useState(1440)
const [selectedCredentialId, setSelectedCredentialId] = useState<string | null>(null)
const [disabledTagIds, setDisabledTagIds] = useState<Set<string>>(new Set())
const [error, setError] = useState<string | null>(null)
const [showOAuthModal, setShowOAuthModal] = useState(false)
const { mutate: createConnector, isPending: isCreating } = useCreateConnector()
const connectorConfig = selectedType ? CONNECTOR_REGISTRY[selectedType] : null
const connectorProviderId = useMemo(
() =>
connectorConfig
? (getProviderIdFromServiceId(connectorConfig.oauth.provider) as OAuthProvider)
: null,
[connectorConfig]
)
const { data: credentials = [], isLoading: credentialsLoading } = useOAuthCredentials(
connectorProviderId ?? undefined,
Boolean(connectorConfig)
)
const effectiveCredentialId =
selectedCredentialId ?? (credentials.length === 1 ? credentials[0].id : null)
const handleSelectType = (type: string) => {
setSelectedType(type)
setStep('configure')
}
const canSubmit = useMemo(() => {
if (!connectorConfig || !effectiveCredentialId) return false
return connectorConfig.configFields
.filter((f) => f.required)
.every((f) => sourceConfig[f.id]?.trim())
}, [connectorConfig, effectiveCredentialId, sourceConfig])
const handleSubmit = () => {
if (!selectedType || !effectiveCredentialId || !canSubmit) return
setError(null)
const finalSourceConfig =
disabledTagIds.size > 0
? { ...sourceConfig, disabledTagIds: Array.from(disabledTagIds) }
: sourceConfig
createConnector(
{
knowledgeBaseId,
connectorType: selectedType,
credentialId: effectiveCredentialId,
sourceConfig: finalSourceConfig,
syncIntervalMinutes: syncInterval,
},
{
onSuccess: () => {
onOpenChange(false)
},
onError: (err) => {
setError(err.message)
},
}
)
}
const connectorEntries = Object.entries(CONNECTOR_REGISTRY)
return (
<>
<Modal open={open} onOpenChange={(val) => !isCreating && onOpenChange(val)}>
<ModalContent size='md'>
<ModalHeader>
{step === 'configure' && (
<Button
variant='ghost'
className='mr-2 h-6 w-6 p-0'
onClick={() => setStep('select-type')}
>
<ArrowLeft className='h-4 w-4' />
</Button>
)}
{step === 'select-type' ? 'Connect Source' : `Configure ${connectorConfig?.name}`}
</ModalHeader>
<ModalBody>
{step === 'select-type' ? (
<div className='flex flex-col gap-[8px]'>
{connectorEntries.map(([type, config]) => (
<ConnectorTypeCard
key={type}
config={config}
onClick={() => handleSelectType(type)}
/>
))}
{connectorEntries.length === 0 && (
<p className='text-[13px] text-[var(--text-muted)]'>No connectors available.</p>
)}
</div>
) : connectorConfig ? (
<div className='flex flex-col gap-[12px]'>
{/* Credential selection */}
<div className='flex flex-col gap-[4px]'>
<Label>Account</Label>
{credentialsLoading ? (
<div className='flex items-center gap-2 text-[13px] text-[var(--text-muted)]'>
<Loader2 className='h-4 w-4 animate-spin' />
Loading credentials...
</div>
) : (
<Combobox
size='sm'
options={[
...credentials.map(
(cred): ComboboxOption => ({
label: cred.name || cred.provider,
value: cred.id,
icon: connectorConfig.icon,
})
),
{
label: 'Connect new account',
value: '__connect_new__',
icon: Plus,
onSelect: () => {
setShowOAuthModal(true)
},
},
]}
value={effectiveCredentialId ?? undefined}
onChange={(value) => setSelectedCredentialId(value)}
placeholder={
credentials.length === 0
? `No ${connectorConfig.name} accounts`
: 'Select account'
}
/>
)}
</div>
{/* Config fields */}
{connectorConfig.configFields.map((field) => (
<div key={field.id} className='flex flex-col gap-[4px]'>
<Label>
{field.title}
{field.required && (
<span className='ml-[2px] text-[var(--text-error)]'>*</span>
)}
</Label>
{field.description && (
<p className='text-[11px] text-[var(--text-muted)]'>{field.description}</p>
)}
{field.type === 'dropdown' && field.options ? (
<Combobox
size='sm'
options={field.options.map((opt) => ({
label: opt.label,
value: opt.id,
}))}
value={sourceConfig[field.id] || undefined}
onChange={(value) =>
setSourceConfig((prev) => ({ ...prev, [field.id]: value }))
}
placeholder={field.placeholder || `Select ${field.title.toLowerCase()}`}
/>
) : (
<Input
value={sourceConfig[field.id] || ''}
onChange={(e) =>
setSourceConfig((prev) => ({ ...prev, [field.id]: e.target.value }))
}
placeholder={field.placeholder}
/>
)}
</div>
))}
{/* Tag definitions (opt-out) */}
{connectorConfig.tagDefinitions && connectorConfig.tagDefinitions.length > 0 && (
<div className='flex flex-col gap-[4px]'>
<Label>Metadata Tags</Label>
{connectorConfig.tagDefinitions.map((tagDef) => (
<div
key={tagDef.id}
className='flex cursor-pointer items-center gap-[8px] rounded-[4px] px-[2px] py-[2px] text-[13px]'
onClick={() => {
setDisabledTagIds((prev) => {
const next = new Set(prev)
if (prev.has(tagDef.id)) {
next.delete(tagDef.id)
} else {
next.add(tagDef.id)
}
return next
})
}}
>
<Checkbox
checked={!disabledTagIds.has(tagDef.id)}
onCheckedChange={(checked) => {
setDisabledTagIds((prev) => {
const next = new Set(prev)
if (checked) {
next.delete(tagDef.id)
} else {
next.add(tagDef.id)
}
return next
})
}}
/>
<span className='text-[var(--text-primary)]'>{tagDef.displayName}</span>
<span className='text-[11px] text-[var(--text-muted)]'>
({tagDef.fieldType})
</span>
</div>
))}
</div>
)}
{/* Sync interval */}
<div className='flex flex-col gap-[4px]'>
<Label>Sync Frequency</Label>
<ButtonGroup
value={String(syncInterval)}
onValueChange={(val) => setSyncInterval(Number(val))}
>
{SYNC_INTERVALS.map((interval) => (
<ButtonGroupItem key={interval.value} value={String(interval.value)}>
{interval.label}
</ButtonGroupItem>
))}
</ButtonGroup>
</div>
{error && (
<p className='text-[12px] text-[var(--text-error)] leading-tight'>{error}</p>
)}
</div>
) : null}
</ModalBody>
{step === 'configure' && (
<ModalFooter>
<Button variant='default' onClick={() => onOpenChange(false)} disabled={isCreating}>
Cancel
</Button>
<Button variant='tertiary' onClick={handleSubmit} disabled={!canSubmit || isCreating}>
{isCreating ? (
<>
<Loader2 className='mr-1.5 h-3.5 w-3.5 animate-spin' />
Connecting...
</>
) : (
'Connect & Sync'
)}
</Button>
</ModalFooter>
)}
</ModalContent>
</Modal>
{connectorConfig && connectorProviderId && (
<OAuthRequiredModal
isOpen={showOAuthModal}
onClose={() => setShowOAuthModal(false)}
provider={connectorProviderId}
toolName={connectorConfig.name}
requiredScopes={getCanonicalScopesForProvider(connectorProviderId)}
newScopes={connectorConfig.oauth.requiredScopes || []}
serviceId={connectorConfig.oauth.provider}
/>
)}
</>
)
}
interface ConnectorTypeCardProps {
config: ConnectorConfig
onClick: () => void
}
function ConnectorTypeCard({ config, onClick }: ConnectorTypeCardProps) {
const Icon = config.icon
return (
<button
type='button'
className='flex items-center gap-[12px] rounded-[8px] border border-[var(--border-1)] px-[14px] py-[12px] text-left transition-colors hover:bg-[var(--surface-2)]'
onClick={onClick}
>
<Icon className='h-6 w-6 flex-shrink-0' />
<div className='flex flex-col gap-[2px]'>
<span className='font-medium text-[14px] text-[var(--text-primary)]'>{config.name}</span>
<span className='text-[12px] text-[var(--text-muted)]'>{config.description}</span>
</div>
</button>
)
}

View File

@@ -22,7 +22,7 @@ import {
type TagDefinition, type TagDefinition,
useKnowledgeBaseTagDefinitions, useKnowledgeBaseTagDefinitions,
} from '@/hooks/kb/use-knowledge-base-tag-definitions' } from '@/hooks/kb/use-knowledge-base-tag-definitions'
import { useCreateTagDefinition, useDeleteTagDefinition } from '@/hooks/queries/kb/knowledge' import { useCreateTagDefinition, useDeleteTagDefinition } from '@/hooks/queries/knowledge'
const logger = createLogger('BaseTagsModal') const logger = createLogger('BaseTagsModal')

View File

@@ -1,537 +0,0 @@
'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { format, formatDistanceToNow } from 'date-fns'
import {
AlertCircle,
CheckCircle2,
ChevronDown,
Loader2,
Pause,
Play,
RefreshCw,
Settings,
Trash,
Unplug,
XCircle,
} from 'lucide-react'
import {
Badge,
Button,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
Tooltip,
} from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import {
getCanonicalScopesForProvider,
getProviderIdFromServiceId,
type OAuthProvider,
} from '@/lib/oauth'
import { EditConnectorModal } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal'
import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal'
import { CONNECTOR_REGISTRY } from '@/connectors/registry'
import type { ConnectorData, SyncLogData } from '@/hooks/queries/kb/connectors'
import {
useConnectorDetail,
useDeleteConnector,
useTriggerSync,
useUpdateConnector,
} from '@/hooks/queries/kb/connectors'
import { useOAuthCredentials } from '@/hooks/queries/oauth/oauth-credentials'
import { getMissingRequiredScopes } from '@/hooks/use-oauth-scope-status'
const logger = createLogger('ConnectorsSection')
interface ConnectorsSectionProps {
knowledgeBaseId: string
connectors: ConnectorData[]
isLoading: boolean
canEdit: boolean
onAddConnector: () => void
}
/** 5-minute cooldown after a manual sync trigger */
const SYNC_COOLDOWN_MS = 5 * 60 * 1000
const STATUS_CONFIG = {
active: { label: 'Active', variant: 'green' as const },
syncing: { label: 'Syncing', variant: 'amber' as const },
error: { label: 'Error', variant: 'red' as const },
paused: { label: 'Paused', variant: 'gray' as const },
} as const
export function ConnectorsSection({
knowledgeBaseId,
connectors,
isLoading,
canEdit,
onAddConnector,
}: ConnectorsSectionProps) {
const { mutate: triggerSync, isPending: isSyncing } = useTriggerSync()
const { mutate: updateConnector } = useUpdateConnector()
const { mutate: deleteConnector } = useDeleteConnector()
const [deleteTarget, setDeleteTarget] = useState<string | null>(null)
const [editingConnector, setEditingConnector] = useState<ConnectorData | null>(null)
const [error, setError] = useState<string | null>(null)
const syncTriggeredAt = useRef<Record<string, number>>({})
const cooldownTimers = useRef<Set<ReturnType<typeof setTimeout>>>(new Set())
const [, forceUpdate] = useState(0)
useEffect(() => {
return () => {
for (const timer of cooldownTimers.current) {
clearTimeout(timer)
}
}
}, [])
const isSyncOnCooldown = useCallback((connectorId: string) => {
const triggeredAt = syncTriggeredAt.current[connectorId]
if (!triggeredAt) return false
return Date.now() - triggeredAt < SYNC_COOLDOWN_MS
}, [])
const handleSync = useCallback(
(connectorId: string) => {
if (isSyncOnCooldown(connectorId)) return
syncTriggeredAt.current[connectorId] = Date.now()
triggerSync(
{ knowledgeBaseId, connectorId },
{
onSuccess: () => {
setError(null)
const timer = setTimeout(() => {
cooldownTimers.current.delete(timer)
forceUpdate((n) => n + 1)
}, SYNC_COOLDOWN_MS)
cooldownTimers.current.add(timer)
},
onError: (err) => {
logger.error('Sync trigger failed', { error: err.message })
setError(err.message)
delete syncTriggeredAt.current[connectorId]
forceUpdate((n) => n + 1)
},
}
)
},
[knowledgeBaseId, triggerSync, isSyncOnCooldown]
)
if (isLoading) return null
if (connectors.length === 0 && !canEdit) return null
return (
<div className='mt-[16px]'>
<div className='flex items-center justify-between'>
<h2 className='font-medium text-[14px] text-[var(--text-secondary)]'>Connected Sources</h2>
{canEdit && (
<Button
variant='default'
className='h-[28px] rounded-[6px] text-[12px]'
onClick={onAddConnector}
>
<Unplug className='mr-1 h-3.5 w-3.5' />
Connect Source
</Button>
)}
</div>
{error && (
<p className='mt-[8px] text-[12px] text-[var(--text-error)] leading-tight'>{error}</p>
)}
{connectors.length === 0 ? (
<p className='mt-[8px] text-[13px] text-[var(--text-muted)]'>
No connected sources yet. Connect an external source to automatically sync documents.
</p>
) : (
<div className='mt-[8px] flex flex-col gap-[8px]'>
{connectors.map((connector) => (
<ConnectorCard
key={connector.id}
connector={connector}
knowledgeBaseId={knowledgeBaseId}
canEdit={canEdit}
isSyncing={isSyncing}
syncCooldown={isSyncOnCooldown(connector.id)}
onSync={() => handleSync(connector.id)}
onTogglePause={() =>
updateConnector(
{
knowledgeBaseId,
connectorId: connector.id,
updates: {
status: connector.status === 'paused' ? 'active' : 'paused',
},
},
{
onSuccess: () => setError(null),
onError: (err) => {
logger.error('Toggle pause failed', { error: err.message })
setError(err.message)
},
}
)
}
onEdit={() => setEditingConnector(connector)}
onDelete={() => setDeleteTarget(connector.id)}
/>
))}
</div>
)}
{editingConnector && (
<EditConnectorModal
open={editingConnector !== null}
onOpenChange={(val) => !val && setEditingConnector(null)}
knowledgeBaseId={knowledgeBaseId}
connector={editingConnector}
/>
)}
<Modal open={deleteTarget !== null} onOpenChange={() => setDeleteTarget(null)}>
<ModalContent size='sm'>
<ModalHeader>Delete Connector</ModalHeader>
<ModalBody>
<p className='text-[14px] text-[var(--text-secondary)]'>
Are you sure you want to remove this connected source? Documents already synced will
remain in the knowledge base.
</p>
</ModalBody>
<ModalFooter>
<Button variant='default' onClick={() => setDeleteTarget(null)}>
Cancel
</Button>
<Button
variant='destructive'
onClick={() => {
if (deleteTarget) {
deleteConnector(
{ knowledgeBaseId, connectorId: deleteTarget },
{
onSuccess: () => setError(null),
onError: (err) => {
logger.error('Delete connector failed', { error: err.message })
setError(err.message)
},
}
)
setDeleteTarget(null)
}
}}
>
Delete
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</div>
)
}
interface ConnectorCardProps {
connector: ConnectorData
knowledgeBaseId: string
canEdit: boolean
isSyncing: boolean
syncCooldown: boolean
onSync: () => void
onEdit: () => void
onTogglePause: () => void
onDelete: () => void
}
function ConnectorCard({
connector,
knowledgeBaseId,
canEdit,
isSyncing,
syncCooldown,
onSync,
onEdit,
onTogglePause,
onDelete,
}: ConnectorCardProps) {
const [expanded, setExpanded] = useState(false)
const [showOAuthModal, setShowOAuthModal] = useState(false)
const connectorDef = CONNECTOR_REGISTRY[connector.connectorType]
const Icon = connectorDef?.icon
const statusConfig =
STATUS_CONFIG[connector.status as keyof typeof STATUS_CONFIG] || STATUS_CONFIG.active
const serviceId = connectorDef?.oauth.provider
const providerId = serviceId ? getProviderIdFromServiceId(serviceId) : undefined
const requiredScopes = connectorDef?.oauth.requiredScopes ?? []
const { data: credentials } = useOAuthCredentials(providerId)
const missingScopes = useMemo(() => {
if (!credentials || !connector.credentialId) return []
const credential = credentials.find((c) => c.id === connector.credentialId)
return getMissingRequiredScopes(credential, requiredScopes)
}, [credentials, connector.credentialId, requiredScopes])
const { data: detail, isLoading: detailLoading } = useConnectorDetail(
expanded ? knowledgeBaseId : undefined,
expanded ? connector.id : undefined
)
const syncLogs = detail?.syncLogs ?? []
return (
<div className='rounded-[8px] border border-[var(--border-1)]'>
<div className='flex items-center justify-between px-[12px] py-[10px]'>
<div className='flex items-center gap-[10px]'>
{Icon && <Icon className='h-5 w-5 flex-shrink-0' />}
<div className='flex flex-col gap-[2px]'>
<div className='flex items-center gap-[8px]'>
<span className='font-medium text-[13px] text-[var(--text-primary)]'>
{connectorDef?.name || connector.connectorType}
</span>
<Badge variant={statusConfig.variant} className='text-[10px]'>
{connector.status === 'syncing' && (
<Loader2 className='mr-1 h-3 w-3 animate-spin' />
)}
{statusConfig.label}
</Badge>
</div>
<div className='flex items-center gap-[6px] text-[11px] text-[var(--text-muted)]'>
{connector.lastSyncAt && (
<span>Last sync: {format(new Date(connector.lastSyncAt), 'MMM d, h:mm a')}</span>
)}
{connector.lastSyncDocCount !== null && (
<>
<span>·</span>
<span>{connector.lastSyncDocCount} docs</span>
</>
)}
{connector.nextSyncAt && connector.status === 'active' && (
<>
<span>·</span>
<span>
Next sync:{' '}
{formatDistanceToNow(new Date(connector.nextSyncAt), { addSuffix: true })}
</span>
</>
)}
{connector.lastSyncError && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<AlertCircle className='h-3 w-3 text-[var(--text-error)]' />
</Tooltip.Trigger>
<Tooltip.Content>{connector.lastSyncError}</Tooltip.Content>
</Tooltip.Root>
)}
</div>
</div>
</div>
<div className='flex items-center gap-[4px]'>
{canEdit && (
<>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
className='h-7 w-7 p-0'
onClick={onSync}
disabled={connector.status === 'syncing' || isSyncing || syncCooldown}
>
<RefreshCw
className={cn(
'h-3.5 w-3.5',
connector.status === 'syncing' && 'animate-spin'
)}
/>
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
{syncCooldown ? 'Sync recently triggered' : 'Sync now'}
</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button variant='ghost' className='h-7 w-7 p-0' onClick={onEdit}>
<Settings className='h-3.5 w-3.5' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>Settings</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button variant='ghost' className='h-7 w-7 p-0' onClick={onTogglePause}>
{connector.status === 'paused' ? (
<Play className='h-3.5 w-3.5' />
) : (
<Pause className='h-3.5 w-3.5' />
)}
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
{connector.status === 'paused' ? 'Resume' : 'Pause'}
</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button variant='ghost' className='h-7 w-7 p-0' onClick={onDelete}>
<Trash className='h-3.5 w-3.5' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>Delete</Tooltip.Content>
</Tooltip.Root>
</>
)}
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
className='h-7 w-7 p-0'
onClick={() => setExpanded((prev) => !prev)}
>
<ChevronDown
className={cn('h-3.5 w-3.5 transition-transform', expanded && 'rotate-180')}
/>
</Button>
</Tooltip.Trigger>
<Tooltip.Content>{expanded ? 'Hide history' : 'Sync history'}</Tooltip.Content>
</Tooltip.Root>
</div>
</div>
{missingScopes.length > 0 && (
<div className='border-[var(--border-1)] border-t px-[12px] py-[8px]'>
<div className='flex flex-col gap-[4px] rounded-[4px] border bg-[var(--surface-2)] px-[8px] py-[6px]'>
<div className='flex items-center font-medium text-[12px]'>
<span className='mr-[6px] inline-block h-[6px] w-[6px] rounded-[2px] bg-amber-500' />
Additional permissions required
</div>
{canEdit && (
<Button
variant='active'
onClick={() => setShowOAuthModal(true)}
className='w-full px-[8px] py-[4px] font-medium text-[12px]'
>
Update access
</Button>
)}
</div>
</div>
)}
{expanded && (
<div className='border-[var(--border-1)] border-t px-[12px] py-[8px]'>
<SyncHistory logs={syncLogs} isLoading={detailLoading} />
</div>
)}
{showOAuthModal && serviceId && providerId && (
<OAuthRequiredModal
isOpen={showOAuthModal}
onClose={() => setShowOAuthModal(false)}
provider={providerId as OAuthProvider}
toolName={connectorDef?.name ?? connector.connectorType}
requiredScopes={getCanonicalScopesForProvider(providerId)}
newScopes={missingScopes}
serviceId={serviceId}
/>
)}
</div>
)
}
interface SyncHistoryProps {
logs: SyncLogData[]
isLoading: boolean
}
function SyncHistory({ logs, isLoading }: SyncHistoryProps) {
if (isLoading) {
return (
<div className='flex items-center gap-2 py-[4px] text-[11px] text-[var(--text-muted)]'>
<Loader2 className='h-3 w-3 animate-spin' />
Loading sync history...
</div>
)
}
if (logs.length === 0) {
return <p className='py-[4px] text-[11px] text-[var(--text-muted)]'>No sync history yet.</p>
}
return (
<div className='flex flex-col gap-[6px]'>
{logs.map((log) => {
const isError = log.status === 'error' || log.status === 'failed'
const isRunning = log.status === 'running' || log.status === 'syncing'
const totalChanges = log.docsAdded + log.docsUpdated + log.docsDeleted
return (
<div key={log.id} className='flex items-start gap-[8px] text-[11px]'>
<div className='mt-[1px] flex-shrink-0'>
{isRunning ? (
<Loader2 className='h-3 w-3 animate-spin text-[var(--text-muted)]' />
) : isError ? (
<XCircle className='h-3 w-3 text-[var(--text-error)]' />
) : (
<CheckCircle2 className='h-3 w-3 text-[var(--color-green-600)]' />
)}
</div>
<div className='flex min-w-0 flex-1 flex-col gap-[1px]'>
<div className='flex items-center gap-[6px]'>
<span className='text-[var(--text-muted)]'>
{format(new Date(log.startedAt), 'MMM d, h:mm a')}
</span>
{!isRunning && !isError && (
<span className='text-[var(--text-muted)]'>
{totalChanges > 0 ? (
<>
{log.docsAdded > 0 && (
<span className='text-[var(--color-green-600)]'>+{log.docsAdded}</span>
)}
{log.docsUpdated > 0 && (
<>
{log.docsAdded > 0 && ' '}
<span className='text-[var(--color-amber-600)]'>
~{log.docsUpdated}
</span>
</>
)}
{log.docsDeleted > 0 && (
<>
{(log.docsAdded > 0 || log.docsUpdated > 0) && ' '}
<span className='text-[var(--text-error)]'>-{log.docsDeleted}</span>
</>
)}
</>
) : (
'No changes'
)}
</span>
)}
{isRunning && <span className='text-[var(--text-muted)]'>In progress...</span>}
</div>
{isError && log.errorMessage && (
<span className='truncate text-[var(--text-error)]'>{log.errorMessage}</span>
)}
</div>
</div>
)
})}
</div>
)
}

View File

@@ -17,7 +17,6 @@ interface DocumentContextMenuProps {
* Document-specific actions (shown when right-clicking on a document) * Document-specific actions (shown when right-clicking on a document)
*/ */
onOpenInNewTab?: () => void onOpenInNewTab?: () => void
onOpenSource?: () => void
onRename?: () => void onRename?: () => void
onToggleEnabled?: () => void onToggleEnabled?: () => void
onViewTags?: () => void onViewTags?: () => void
@@ -75,7 +74,6 @@ export function DocumentContextMenu({
menuRef, menuRef,
onClose, onClose,
onOpenInNewTab, onOpenInNewTab,
onOpenSource,
onRename, onRename,
onToggleEnabled, onToggleEnabled,
onViewTags, onViewTags,
@@ -131,17 +129,7 @@ export function DocumentContextMenu({
Open in new tab Open in new tab
</PopoverItem> </PopoverItem>
)} )}
{!isMultiSelect && onOpenSource && ( {!isMultiSelect && onOpenInNewTab && <PopoverDivider />}
<PopoverItem
onClick={() => {
onOpenSource()
onClose()
}}
>
Open source
</PopoverItem>
)}
{!isMultiSelect && (onOpenInNewTab || onOpenSource) && <PopoverDivider />}
{/* Edit and view actions */} {/* Edit and view actions */}
{!isMultiSelect && onRename && ( {!isMultiSelect && onRename && (
@@ -182,7 +170,6 @@ export function DocumentContextMenu({
{/* Destructive action */} {/* Destructive action */}
{onDelete && {onDelete &&
((!isMultiSelect && onOpenInNewTab) || ((!isMultiSelect && onOpenInNewTab) ||
(!isMultiSelect && onOpenSource) ||
(!isMultiSelect && onRename) || (!isMultiSelect && onRename) ||
(!isMultiSelect && hasTags && onViewTags) || (!isMultiSelect && hasTags && onViewTags) ||
onToggleEnabled) && <PopoverDivider />} onToggleEnabled) && <PopoverDivider />}

View File

@@ -1,339 +0,0 @@
'use client'
import { useMemo, useState } from 'react'
import { createLogger } from '@sim/logger'
import { ExternalLink, Loader2, RotateCcw } from 'lucide-react'
import {
Button,
ButtonGroup,
ButtonGroupItem,
Combobox,
Input,
Label,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
ModalTabs,
ModalTabsContent,
ModalTabsList,
ModalTabsTrigger,
} from '@/components/emcn'
import { Skeleton } from '@/components/ui/skeleton'
import { CONNECTOR_REGISTRY } from '@/connectors/registry'
import type { ConnectorConfig } from '@/connectors/types'
import type { ConnectorData } from '@/hooks/queries/kb/connectors'
import {
useConnectorDocuments,
useExcludeConnectorDocument,
useRestoreConnectorDocument,
useUpdateConnector,
} from '@/hooks/queries/kb/connectors'
const logger = createLogger('EditConnectorModal')
const SYNC_INTERVALS = [
{ label: 'Every hour', value: 60 },
{ label: 'Every 6 hours', value: 360 },
{ label: 'Daily', value: 1440 },
{ label: 'Weekly', value: 10080 },
{ label: 'Manual only', value: 0 },
] as const
/** Keys injected by the sync engine — not user-editable */
const INTERNAL_CONFIG_KEYS = new Set(['tagSlotMapping', 'disabledTagIds'])
interface EditConnectorModalProps {
open: boolean
onOpenChange: (open: boolean) => void
knowledgeBaseId: string
connector: ConnectorData
}
export function EditConnectorModal({
open,
onOpenChange,
knowledgeBaseId,
connector,
}: EditConnectorModalProps) {
const connectorConfig = CONNECTOR_REGISTRY[connector.connectorType] ?? null
const initialSourceConfig = useMemo(() => {
const config: Record<string, string> = {}
for (const [key, value] of Object.entries(connector.sourceConfig)) {
if (!INTERNAL_CONFIG_KEYS.has(key)) {
config[key] = String(value ?? '')
}
}
return config
}, [connector.sourceConfig])
const [activeTab, setActiveTab] = useState('settings')
const [sourceConfig, setSourceConfig] = useState<Record<string, string>>(initialSourceConfig)
const [syncInterval, setSyncInterval] = useState(connector.syncIntervalMinutes)
const [error, setError] = useState<string | null>(null)
const { mutate: updateConnector, isPending: isSaving } = useUpdateConnector()
const hasChanges = useMemo(() => {
if (syncInterval !== connector.syncIntervalMinutes) return true
for (const [key, value] of Object.entries(sourceConfig)) {
if (String(connector.sourceConfig[key] ?? '') !== value) return true
}
return false
}, [sourceConfig, syncInterval, connector.syncIntervalMinutes, connector.sourceConfig])
const handleSave = () => {
setError(null)
const updates: { sourceConfig?: Record<string, unknown>; syncIntervalMinutes?: number } = {}
if (syncInterval !== connector.syncIntervalMinutes) {
updates.syncIntervalMinutes = syncInterval
}
const configChanged = Object.entries(sourceConfig).some(
([key, value]) => String(connector.sourceConfig[key] ?? '') !== value
)
if (configChanged) {
updates.sourceConfig = { ...connector.sourceConfig, ...sourceConfig }
}
if (Object.keys(updates).length === 0) {
onOpenChange(false)
return
}
updateConnector(
{ knowledgeBaseId, connectorId: connector.id, updates },
{
onSuccess: () => {
onOpenChange(false)
},
onError: (err) => {
logger.error('Failed to update connector', { error: err.message })
setError(err.message)
},
}
)
}
const displayName = connectorConfig?.name ?? connector.connectorType
const Icon = connectorConfig?.icon
return (
<Modal open={open} onOpenChange={(val) => !isSaving && onOpenChange(val)}>
<ModalContent size='md'>
<ModalHeader>
<div className='flex items-center gap-2'>
{Icon && <Icon className='h-5 w-5' />}
Edit {displayName}
</div>
</ModalHeader>
<ModalTabs value={activeTab} onValueChange={setActiveTab}>
<ModalTabsList>
<ModalTabsTrigger value='settings'>Settings</ModalTabsTrigger>
<ModalTabsTrigger value='documents'>Documents</ModalTabsTrigger>
</ModalTabsList>
<ModalBody>
<ModalTabsContent value='settings'>
<SettingsTab
connectorConfig={connectorConfig}
sourceConfig={sourceConfig}
setSourceConfig={setSourceConfig}
syncInterval={syncInterval}
setSyncInterval={setSyncInterval}
error={error}
/>
</ModalTabsContent>
<ModalTabsContent value='documents'>
<DocumentsTab knowledgeBaseId={knowledgeBaseId} connectorId={connector.id} />
</ModalTabsContent>
</ModalBody>
</ModalTabs>
{activeTab === 'settings' && (
<ModalFooter>
<Button variant='default' onClick={() => onOpenChange(false)} disabled={isSaving}>
Cancel
</Button>
<Button variant='tertiary' onClick={handleSave} disabled={!hasChanges || isSaving}>
{isSaving ? (
<>
<Loader2 className='mr-1.5 h-3.5 w-3.5 animate-spin' />
Saving...
</>
) : (
'Save'
)}
</Button>
</ModalFooter>
)}
</ModalContent>
</Modal>
)
}
interface SettingsTabProps {
connectorConfig: ConnectorConfig | null
sourceConfig: Record<string, string>
setSourceConfig: React.Dispatch<React.SetStateAction<Record<string, string>>>
syncInterval: number
setSyncInterval: (v: number) => void
error: string | null
}
function SettingsTab({
connectorConfig,
sourceConfig,
setSourceConfig,
syncInterval,
setSyncInterval,
error,
}: SettingsTabProps) {
return (
<div className='flex flex-col gap-[12px]'>
{connectorConfig?.configFields.map((field) => (
<div key={field.id} className='flex flex-col gap-[4px]'>
<Label>
{field.title}
{field.required && <span className='ml-[2px] text-[var(--text-error)]'>*</span>}
</Label>
{field.description && (
<p className='text-[11px] text-[var(--text-muted)]'>{field.description}</p>
)}
{field.type === 'dropdown' && field.options ? (
<Combobox
size='sm'
options={field.options.map((opt) => ({
label: opt.label,
value: opt.id,
}))}
value={sourceConfig[field.id] || undefined}
onChange={(value) => setSourceConfig((prev) => ({ ...prev, [field.id]: value }))}
placeholder={field.placeholder || `Select ${field.title.toLowerCase()}`}
/>
) : (
<Input
value={sourceConfig[field.id] || ''}
onChange={(e) => setSourceConfig((prev) => ({ ...prev, [field.id]: e.target.value }))}
placeholder={field.placeholder}
/>
)}
</div>
))}
<div className='flex flex-col gap-[4px]'>
<Label>Sync Frequency</Label>
<ButtonGroup
value={String(syncInterval)}
onValueChange={(val) => setSyncInterval(Number(val))}
>
{SYNC_INTERVALS.map((interval) => (
<ButtonGroupItem key={interval.value} value={String(interval.value)}>
{interval.label}
</ButtonGroupItem>
))}
</ButtonGroup>
</div>
{error && <p className='text-[12px] text-[var(--text-error)] leading-tight'>{error}</p>}
</div>
)
}
interface DocumentsTabProps {
knowledgeBaseId: string
connectorId: string
}
function DocumentsTab({ knowledgeBaseId, connectorId }: DocumentsTabProps) {
const [filter, setFilter] = useState<'active' | 'excluded'>('active')
const { data, isLoading } = useConnectorDocuments(knowledgeBaseId, connectorId, {
includeExcluded: true,
})
const { mutate: excludeDoc, isPending: isExcluding } = useExcludeConnectorDocument()
const { mutate: restoreDoc, isPending: isRestoring } = useRestoreConnectorDocument()
const documents = useMemo(() => {
if (!data?.documents) return []
return data.documents.filter((d) => (filter === 'excluded' ? d.userExcluded : !d.userExcluded))
}, [data?.documents, filter])
const counts = data?.counts ?? { active: 0, excluded: 0 }
if (isLoading) {
return (
<div className='flex flex-col gap-[8px]'>
<Skeleton className='h-6 w-full' />
<Skeleton className='h-6 w-full' />
<Skeleton className='h-6 w-full' />
</div>
)
}
return (
<div className='flex flex-col gap-[16px]'>
<ButtonGroup value={filter} onValueChange={(val) => setFilter(val as 'active' | 'excluded')}>
<ButtonGroupItem value='active'>Active ({counts.active})</ButtonGroupItem>
<ButtonGroupItem value='excluded'>Excluded ({counts.excluded})</ButtonGroupItem>
</ButtonGroup>
<div className='max-h-[320px] min-h-0 overflow-y-auto'>
{documents.length === 0 ? (
<p className='py-[16px] text-center text-[13px] text-[var(--text-muted)]'>
{filter === 'excluded' ? 'No excluded documents' : 'No documents yet'}
</p>
) : (
<div className='flex flex-col gap-[8px]'>
{documents.map((doc) => (
<div key={doc.id} className='flex items-center justify-between'>
<div className='flex min-w-0 items-center gap-[6px]'>
<span className='truncate text-[13px] text-[var(--text-primary)]'>
{doc.filename}
</span>
{doc.sourceUrl && (
<a
href={doc.sourceUrl}
target='_blank'
rel='noopener noreferrer'
className='flex-shrink-0 text-[var(--text-muted)] hover:text-[var(--text-secondary)]'
>
<ExternalLink className='h-3 w-3' />
</a>
)}
</div>
<Button
variant='ghost'
size='sm'
className='flex-shrink-0'
disabled={doc.userExcluded ? isRestoring : isExcluding}
onClick={() =>
doc.userExcluded
? restoreDoc({ knowledgeBaseId, connectorId, documentIds: [doc.id] })
: excludeDoc({ knowledgeBaseId, connectorId, documentIds: [doc.id] })
}
>
{doc.userExcluded ? (
<>
<RotateCcw className='mr-1 h-3 w-3' />
Restore
</>
) : (
'Exclude'
)}
</Button>
</div>
))}
</div>
)}
</div>
</div>
)
}

View File

@@ -1 +0,0 @@
export { EditConnectorModal } from './edit-connector-modal'

View File

@@ -1,8 +1,5 @@
export { ActionBar } from './action-bar/action-bar' export { ActionBar } from './action-bar/action-bar'
export { AddConnectorModal } from './add-connector-modal/add-connector-modal'
export { AddDocumentsModal } from './add-documents-modal/add-documents-modal' export { AddDocumentsModal } from './add-documents-modal/add-documents-modal'
export { BaseTagsModal } from './base-tags-modal/base-tags-modal' export { BaseTagsModal } from './base-tags-modal/base-tags-modal'
export { ConnectorsSection } from './connectors-section/connectors-section'
export { DocumentContextMenu } from './document-context-menu' export { DocumentContextMenu } from './document-context-menu'
export { EditConnectorModal } from './edit-connector-modal/edit-connector-modal'
export { RenameDocumentModal } from './rename-document-modal' export { RenameDocumentModal } from './rename-document-modal'

View File

@@ -7,7 +7,6 @@ import { formatAbsoluteDate, formatRelativeTime } from '@/lib/core/utils/formatt
import { BaseTagsModal } from '@/app/workspace/[workspaceId]/knowledge/[id]/components' import { BaseTagsModal } from '@/app/workspace/[workspaceId]/knowledge/[id]/components'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
import { CONNECTOR_REGISTRY } from '@/connectors/registry'
import { DeleteKnowledgeBaseModal } from '../delete-knowledge-base-modal/delete-knowledge-base-modal' import { DeleteKnowledgeBaseModal } from '../delete-knowledge-base-modal/delete-knowledge-base-modal'
import { EditKnowledgeBaseModal } from '../edit-knowledge-base-modal/edit-knowledge-base-modal' import { EditKnowledgeBaseModal } from '../edit-knowledge-base-modal/edit-knowledge-base-modal'
import { KnowledgeBaseContextMenu } from '../knowledge-base-context-menu/knowledge-base-context-menu' import { KnowledgeBaseContextMenu } from '../knowledge-base-context-menu/knowledge-base-context-menu'
@@ -19,7 +18,6 @@ interface BaseCardProps {
description: string description: string
createdAt?: string createdAt?: string
updatedAt?: string updatedAt?: string
connectorTypes?: string[]
onUpdate?: (id: string, name: string, description: string) => Promise<void> onUpdate?: (id: string, name: string, description: string) => Promise<void>
onDelete?: (id: string) => Promise<void> onDelete?: (id: string) => Promise<void>
} }
@@ -77,7 +75,6 @@ export function BaseCard({
docCount, docCount,
description, description,
updatedAt, updatedAt,
connectorTypes = [],
onUpdate, onUpdate,
onDelete, onDelete,
}: BaseCardProps) { }: BaseCardProps) {
@@ -203,33 +200,9 @@ export function BaseCard({
<div className='h-0 w-full border-[var(--divider)] border-t' /> <div className='h-0 w-full border-[var(--divider)] border-t' />
<div className='flex items-start justify-between gap-[8px]'> <p className='line-clamp-2 h-[36px] text-[12px] text-[var(--text-tertiary)] leading-[18px]'>
<p className='line-clamp-2 h-[36px] flex-1 text-[12px] text-[var(--text-tertiary)] leading-[18px]'>
{description} {description}
</p> </p>
{connectorTypes.length > 0 && (
<div className='flex flex-shrink-0 items-center'>
{connectorTypes.map((type, index) => {
const config = CONNECTOR_REGISTRY[type]
if (!config?.icon) return null
const Icon = config.icon
return (
<Tooltip.Root key={type}>
<Tooltip.Trigger asChild>
<div
className='flex h-[20px] w-[20px] flex-shrink-0 items-center justify-center rounded-[4px] bg-[var(--surface-5)]'
style={{ marginLeft: index > 0 ? '-4px' : '0' }}
>
<Icon className='h-[12px] w-[12px] text-[var(--text-secondary)]' />
</div>
</Tooltip.Trigger>
<Tooltip.Content>{config.name}</Tooltip.Content>
</Tooltip.Root>
)
})}
</div>
)}
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -22,7 +22,7 @@ import { cn } from '@/lib/core/utils/cn'
import { formatFileSize, validateKnowledgeBaseFile } from '@/lib/uploads/utils/file-utils' import { formatFileSize, validateKnowledgeBaseFile } from '@/lib/uploads/utils/file-utils'
import { ACCEPT_ATTRIBUTE } from '@/lib/uploads/utils/validation' import { ACCEPT_ATTRIBUTE } from '@/lib/uploads/utils/validation'
import { useKnowledgeUpload } from '@/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload' import { useKnowledgeUpload } from '@/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload'
import { useCreateKnowledgeBase, useDeleteKnowledgeBase } from '@/hooks/queries/kb/knowledge' import { useCreateKnowledgeBase, useDeleteKnowledgeBase } from '@/hooks/queries/knowledge'
const logger = createLogger('CreateBaseModal') const logger = createLogger('CreateBaseModal')

View File

@@ -14,7 +14,7 @@ import {
} from '@/components/emcn' } from '@/components/emcn'
import { Trash } from '@/components/emcn/icons/trash' import { Trash } from '@/components/emcn/icons/trash'
import { filterButtonClass } from '@/app/workspace/[workspaceId]/knowledge/components/constants' import { filterButtonClass } from '@/app/workspace/[workspaceId]/knowledge/components/constants'
import { useUpdateKnowledgeBase } from '@/hooks/queries/kb/knowledge' import { useUpdateKnowledgeBase } from '@/hooks/queries/knowledge'
const logger = createLogger('KnowledgeHeader') const logger = createLogger('KnowledgeHeader')

View File

@@ -2,7 +2,7 @@ import { useCallback, useState } from 'react'
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { useQueryClient } from '@tanstack/react-query' import { useQueryClient } from '@tanstack/react-query'
import { getFileExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file-utils' import { getFileExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file-utils'
import { knowledgeKeys } from '@/hooks/queries/kb/knowledge' import { knowledgeKeys } from '@/hooks/queries/knowledge'
const logger = createLogger('KnowledgeUpload') const logger = createLogger('KnowledgeUpload')

View File

@@ -32,7 +32,7 @@ import {
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
import { useKnowledgeBasesList } from '@/hooks/kb/use-knowledge' import { useKnowledgeBasesList } from '@/hooks/kb/use-knowledge'
import { useDeleteKnowledgeBase, useUpdateKnowledgeBase } from '@/hooks/queries/kb/knowledge' import { useDeleteKnowledgeBase, useUpdateKnowledgeBase } from '@/hooks/queries/knowledge'
import { useDebounce } from '@/hooks/use-debounce' import { useDebounce } from '@/hooks/use-debounce'
const logger = createLogger('Knowledge') const logger = createLogger('Knowledge')
@@ -153,7 +153,6 @@ export function Knowledge() {
description: kb.description || 'No description provided', description: kb.description || 'No description provided',
createdAt: kb.createdAt, createdAt: kb.createdAt,
updatedAt: kb.updatedAt, updatedAt: kb.updatedAt,
connectorTypes: kb.connectorTypes ?? [],
}) })
/** /**
@@ -284,7 +283,6 @@ export function Knowledge() {
title={displayData.title} title={displayData.title}
docCount={displayData.docCount} docCount={displayData.docCount}
description={displayData.description} description={displayData.description}
connectorTypes={displayData.connectorTypes}
createdAt={displayData.createdAt} createdAt={displayData.createdAt}
updatedAt={displayData.updatedAt} updatedAt={displayData.updatedAt}
onUpdate={handleUpdateKnowledgeBase} onUpdate={handleUpdateKnowledgeBase}

View File

@@ -32,10 +32,7 @@ import {
useTestNotification, useTestNotification,
useUpdateNotification, useUpdateNotification,
} from '@/hooks/queries/notifications' } from '@/hooks/queries/notifications'
import { import { useConnectedAccounts, useConnectOAuthService } from '@/hooks/queries/oauth-connections'
useConnectedAccounts,
useConnectOAuthService,
} from '@/hooks/queries/oauth/oauth-connections'
import { CORE_TRIGGER_TYPES, type CoreTriggerType } from '@/stores/logs/filters/types' import { CORE_TRIGGER_TYPES, type CoreTriggerType } from '@/stores/logs/filters/types'
import { SlackChannelSelector } from './components/slack-channel-selector' import { SlackChannelSelector } from './components/slack-channel-selector'
import { WorkflowSelector } from './components/workflow-selector' import { WorkflowSelector } from './components/workflow-selector'

View File

@@ -131,10 +131,8 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
resumeActiveStream, resumeActiveStream,
}) })
// Handle scroll management (80px stickiness for copilot) // Handle scroll management
const { scrollAreaRef, scrollToBottom } = useScrollManagement(messages, isSendingMessage, { const { scrollAreaRef, scrollToBottom } = useScrollManagement(messages, isSendingMessage)
stickinessThreshold: 40,
})
// Handle chat history grouping // Handle chat history grouping
const { groupedChats, handleHistoryDropdownOpen: handleHistoryDropdownOpenHook } = useChatHistory( const { groupedChats, handleHistoryDropdownOpen: handleHistoryDropdownOpenHook } = useChatHistory(

View File

@@ -1,5 +1,5 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { isEqual } from 'lodash' import isEqual from 'lodash/isEqual'
import { useReactFlow } from 'reactflow' import { useReactFlow } from 'reactflow'
import { useStoreWithEqualityFn } from 'zustand/traditional' import { useStoreWithEqualityFn } from 'zustand/traditional'
import { Combobox, type ComboboxOption } from '@/components/emcn/components' import { Combobox, type ComboboxOption } from '@/components/emcn/components'

View File

@@ -95,7 +95,6 @@ const SCOPE_DESCRIPTIONS: Record<string, string> = {
'offline.access': 'Access account when not using the application', 'offline.access': 'Access account when not using the application',
'data.records:read': 'Read records', 'data.records:read': 'Read records',
'data.records:write': 'Write to records', 'data.records:write': 'Write to records',
'schema.bases:read': 'Read base schemas and table metadata',
'webhook:manage': 'Manage webhooks', 'webhook:manage': 'Manage webhooks',
'page.read': 'Read Notion pages', 'page.read': 'Read Notion pages',
'page.write': 'Write to Notion pages', 'page.write': 'Write to Notion pages',

View File

@@ -20,10 +20,7 @@ import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/c
import type { SubBlockConfig } from '@/blocks/types' import type { SubBlockConfig } from '@/blocks/types'
import { CREDENTIAL, CREDENTIAL_SET } from '@/executor/constants' import { CREDENTIAL, CREDENTIAL_SET } from '@/executor/constants'
import { useCredentialSets } from '@/hooks/queries/credential-sets' import { useCredentialSets } from '@/hooks/queries/credential-sets'
import { import { useOAuthCredentialDetail, useOAuthCredentials } from '@/hooks/queries/oauth-credentials'
useOAuthCredentialDetail,
useOAuthCredentials,
} from '@/hooks/queries/oauth/oauth-credentials'
import { useOrganizations } from '@/hooks/queries/organization' import { useOrganizations } from '@/hooks/queries/organization'
import { useSubscriptionData } from '@/hooks/queries/subscription' import { useSubscriptionData } from '@/hooks/queries/subscription'
import { getMissingRequiredScopes } from '@/hooks/use-oauth-scope-status' import { getMissingRequiredScopes } from '@/hooks/use-oauth-scope-status'

View File

@@ -1,5 +1,5 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { isEqual } from 'lodash' import isEqual from 'lodash/isEqual'
import { useStoreWithEqualityFn } from 'zustand/traditional' import { useStoreWithEqualityFn } from 'zustand/traditional'
import { Badge } from '@/components/emcn' import { Badge } from '@/components/emcn'
import { Combobox, type ComboboxOption } from '@/components/emcn/components' import { Combobox, type ComboboxOption } from '@/components/emcn/components'

View File

@@ -10,7 +10,7 @@ import type { KnowledgeBaseData } from '@/lib/knowledge/types'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value' 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' import type { SubBlockConfig } from '@/blocks/types'
import { useKnowledgeBasesList } from '@/hooks/kb/use-knowledge' import { useKnowledgeBasesList } from '@/hooks/kb/use-knowledge'
import { fetchKnowledgeBase, knowledgeKeys } from '@/hooks/queries/kb/knowledge' import { fetchKnowledgeBase, knowledgeKeys } from '@/hooks/queries/knowledge'
interface KnowledgeBaseSelectorProps { interface KnowledgeBaseSelectorProps {
blockId: string blockId: string

View File

@@ -7,7 +7,7 @@ import {
useRef, useRef,
useState, useState,
} from 'react' } from 'react'
import { isEqual } from 'lodash' import isEqual from 'lodash/isEqual'
import { ChevronDown, ChevronsUpDown, ChevronUp, Plus } from 'lucide-react' import { ChevronDown, ChevronsUpDown, ChevronUp, Plus } from 'lucide-react'
import { Button, Popover, PopoverContent, PopoverItem, PopoverTrigger } from '@/components/emcn' import { Button, Popover, PopoverContent, PopoverItem, PopoverTrigger } from '@/components/emcn'
import { Trash } from '@/components/emcn/icons/trash' import { Trash } from '@/components/emcn/icons/trash'

View File

@@ -12,10 +12,7 @@ import {
} from '@/lib/oauth' } from '@/lib/oauth'
import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal' import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal'
import { CREDENTIAL } from '@/executor/constants' import { CREDENTIAL } from '@/executor/constants'
import { import { useOAuthCredentialDetail, useOAuthCredentials } from '@/hooks/queries/oauth-credentials'
useOAuthCredentialDetail,
useOAuthCredentials,
} from '@/hooks/queries/oauth/oauth-credentials'
import { getMissingRequiredScopes } from '@/hooks/use-oauth-scope-status' import { getMissingRequiredScopes } from '@/hooks/use-oauth-scope-status'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store'

View File

@@ -1,5 +1,5 @@
import { type JSX, type MouseEvent, memo, useCallback, useRef, useState } from 'react' import { type JSX, type MouseEvent, memo, useCallback, useRef, useState } from 'react'
import { isEqual } from 'lodash' import isEqual from 'lodash/isEqual'
import { AlertTriangle, ArrowLeftRight, ArrowUp, Check, Clipboard } from 'lucide-react' import { AlertTriangle, ArrowLeftRight, ArrowUp, Check, Clipboard } from 'lucide-react'
import { Button, Input, Label, Tooltip } from '@/components/emcn/components' import { Button, Input, Label, Tooltip } from '@/components/emcn/components'
import { cn } from '@/lib/core/utils/cn' import { cn } from '@/lib/core/utils/cn'

View File

@@ -1,7 +1,7 @@
'use client' 'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { isEqual } from 'lodash' import isEqual from 'lodash/isEqual'
import { import {
BookOpen, BookOpen,
Check, Check,

View File

@@ -10,40 +10,6 @@ import { ActionBar } from '@/app/workspace/[workspaceId]/w/[workflowId]/componen
import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks' import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
import { usePanelEditorStore } from '@/stores/panel' import { usePanelEditorStore } from '@/stores/panel'
/**
* Global styles for subflow nodes (loop and parallel containers).
* Includes animations for drag-over states and hover effects.
*
* @returns Style component with global CSS
*/
const SubflowNodeStyles: React.FC = () => {
return (
<style jsx global>{`
/* Z-index management for subflow nodes - default behind blocks */
.workflow-container .react-flow__node-subflowNode {
z-index: -1 !important;
}
/* Selected subflows appear above other subflows but below blocks (z-21) */
.workflow-container .react-flow__node-subflowNode:has([data-subflow-selected='true']) {
z-index: 10 !important;
}
/* Drag-over states */
.loop-node-drag-over,
.parallel-node-drag-over {
box-shadow: 0 0 0 1.75px var(--brand-secondary) !important;
border-radius: 8px !important;
}
/* Handle z-index for nested nodes */
.react-flow__node[data-parent-node-id] .react-flow__handle {
z-index: 30;
}
`}</style>
)
}
/** /**
* Data structure for subflow nodes (loop and parallel containers) * Data structure for subflow nodes (loop and parallel containers)
*/ */
@@ -151,8 +117,6 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
) )
return ( return (
<>
<SubflowNodeStyles />
<div className='group relative'> <div className='group relative'>
<div <div
ref={blockRef} ref={blockRef}
@@ -277,7 +241,6 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
)} )}
</div> </div>
</div> </div>
</>
) )
}) })

View File

@@ -134,57 +134,6 @@ export function WandPromptBar({
</Button> </Button>
)} )}
</div> </div>
<style jsx global>{`
@keyframes smoke-pulse {
0%,
100% {
transform: scale(0.8);
opacity: 0.4;
}
50% {
transform: scale(1.1);
opacity: 0.8;
}
}
.status-indicator {
position: relative;
width: 12px;
height: 12px;
border-radius: 50%;
overflow: hidden;
background-color: hsl(var(--muted-foreground) / 0.5);
transition: background-color 0.3s ease;
}
.status-indicator.streaming {
background-color: transparent;
}
.status-indicator.streaming::before {
content: '';
position: absolute;
inset: 0;
border-radius: 50%;
background: radial-gradient(
circle,
hsl(var(--primary) / 0.9) 0%,
hsl(var(--primary) / 0.4) 60%,
transparent 80%
);
animation: smoke-pulse 1.8s ease-in-out infinite;
opacity: 0.9;
}
.dark .status-indicator.streaming::before {
background: #6b7280;
opacity: 0.9;
animation: smoke-pulse 1.8s ease-in-out infinite;
}
`}</style>
</div> </div>
) )
} }

View File

@@ -1,6 +1,6 @@
import { memo, useCallback, useEffect, useMemo, useRef } from 'react' import { memo, useCallback, useEffect, useMemo, useRef } from 'react'
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { isEqual } from 'lodash' import isEqual from 'lodash/isEqual'
import { useParams } from 'next/navigation' import { useParams } from 'next/navigation'
import { Handle, type NodeProps, Position, useUpdateNodeInternals } from 'reactflow' import { Handle, type NodeProps, Position, useUpdateNodeInternals } from 'reactflow'
import { useStoreWithEqualityFn } from 'zustand/traditional' import { useStoreWithEqualityFn } from 'zustand/traditional'
@@ -38,7 +38,7 @@ import { getDependsOnFields } from '@/blocks/utils'
import { useKnowledgeBase } from '@/hooks/kb/use-knowledge' import { useKnowledgeBase } from '@/hooks/kb/use-knowledge'
import { useCustomTools } from '@/hooks/queries/custom-tools' import { useCustomTools } from '@/hooks/queries/custom-tools'
import { useMcpServers, useMcpToolsQuery } from '@/hooks/queries/mcp' import { useMcpServers, useMcpToolsQuery } from '@/hooks/queries/mcp'
import { useCredentialName } from '@/hooks/queries/oauth/oauth-credentials' import { useCredentialName } from '@/hooks/queries/oauth-credentials'
import { useReactivateSchedule, useScheduleInfo } from '@/hooks/queries/schedules' import { useReactivateSchedule, useScheduleInfo } from '@/hooks/queries/schedules'
import { useSkills } from '@/hooks/queries/skills' import { useSkills } from '@/hooks/queries/skills'
import { useDeployChildWorkflow } from '@/hooks/queries/workflows' import { useDeployChildWorkflow } from '@/hooks/queries/workflows'

View File

@@ -16,7 +16,7 @@ interface UseScrollManagementOptions {
/** /**
* Distance from bottom (in pixels) within which auto-scroll stays active * Distance from bottom (in pixels) within which auto-scroll stays active
* @remarks Lower values = less sticky (user can scroll away easier) * @remarks Lower values = less sticky (user can scroll away easier)
* @defaultValue 100 * @defaultValue 30
*/ */
stickinessThreshold?: number stickinessThreshold?: number
} }
@@ -41,7 +41,7 @@ export function useScrollManagement(
const lastScrollTopRef = useRef(0) const lastScrollTopRef = useRef(0)
const scrollBehavior = options?.behavior ?? 'smooth' const scrollBehavior = options?.behavior ?? 'smooth'
const stickinessThreshold = options?.stickinessThreshold ?? 100 const stickinessThreshold = options?.stickinessThreshold ?? 30
/** Scrolls the container to the bottom */ /** Scrolls the container to the bottom */
const scrollToBottom = useCallback(() => { const scrollToBottom = useCallback(() => {

View File

@@ -514,6 +514,7 @@ export function HelpModal({ open, onOpenChange, workflowId, workspaceId }: HelpM
alt={`Preview ${index + 1}`} alt={`Preview ${index + 1}`}
fill fill
unoptimized unoptimized
sizes='(max-width: 768px) 100vw, 50vw'
className='object-contain' className='object-contain'
/> />
<button <button

View File

@@ -21,7 +21,7 @@ import {
useConnectOAuthService, useConnectOAuthService,
useDisconnectOAuthService, useDisconnectOAuthService,
useOAuthConnections, useOAuthConnections,
} from '@/hooks/queries/oauth/oauth-connections' } from '@/hooks/queries/oauth-connections'
import { usePermissionConfig } from '@/hooks/use-permission-config' import { usePermissionConfig } from '@/hooks/use-permission-config'
const logger = createLogger('Integrations') const logger = createLogger('Integrations')

View File

@@ -165,12 +165,16 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub
logger.info('Subscription restored successfully', result) logger.info('Subscription restored successfully', result)
} }
await queryClient.invalidateQueries({ queryKey: subscriptionKeys.all }) await Promise.all([
if (activeOrgId) { queryClient.invalidateQueries({ queryKey: subscriptionKeys.all }),
await queryClient.invalidateQueries({ queryKey: organizationKeys.detail(activeOrgId) }) ...(activeOrgId
await queryClient.invalidateQueries({ queryKey: organizationKeys.billing(activeOrgId) }) ? [
await queryClient.invalidateQueries({ queryKey: organizationKeys.lists() }) queryClient.invalidateQueries({ queryKey: organizationKeys.detail(activeOrgId) }),
} queryClient.invalidateQueries({ queryKey: organizationKeys.billing(activeOrgId) }),
queryClient.invalidateQueries({ queryKey: organizationKeys.lists() }),
]
: []),
])
setIsDialogOpen(false) setIsDialogOpen(false)
} catch (err) { } catch (err) {

View File

@@ -37,7 +37,7 @@ export const UsageLimit = forwardRef<UsageLimitRef, UsageLimitProps>(
}, },
ref ref
) => { ) => {
const [inputValue, setInputValue] = useState(currentLimit.toString()) const [inputValue, setInputValue] = useState(() => currentLimit.toString())
const [hasError, setHasError] = useState(false) const [hasError, setHasError] = useState(false)
const [errorType, setErrorType] = useState<'general' | 'belowUsage' | null>(null) const [errorType, setErrorType] = useState<'general' | 'belowUsage' | null>(null)
const [isEditing, setIsEditing] = useState(false) const [isEditing, setIsEditing] = useState(false)

View File

@@ -1,53 +0,0 @@
import { createLogger } from '@sim/logger'
import { task } from '@trigger.dev/sdk'
import { executeSync } from '@/lib/knowledge/connectors/sync-engine'
const logger = createLogger('TriggerKnowledgeConnectorSync')
export type ConnectorSyncPayload = {
connectorId: string
fullSync?: boolean
requestId: string
}
export const knowledgeConnectorSync = task({
id: 'knowledge-connector-sync',
maxDuration: 1800,
machine: 'large-1x',
retry: {
maxAttempts: 3,
factor: 2,
minTimeoutInMs: 5000,
maxTimeoutInMs: 30000,
},
queue: {
concurrencyLimit: 5,
name: 'connector-sync-queue',
},
run: async (payload: ConnectorSyncPayload) => {
const { connectorId, fullSync, requestId } = payload
logger.info(`[${requestId}] Starting connector sync: ${connectorId}`)
try {
const result = await executeSync(connectorId, { fullSync })
logger.info(`[${requestId}] Connector sync completed`, {
connectorId,
added: result.docsAdded,
updated: result.docsUpdated,
deleted: result.docsDeleted,
unchanged: result.docsUnchanged,
})
return {
success: !result.error,
connectorId,
...result,
}
} catch (error) {
logger.error(`[${requestId}] Connector sync failed: ${connectorId}`, error)
throw error
}
},
})

View File

@@ -25,8 +25,6 @@ export const AirtableBlock: BlockConfig<AirtableResponse> = {
{ label: 'Get Record', id: 'get' }, { label: 'Get Record', id: 'get' },
{ label: 'Create Records', id: 'create' }, { label: 'Create Records', id: 'create' },
{ label: 'Update Record', id: 'update' }, { label: 'Update Record', id: 'update' },
{ label: 'List Bases', id: 'listBases' },
{ label: 'Get Base Schema', id: 'getSchema' },
], ],
value: () => 'list', value: () => 'list',
}, },
@@ -38,7 +36,6 @@ export const AirtableBlock: BlockConfig<AirtableResponse> = {
requiredScopes: [ requiredScopes: [
'data.records:read', 'data.records:read',
'data.records:write', 'data.records:write',
'schema.bases:read',
'user.email:read', 'user.email:read',
'webhook:manage', 'webhook:manage',
], ],
@@ -51,7 +48,6 @@ export const AirtableBlock: BlockConfig<AirtableResponse> = {
type: 'short-input', type: 'short-input',
placeholder: 'Enter your base ID (e.g., appXXXXXXXXXXXXXX)', placeholder: 'Enter your base ID (e.g., appXXXXXXXXXXXXXX)',
dependsOn: ['credential'], dependsOn: ['credential'],
condition: { field: 'operation', value: 'listBases', not: true },
required: true, required: true,
}, },
{ {
@@ -60,7 +56,6 @@ export const AirtableBlock: BlockConfig<AirtableResponse> = {
type: 'short-input', type: 'short-input',
placeholder: 'Enter table ID (e.g., tblXXXXXXXXXXXXXX)', placeholder: 'Enter table ID (e.g., tblXXXXXXXXXXXXXX)',
dependsOn: ['credential', 'baseId'], dependsOn: ['credential', 'baseId'],
condition: { field: 'operation', value: ['listBases', 'getSchema'], not: true },
required: true, required: true,
}, },
{ {
@@ -205,8 +200,6 @@ Return ONLY the valid JSON object - no explanations, no markdown.`,
'airtable_create_records', 'airtable_create_records',
'airtable_update_record', 'airtable_update_record',
'airtable_update_multiple_records', 'airtable_update_multiple_records',
'airtable_list_bases',
'airtable_get_base_schema',
], ],
config: { config: {
tool: (params) => { tool: (params) => {
@@ -221,10 +214,6 @@ Return ONLY the valid JSON object - no explanations, no markdown.`,
return 'airtable_update_record' return 'airtable_update_record'
case 'updateMultiple': case 'updateMultiple':
return 'airtable_update_multiple_records' return 'airtable_update_multiple_records'
case 'listBases':
return 'airtable_list_bases'
case 'getSchema':
return 'airtable_get_base_schema'
default: default:
throw new Error(`Invalid Airtable operation: ${params.operation}`) throw new Error(`Invalid Airtable operation: ${params.operation}`)
} }
@@ -278,11 +267,9 @@ Return ONLY the valid JSON object - no explanations, no markdown.`,
}, },
// Output structure depends on the operation, covered by AirtableResponse union type // Output structure depends on the operation, covered by AirtableResponse union type
outputs: { outputs: {
records: { type: 'json', description: 'Retrieved record data' }, records: { type: 'json', description: 'Retrieved record data' }, // Optional: for list, create, updateMultiple
record: { type: 'json', description: 'Single record data' }, record: { type: 'json', description: 'Single record data' }, // Optional: for get, update single
bases: { type: 'json', description: 'List of accessible Airtable bases' }, metadata: { type: 'json', description: 'Operation metadata' }, // Required: present in all responses
tables: { type: 'json', description: 'Table schemas with fields and views' },
metadata: { type: 'json', description: 'Operation metadata' },
// Trigger outputs // Trigger outputs
event_type: { type: 'string', description: 'Type of Airtable event' }, event_type: { type: 'string', description: 'Type of Airtable event' },
base_id: { type: 'string', description: 'Airtable base identifier' }, base_id: { type: 'string', description: 'Airtable base identifier' },

View File

@@ -92,12 +92,9 @@ export const IncidentioBlock: BlockConfig<IncidentioResponse> = {
field: 'operation', field: 'operation',
value: [ value: [
'incidentio_incidents_list', 'incidentio_incidents_list',
'incidentio_actions_list',
'incidentio_follow_ups_list',
'incidentio_users_list', 'incidentio_users_list',
'incidentio_workflows_list', 'incidentio_workflows_list',
'incidentio_schedules_list', 'incidentio_schedules_list',
'incidentio_escalations_list',
'incidentio_incident_updates_list', 'incidentio_incident_updates_list',
'incidentio_schedule_entries_list', 'incidentio_schedule_entries_list',
], ],
@@ -113,6 +110,7 @@ export const IncidentioBlock: BlockConfig<IncidentioResponse> = {
field: 'operation', field: 'operation',
value: [ value: [
'incidentio_incidents_list', 'incidentio_incidents_list',
'incidentio_users_list',
'incidentio_workflows_list', 'incidentio_workflows_list',
'incidentio_schedules_list', 'incidentio_schedules_list',
'incidentio_incident_updates_list', 'incidentio_incident_updates_list',

View File

@@ -6,11 +6,9 @@ export const KnowledgeBlock: BlockConfig = {
name: 'Knowledge', name: 'Knowledge',
description: 'Use vector search', description: 'Use vector search',
longDescription: longDescription:
'Integrate Knowledge into the workflow. Perform full CRUD operations on documents, chunks, and tags.', 'Integrate Knowledge into the workflow. Can search, upload chunks, and create documents.',
bestPractices: ` bestPractices: `
- Clarify which tags are available for the knowledge base to understand whether to use tag filters on a search. - Clarify which tags are available for the knowledge base to understand whether to use tag filters on a search.
- Use List Documents to enumerate documents before operating on them.
- Use List Chunks to inspect a document's contents before updating or deleting chunks.
`, `,
bgColor: '#00B0B0', bgColor: '#00B0B0',
icon: PackageSearchIcon, icon: PackageSearchIcon,
@@ -23,41 +21,20 @@ export const KnowledgeBlock: BlockConfig = {
type: 'dropdown', type: 'dropdown',
options: [ options: [
{ label: 'Search', id: 'search' }, { label: 'Search', id: 'search' },
{ label: 'List Documents', id: 'list_documents' },
{ label: 'Create Document', id: 'create_document' },
{ label: 'Delete Document', id: 'delete_document' },
{ label: 'List Chunks', id: 'list_chunks' },
{ label: 'Upload Chunk', id: 'upload_chunk' }, { label: 'Upload Chunk', id: 'upload_chunk' },
{ label: 'Update Chunk', id: 'update_chunk' }, { label: 'Create Document', id: 'create_document' },
{ label: 'Delete Chunk', id: 'delete_chunk' },
{ label: 'List Tags', id: 'list_tags' },
], ],
value: () => 'search', value: () => 'search',
}, },
// Knowledge Base selector — basic mode (visual selector)
{ {
id: 'knowledgeBaseSelector', id: 'knowledgeBaseId',
title: 'Knowledge Base', title: 'Knowledge Base',
type: 'knowledge-base-selector', type: 'knowledge-base-selector',
canonicalParamId: 'knowledgeBaseId',
placeholder: 'Select knowledge base', placeholder: 'Select knowledge base',
multiSelect: false, multiSelect: false,
required: true, required: true,
mode: 'basic', condition: { field: 'operation', value: ['search', 'upload_chunk', 'create_document'] },
}, },
// Knowledge Base selector — advanced mode (manual ID input)
{
id: 'manualKnowledgeBaseId',
title: 'Knowledge Base ID',
type: 'short-input',
canonicalParamId: 'knowledgeBaseId',
placeholder: 'Enter knowledge base ID',
required: true,
mode: 'advanced',
},
// --- Search ---
{ {
id: 'query', id: 'query',
title: 'Search Query', title: 'Search Query',
@@ -80,72 +57,15 @@ export const KnowledgeBlock: BlockConfig = {
placeholder: 'Add tag filters', placeholder: 'Add tag filters',
condition: { field: 'operation', value: 'search' }, condition: { field: 'operation', value: 'search' },
}, },
// --- List Documents ---
{ {
id: 'search', id: 'documentId',
title: 'Search',
type: 'short-input',
placeholder: 'Filter documents by filename',
condition: { field: 'operation', value: 'list_documents' },
},
{
id: 'enabledFilter',
title: 'Status Filter',
type: 'dropdown',
options: [
{ label: 'All', id: 'all' },
{ label: 'Enabled', id: 'enabled' },
{ label: 'Disabled', id: 'disabled' },
],
condition: { field: 'operation', value: 'list_documents' },
},
{
id: 'limit',
title: 'Limit',
type: 'short-input',
placeholder: 'Max items to return (default: 50)',
condition: { field: 'operation', value: ['list_documents', 'list_chunks'] },
},
{
id: 'offset',
title: 'Offset',
type: 'short-input',
placeholder: 'Number of items to skip (default: 0)',
condition: { field: 'operation', value: ['list_documents', 'list_chunks'] },
},
// Document selector — basic mode (visual selector)
{
id: 'documentSelector',
title: 'Document', title: 'Document',
type: 'document-selector', type: 'document-selector',
canonicalParamId: 'documentId',
placeholder: 'Select document', placeholder: 'Select document',
dependsOn: ['knowledgeBaseId'], dependsOn: ['knowledgeBaseId'],
required: true, required: true,
mode: 'basic', condition: { field: 'operation', value: 'upload_chunk' },
condition: {
field: 'operation',
value: ['upload_chunk', 'delete_document', 'list_chunks', 'update_chunk', 'delete_chunk'],
}, },
},
// Document selector — advanced mode (manual ID input)
{
id: 'manualDocumentId',
title: 'Document ID',
type: 'short-input',
canonicalParamId: 'documentId',
placeholder: 'Enter document ID',
required: true,
mode: 'advanced',
condition: {
field: 'operation',
value: ['upload_chunk', 'delete_document', 'list_chunks', 'update_chunk', 'delete_chunk'],
},
},
// --- Upload Chunk ---
{ {
id: 'content', id: 'content',
title: 'Chunk Content', title: 'Chunk Content',
@@ -155,15 +75,13 @@ export const KnowledgeBlock: BlockConfig = {
required: true, required: true,
condition: { field: 'operation', value: 'upload_chunk' }, condition: { field: 'operation', value: 'upload_chunk' },
}, },
// --- Create Document ---
{ {
id: 'name', id: 'name',
title: 'Document Name', title: 'Document Name',
type: 'short-input', type: 'short-input',
placeholder: 'Enter document name', placeholder: 'Enter document name',
required: true, required: true,
condition: { field: 'operation', value: 'create_document' }, condition: { field: 'operation', value: ['create_document'] },
}, },
{ {
id: 'content', id: 'content',
@@ -172,75 +90,18 @@ export const KnowledgeBlock: BlockConfig = {
placeholder: 'Enter the document content', placeholder: 'Enter the document content',
rows: 6, rows: 6,
required: true, required: true,
condition: { field: 'operation', value: 'create_document' }, condition: { field: 'operation', value: ['create_document'] },
}, },
// Dynamic tag entry for Create Document
{ {
id: 'documentTags', id: 'documentTags',
title: 'Document Tags', title: 'Document Tags',
type: 'document-tag-entry', type: 'document-tag-entry',
condition: { field: 'operation', value: 'create_document' }, condition: { field: 'operation', value: 'create_document' },
}, },
// --- Update Chunk / Delete Chunk ---
{
id: 'chunkId',
title: 'Chunk ID',
type: 'short-input',
placeholder: 'Enter chunk ID',
required: true,
condition: { field: 'operation', value: ['update_chunk', 'delete_chunk'] },
},
{
id: 'content',
title: 'New Content',
type: 'long-input',
placeholder: 'Enter updated chunk content',
rows: 6,
condition: { field: 'operation', value: 'update_chunk' },
},
{
id: 'enabled',
title: 'Enabled',
type: 'dropdown',
options: [
{ label: 'Yes', id: 'true' },
{ label: 'No', id: 'false' },
],
condition: { field: 'operation', value: 'update_chunk' },
},
// --- List Chunks ---
{
id: 'chunkSearch',
title: 'Search',
type: 'short-input',
placeholder: 'Filter chunks by content',
condition: { field: 'operation', value: 'list_chunks' },
},
{
id: 'chunkEnabledFilter',
title: 'Status Filter',
type: 'dropdown',
options: [
{ label: 'All', id: 'all' },
{ label: 'Enabled', id: 'true' },
{ label: 'Disabled', id: 'false' },
],
condition: { field: 'operation', value: 'list_chunks' },
},
], ],
tools: { tools: {
access: [ access: ['knowledge_search', 'knowledge_upload_chunk', 'knowledge_create_document'],
'knowledge_search',
'knowledge_upload_chunk',
'knowledge_create_document',
'knowledge_list_tags',
'knowledge_list_documents',
'knowledge_delete_document',
'knowledge_list_chunks',
'knowledge_update_chunk',
'knowledge_delete_chunk',
],
config: { config: {
tool: (params) => { tool: (params) => {
switch (params.operation) { switch (params.operation) {
@@ -250,62 +111,25 @@ export const KnowledgeBlock: BlockConfig = {
return 'knowledge_upload_chunk' return 'knowledge_upload_chunk'
case 'create_document': case 'create_document':
return 'knowledge_create_document' return 'knowledge_create_document'
case 'list_tags':
return 'knowledge_list_tags'
case 'list_documents':
return 'knowledge_list_documents'
case 'delete_document':
return 'knowledge_delete_document'
case 'list_chunks':
return 'knowledge_list_chunks'
case 'update_chunk':
return 'knowledge_update_chunk'
case 'delete_chunk':
return 'knowledge_delete_chunk'
default: default:
return 'knowledge_search' return 'knowledge_search'
} }
}, },
params: (params) => { params: (params) => {
const knowledgeBaseId = params.knowledgeBaseId ? String(params.knowledgeBaseId).trim() : '' // Validate required fields for each operation
if (!knowledgeBaseId) { if (params.operation === 'search' && !params.knowledgeBaseId) {
throw new Error('Knowledge base ID is required') throw new Error('Knowledge base ID is required for search operation')
} }
params.knowledgeBaseId = knowledgeBaseId if (
(params.operation === 'upload_chunk' || params.operation === 'create_document') &&
const docOps = [ !params.knowledgeBaseId
'upload_chunk', ) {
'delete_document', throw new Error(
'list_chunks', 'Knowledge base ID is required for upload_chunk and create_document operations'
'update_chunk', )
'delete_chunk',
]
if (docOps.includes(params.operation)) {
const documentId = params.documentId ? String(params.documentId).trim() : ''
if (!documentId) {
throw new Error(`Document ID is required for ${params.operation} operation`)
} }
params.documentId = documentId if (params.operation === 'upload_chunk' && !params.documentId) {
} throw new Error('Document ID is required for upload_chunk operation')
const chunkOps = ['update_chunk', 'delete_chunk']
if (chunkOps.includes(params.operation)) {
const chunkId = params.chunkId ? String(params.chunkId).trim() : ''
if (!chunkId) {
throw new Error(`Chunk ID is required for ${params.operation} operation`)
}
params.chunkId = chunkId
}
// Map list_chunks sub-block fields to tool params
if (params.operation === 'list_chunks') {
if (params.chunkSearch) params.search = params.chunkSearch
if (params.chunkEnabledFilter) params.enabled = params.chunkEnabledFilter
}
// Convert enabled dropdown string to boolean for update_chunk
if (params.operation === 'update_chunk' && typeof params.enabled === 'string') {
params.enabled = params.enabled === 'true'
} }
return params return params
@@ -318,18 +142,12 @@ export const KnowledgeBlock: BlockConfig = {
query: { type: 'string', description: 'Search query terms' }, query: { type: 'string', description: 'Search query terms' },
topK: { type: 'number', description: 'Number of results' }, topK: { type: 'number', description: 'Number of results' },
documentId: { type: 'string', description: 'Document identifier' }, documentId: { type: 'string', description: 'Document identifier' },
chunkId: { type: 'string', description: 'Chunk identifier' },
content: { type: 'string', description: 'Content data' }, content: { type: 'string', description: 'Content data' },
name: { type: 'string', description: 'Document name' }, name: { type: 'string', description: 'Document name' },
search: { type: 'string', description: 'Search filter for documents' }, // Dynamic tag filters for search
enabledFilter: { type: 'string', description: 'Filter by enabled status' },
enabled: { type: 'string', description: 'Enable or disable a chunk' },
limit: { type: 'number', description: 'Max items to return' },
offset: { type: 'number', description: 'Pagination offset' },
tagFilters: { type: 'string', description: 'Tag filter criteria' }, tagFilters: { type: 'string', description: 'Tag filter criteria' },
// Document tags for create document (JSON string of tag objects)
documentTags: { type: 'string', description: 'Document tags' }, documentTags: { type: 'string', description: 'Document tags' },
chunkSearch: { type: 'string', description: 'Search filter for chunks' },
chunkEnabledFilter: { type: 'string', description: 'Filter chunks by enabled status' },
}, },
outputs: { outputs: {
results: { type: 'json', description: 'Search results' }, results: { type: 'json', description: 'Search results' },

View File

@@ -216,31 +216,21 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
condition: { field: 'operation', value: ['update_deal'] }, condition: { field: 'operation', value: ['update_deal'] },
}, },
{ {
id: 'deal_id', id: 'sort',
title: 'Deal ID', title: 'Sort By',
type: 'short-input', type: 'dropdown',
placeholder: 'Filter by deal ID ', options: [
condition: { field: 'operation', value: ['get_files'] }, { label: 'ID', id: 'id' },
}, { label: 'Update Time', id: 'update_time' },
{ ],
id: 'person_id', value: () => 'id',
title: 'Person ID',
type: 'short-input',
placeholder: 'Filter by person ID ',
condition: { field: 'operation', value: ['get_files'] },
},
{
id: 'org_id',
title: 'Organization ID',
type: 'short-input',
placeholder: 'Filter by organization ID ',
condition: { field: 'operation', value: ['get_files'] }, condition: { field: 'operation', value: ['get_files'] },
}, },
{ {
id: 'limit', id: 'limit',
title: 'Limit', title: 'Limit',
type: 'short-input', type: 'short-input',
placeholder: 'Number of results (default 100, max 500)', placeholder: 'Number of results (default 100, max 100)',
condition: { field: 'operation', value: ['get_files'] }, condition: { field: 'operation', value: ['get_files'] },
}, },
{ {
@@ -305,8 +295,28 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
id: 'cursor', id: 'cursor',
title: 'Cursor', title: 'Cursor',
type: 'short-input', type: 'short-input',
placeholder: 'Pagination cursor (optional)', placeholder: 'Pagination cursor from previous response',
condition: { field: 'operation', value: ['get_pipelines'] }, condition: {
field: 'operation',
value: ['get_all_deals', 'get_projects'],
},
},
{
id: 'start',
title: 'Start (Offset)',
type: 'short-input',
placeholder: 'Pagination offset (e.g., 0, 100, 200)',
condition: {
field: 'operation',
value: [
'get_activities',
'get_leads',
'get_files',
'get_pipeline_deals',
'get_mail_messages',
'get_pipelines',
],
},
}, },
{ {
id: 'pipeline_id', id: 'pipeline_id',
@@ -323,19 +333,6 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
placeholder: 'Filter by stage ID ', placeholder: 'Filter by stage ID ',
condition: { field: 'operation', value: ['get_pipeline_deals'] }, condition: { field: 'operation', value: ['get_pipeline_deals'] },
}, },
{
id: 'status',
title: 'Status',
type: 'dropdown',
options: [
{ label: 'All', id: '' },
{ label: 'Open', id: 'open' },
{ label: 'Won', id: 'won' },
{ label: 'Lost', id: 'lost' },
],
value: () => '',
condition: { field: 'operation', value: ['get_pipeline_deals'] },
},
{ {
id: 'limit', id: 'limit',
title: 'Limit', title: 'Limit',
@@ -426,22 +423,29 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
id: 'deal_id', id: 'deal_id',
title: 'Deal ID', title: 'Deal ID',
type: 'short-input', type: 'short-input',
placeholder: 'Filter by deal ID ', placeholder: 'Associated deal ID ',
condition: { field: 'operation', value: ['get_activities', 'create_activity'] }, condition: { field: 'operation', value: ['create_activity'] },
}, },
{ {
id: 'person_id', id: 'person_id',
title: 'Person ID', title: 'Person ID',
type: 'short-input', type: 'short-input',
placeholder: 'Filter by person ID ', placeholder: 'Associated person ID ',
condition: { field: 'operation', value: ['get_activities', 'create_activity'] }, condition: { field: 'operation', value: ['create_activity'] },
}, },
{ {
id: 'org_id', id: 'org_id',
title: 'Organization ID', title: 'Organization ID',
type: 'short-input', type: 'short-input',
placeholder: 'Filter by organization ID ', placeholder: 'Associated organization ID ',
condition: { field: 'operation', value: ['get_activities', 'create_activity'] }, condition: { field: 'operation', value: ['create_activity'] },
},
{
id: 'user_id',
title: 'User ID',
type: 'short-input',
placeholder: 'Filter by user ID',
condition: { field: 'operation', value: ['get_activities'] },
}, },
{ {
id: 'type', id: 'type',
@@ -781,7 +785,8 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
thread_id: { type: 'string', description: 'Mail thread ID' }, thread_id: { type: 'string', description: 'Mail thread ID' },
sort_by: { type: 'string', description: 'Field to sort by' }, sort_by: { type: 'string', description: 'Field to sort by' },
sort_direction: { type: 'string', description: 'Sorting direction' }, sort_direction: { type: 'string', description: 'Sorting direction' },
cursor: { type: 'string', description: 'Pagination cursor' }, cursor: { type: 'string', description: 'Pagination cursor (v2 endpoints)' },
start: { type: 'string', description: 'Pagination start offset (v1 endpoints)' },
project_id: { type: 'string', description: 'Project ID' }, project_id: { type: 'string', description: 'Project ID' },
description: { type: 'string', description: 'Description' }, description: { type: 'string', description: 'Description' },
start_date: { type: 'string', description: 'Start date' }, start_date: { type: 'string', description: 'Start date' },
@@ -793,12 +798,15 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
due_time: { type: 'string', description: 'Due time' }, due_time: { type: 'string', description: 'Due time' },
duration: { type: 'string', description: 'Duration' }, duration: { type: 'string', description: 'Duration' },
done: { type: 'string', description: 'Completion status' }, done: { type: 'string', description: 'Completion status' },
user_id: { type: 'string', description: 'User ID' },
note: { type: 'string', description: 'Notes' }, note: { type: 'string', description: 'Notes' },
lead_id: { type: 'string', description: 'Lead ID' }, lead_id: { type: 'string', description: 'Lead ID' },
archived: { type: 'string', description: 'Archived status' }, archived: { type: 'string', description: 'Archived status' },
value_amount: { type: 'string', description: 'Value amount' }, value_amount: { type: 'string', description: 'Value amount' },
value_currency: { type: 'string', description: 'Value currency' }, value_currency: { type: 'string', description: 'Value currency' },
is_archived: { type: 'string', description: 'Archive status' }, is_archived: { type: 'string', description: 'Archive status' },
organization_id: { type: 'string', description: 'Organization ID' },
owner_id: { type: 'string', description: 'Owner user ID' },
}, },
outputs: { outputs: {
deals: { type: 'json', description: 'Array of deal objects' }, deals: { type: 'json', description: 'Array of deal objects' },

View File

@@ -445,6 +445,13 @@ Return ONLY the order by expression - no explanations, no extra text.`,
placeholder: '100', placeholder: '100',
condition: { field: 'operation', value: 'query' }, condition: { field: 'operation', value: 'query' },
}, },
{
id: 'offset',
title: 'Offset',
type: 'short-input',
placeholder: '0',
condition: { field: 'operation', value: 'query' },
},
// Vector search operation fields // Vector search operation fields
{ {
id: 'functionName', id: 'functionName',
@@ -543,6 +550,13 @@ Return ONLY the order by expression - no explanations, no extra text.`,
placeholder: '100', placeholder: '100',
condition: { field: 'operation', value: 'text_search' }, condition: { field: 'operation', value: 'text_search' },
}, },
{
id: 'offset',
title: 'Offset',
type: 'short-input',
placeholder: '0',
condition: { field: 'operation', value: 'text_search' },
},
// Count operation fields // Count operation fields
{ {
id: 'filter', id: 'filter',

View File

@@ -66,6 +66,20 @@ export const TypeformBlock: BlockConfig<TypeformResponse> = {
placeholder: 'Number of responses per page (default: 25)', placeholder: 'Number of responses per page (default: 25)',
condition: { field: 'operation', value: 'typeform_responses' }, condition: { field: 'operation', value: 'typeform_responses' },
}, },
{
id: 'before',
title: 'Before (Cursor)',
type: 'short-input',
placeholder: 'Cursor token from previous response for pagination',
condition: { field: 'operation', value: 'typeform_responses' },
},
{
id: 'after',
title: 'After (Cursor)',
type: 'short-input',
placeholder: 'Cursor token from previous response for newer results',
condition: { field: 'operation', value: 'typeform_responses' },
},
{ {
id: 'since', id: 'since',
title: 'Since', title: 'Since',
@@ -380,6 +394,8 @@ Do not include any explanations, markdown formatting, or other text outside the
apiKey: { type: 'string', description: 'Personal access token' }, apiKey: { type: 'string', description: 'Personal access token' },
// Response operation params // Response operation params
pageSize: { type: 'number', description: 'Responses per page' }, pageSize: { type: 'number', description: 'Responses per page' },
before: { type: 'string', description: 'Cursor token for fetching the next page' },
after: { type: 'string', description: 'Cursor token for fetching newer results' },
since: { type: 'string', description: 'Start date filter' }, since: { type: 'string', description: 'Start date filter' },
until: { type: 'string', description: 'End date filter' }, until: { type: 'string', description: 'End date filter' },
completed: { type: 'string', description: 'Completion status filter' }, completed: { type: 'string', description: 'Completion status filter' },

View File

@@ -444,33 +444,36 @@ Return ONLY the search query - no explanations.`,
}, },
}, },
{ {
id: 'sortBy', id: 'filterType',
title: 'Sort By', title: 'Resource Type',
type: 'dropdown', type: 'dropdown',
options: [ options: [
{ label: 'Relevance', id: 'relevance' }, { label: 'Ticket', id: 'ticket' },
{ label: 'Created At', id: 'created_at' }, { label: 'User', id: 'user' },
{ label: 'Updated At', id: 'updated_at' }, { label: 'Organization', id: 'organization' },
{ label: 'Priority', id: 'priority' }, { label: 'Group', id: 'group' },
{ label: 'Status', id: 'status' },
{ label: 'Ticket Type', id: 'ticket_type' },
], ],
required: true,
condition: { condition: {
field: 'operation', field: 'operation',
value: ['search'], value: ['search'],
}, },
}, },
{ {
id: 'sortOrder', id: 'sort',
title: 'Sort Order', title: 'Sort',
type: 'dropdown', type: 'dropdown',
options: [ options: [
{ label: 'Ascending', id: 'asc' }, { label: 'Updated At (Asc)', id: 'updated_at' },
{ label: 'Descending', id: 'desc' }, { label: 'Updated At (Desc)', id: '-updated_at' },
{ label: 'ID (Asc)', id: 'id' },
{ label: 'ID (Desc)', id: '-id' },
{ label: 'Status (Asc)', id: 'status' },
{ label: 'Status (Desc)', id: '-status' },
], ],
condition: { condition: {
field: 'operation', field: 'operation',
value: ['search'], value: ['get_tickets'],
}, },
}, },
// Pagination fields // Pagination fields
@@ -492,20 +495,25 @@ Return ONLY the search query - no explanations.`,
}, },
}, },
{ {
id: 'page', id: 'pageAfter',
title: 'Page', title: 'Page After (Cursor)',
type: 'short-input', type: 'short-input',
placeholder: 'Page number', placeholder: 'Cursor from previous response (after_cursor)',
description: 'Cursor value from a previous response to fetch the next page of results',
condition: { condition: {
field: 'operation', field: 'operation',
value: [ value: ['get_tickets', 'get_users', 'get_organizations', 'search'],
'get_tickets', },
'get_users', },
'get_organizations', {
'search_users', id: 'page',
'autocomplete_organizations', title: 'Page Number',
'search', type: 'short-input',
], placeholder: 'Page number (default: 1)',
description: 'Page number for offset-based pagination',
condition: {
field: 'operation',
value: ['search_users', 'autocomplete_organizations'],
}, },
}, },
], ],
@@ -624,6 +632,7 @@ Return ONLY the search query - no explanations.`,
email: { type: 'string', description: 'Zendesk email address' }, email: { type: 'string', description: 'Zendesk email address' },
apiToken: { type: 'string', description: 'Zendesk API token' }, apiToken: { type: 'string', description: 'Zendesk API token' },
subdomain: { type: 'string', description: 'Zendesk subdomain' }, subdomain: { type: 'string', description: 'Zendesk subdomain' },
sort: { type: 'string', description: 'Sort field for ticket listing' },
}, },
outputs: { outputs: {
// Ticket operations - list // Ticket operations - list
@@ -665,8 +674,11 @@ Return ONLY the search query - no explanations.`,
type: 'boolean', type: 'boolean',
description: 'Deletion confirmation (delete_ticket, delete_user, delete_organization)', description: 'Deletion confirmation (delete_ticket, delete_user, delete_organization)',
}, },
// Pagination (shared across list operations) // Cursor-based pagination (shared across list operations)
paging: { type: 'json', description: 'Pagination information for list operations' }, paging: {
type: 'json',
description: 'Cursor-based pagination information (after_cursor, has_more)',
},
// Metadata (shared across all operations) // Metadata (shared across all operations)
metadata: { type: 'json', description: 'Operation metadata including operation type' }, metadata: { type: 'json', description: 'Operation metadata including operation type' },
}, },

View File

@@ -28,7 +28,7 @@ const checkboxVariants = cva(
'border-[var(--border-1)] bg-transparent', 'border-[var(--border-1)] bg-transparent',
'focus-visible:outline-none', 'focus-visible:outline-none',
'data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50', 'data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50',
'data-[state=checked]:border-[var(--brand-tertiary-2)] data-[state=checked]:bg-[var(--brand-tertiary-2)]', 'data-[state=checked]:border-[var(--text-primary)] data-[state=checked]:bg-[var(--text-primary)]',
].join(' '), ].join(' '),
{ {
variants: { variants: {

View File

@@ -9,6 +9,7 @@ import {
type ReactNode, type ReactNode,
useCallback, useCallback,
useEffect, useEffect,
useId,
useMemo, useMemo,
useRef, useRef,
useState, useState,
@@ -170,6 +171,7 @@ const Combobox = memo(
}, },
ref ref
) => { ) => {
const listboxId = useId()
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [highlightedIndex, setHighlightedIndex] = useState(-1) const [highlightedIndex, setHighlightedIndex] = useState(-1)
const [searchQuery, setSearchQuery] = useState('') const [searchQuery, setSearchQuery] = useState('')
@@ -513,6 +515,7 @@ const Combobox = memo(
role='combobox' role='combobox'
aria-expanded={open} aria-expanded={open}
aria-haspopup='listbox' aria-haspopup='listbox'
aria-controls={listboxId}
aria-disabled={disabled} aria-disabled={disabled}
tabIndex={disabled ? -1 : 0} tabIndex={disabled ? -1 : 0}
className={cn( className={cn(
@@ -616,7 +619,7 @@ const Combobox = memo(
} }
}} }}
> >
<div ref={dropdownRef} role='listbox'> <div ref={dropdownRef} role='listbox' id={listboxId}>
{isLoading ? ( {isLoading ? (
<div className='flex items-center justify-center py-[14px]'> <div className='flex items-center justify-center py-[14px]'>
<Loader2 className='h-[16px] w-[16px] animate-spin text-[var(--text-muted)]' /> <Loader2 className='h-[16px] w-[16px] animate-spin text-[var(--text-muted)]' />

View File

@@ -797,7 +797,7 @@ const DatePicker = React.forwardRef<HTMLDivElement, DatePickerProps>((props, ref
side='bottom' side='bottom'
align='start' align='start'
sideOffset={4} sideOffset={4}
collisionPadding={16} avoidCollisions={false}
className={cn( className={cn(
'rounded-[6px] border border-[var(--border-1)] p-0', 'rounded-[6px] border border-[var(--border-1)] p-0',
isRangeMode ? 'w-auto' : 'w-[280px]' isRangeMode ? 'w-auto' : 'w-[280px]'

View File

@@ -27,12 +27,14 @@ const Alert = React.forwardRef<
Alert.displayName = 'Alert' Alert.displayName = 'Alert'
const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>( const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => ( ({ className, children, ...props }, ref) => (
<h5 <h5
ref={ref} ref={ref}
className={cn('mb-1 font-medium leading-none tracking-tight', className)} className={cn('mb-1 font-medium leading-none tracking-tight', className)}
{...props} {...props}
/> >
{children}
</h5>
) )
) )
AlertTitle.displayName = 'AlertTitle' AlertTitle.displayName = 'AlertTitle'

View File

@@ -1,400 +0,0 @@
import { createLogger } from '@sim/logger'
import { AirtableIcon } from '@/components/icons'
import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils'
import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types'
const logger = createLogger('AirtableConnector')
const AIRTABLE_API = 'https://api.airtable.com/v0'
const PAGE_SIZE = 100
/**
* Computes a SHA-256 hash of the given content.
*/
async function computeContentHash(content: string): Promise<string> {
const data = new TextEncoder().encode(content)
const hashBuffer = await crypto.subtle.digest('SHA-256', data)
return Array.from(new Uint8Array(hashBuffer))
.map((b) => b.toString(16).padStart(2, '0'))
.join('')
}
/**
* Flattens a record's fields into a plain-text representation.
* Each field is rendered as "Field Name: value" on its own line.
*/
function recordToPlainText(
fields: Record<string, unknown>,
fieldNames?: Map<string, string>
): string {
const lines: string[] = []
for (const [key, value] of Object.entries(fields)) {
if (value == null) continue
const displayName = fieldNames?.get(key) ?? key
if (Array.isArray(value)) {
// Attachments or linked records
const items = value.map((v) => {
if (typeof v === 'object' && v !== null) {
const obj = v as Record<string, unknown>
return (obj.url as string) || (obj.name as string) || JSON.stringify(v)
}
return String(v)
})
lines.push(`${displayName}: ${items.join(', ')}`)
} else if (typeof value === 'object') {
lines.push(`${displayName}: ${JSON.stringify(value)}`)
} else {
lines.push(`${displayName}: ${String(value)}`)
}
}
return lines.join('\n')
}
/**
* Extracts a human-readable title from a record's fields.
* Prefers the configured title field, then falls back to common field names.
*/
function extractTitle(fields: Record<string, unknown>, titleField?: string): string {
if (titleField && fields[titleField] != null) {
return String(fields[titleField])
}
const candidates = ['Name', 'Title', 'name', 'title', 'Summary', 'summary']
for (const candidate of candidates) {
if (fields[candidate] != null) {
return String(fields[candidate])
}
}
for (const value of Object.values(fields)) {
if (typeof value === 'string' && value.trim()) {
return value.length > 80 ? `${value.slice(0, 80)}` : value
}
}
return 'Untitled'
}
/**
* Parses the cursor format: "offset:<airtable_offset>"
*/
function parseCursor(cursor?: string): string | undefined {
if (!cursor) return undefined
if (cursor.startsWith('offset:')) return cursor.slice(7)
return cursor
}
export const airtableConnector: ConnectorConfig = {
id: 'airtable',
name: 'Airtable',
description: 'Sync records from an Airtable table into your knowledge base',
version: '1.0.0',
icon: AirtableIcon,
oauth: {
required: true,
provider: 'airtable',
requiredScopes: ['data.records:read', 'schema.bases:read'],
},
configFields: [
{
id: 'baseId',
title: 'Base ID',
type: 'short-input',
placeholder: 'e.g. appXXXXXXXXXXXXXX',
required: true,
},
{
id: 'tableIdOrName',
title: 'Table Name or ID',
type: 'short-input',
placeholder: 'e.g. Tasks or tblXXXXXXXXXXXXXX',
required: true,
},
{
id: 'viewId',
title: 'View',
type: 'short-input',
placeholder: 'e.g. Grid view or viwXXXXXXXXXXXXXX',
required: false,
},
{
id: 'titleField',
title: 'Title Field',
type: 'short-input',
placeholder: 'e.g. Name',
required: false,
},
{
id: 'maxRecords',
title: 'Max Records',
type: 'short-input',
placeholder: 'e.g. 1000 (default: unlimited)',
required: false,
},
],
listDocuments: async (
accessToken: string,
sourceConfig: Record<string, unknown>,
cursor?: string,
syncContext?: Record<string, unknown>
): Promise<ExternalDocumentList> => {
const baseId = sourceConfig.baseId as string
const tableIdOrName = sourceConfig.tableIdOrName as string
const viewId = sourceConfig.viewId as string | undefined
const titleField = sourceConfig.titleField as string | undefined
const maxRecords = sourceConfig.maxRecords ? Number(sourceConfig.maxRecords) : 0
const fieldNames = await fetchFieldNames(accessToken, baseId, tableIdOrName, syncContext)
const params = new URLSearchParams()
params.append('pageSize', String(PAGE_SIZE))
if (viewId) params.append('view', viewId)
if (maxRecords > 0) params.append('maxRecords', String(maxRecords))
const offset = parseCursor(cursor)
if (offset) params.append('offset', offset)
const encodedTable = encodeURIComponent(tableIdOrName)
const url = `${AIRTABLE_API}/${baseId}/${encodedTable}?${params.toString()}`
logger.info(`Listing records from ${baseId}/${tableIdOrName}`, {
offset: offset ?? 'none',
view: viewId ?? 'default',
})
const response = await fetchWithRetry(url, {
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
},
})
if (!response.ok) {
const errorText = await response.text()
logger.error('Failed to list Airtable records', {
status: response.status,
error: errorText,
})
throw new Error(`Failed to list Airtable records: ${response.status}`)
}
const data = (await response.json()) as {
records: AirtableRecord[]
offset?: string
}
const records = data.records || []
const documents: ExternalDocument[] = await Promise.all(
records.map((record) =>
recordToDocument(record, baseId, tableIdOrName, titleField, fieldNames)
)
)
const nextOffset = data.offset
return {
documents,
nextCursor: nextOffset ? `offset:${nextOffset}` : undefined,
hasMore: Boolean(nextOffset),
}
},
getDocument: async (
accessToken: string,
sourceConfig: Record<string, unknown>,
externalId: string
): Promise<ExternalDocument | null> => {
const baseId = sourceConfig.baseId as string
const tableIdOrName = sourceConfig.tableIdOrName as string
const titleField = sourceConfig.titleField as string | undefined
const fieldNames = await fetchFieldNames(accessToken, baseId, tableIdOrName)
const encodedTable = encodeURIComponent(tableIdOrName)
const url = `${AIRTABLE_API}/${baseId}/${encodedTable}/${externalId}`
const response = await fetchWithRetry(url, {
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
},
})
if (!response.ok) {
if (response.status === 404 || response.status === 422) return null
throw new Error(`Failed to get Airtable record: ${response.status}`)
}
const record = (await response.json()) as AirtableRecord
return recordToDocument(record, baseId, tableIdOrName, titleField, fieldNames)
},
validateConfig: async (
accessToken: string,
sourceConfig: Record<string, unknown>
): Promise<{ valid: boolean; error?: string }> => {
const baseId = sourceConfig.baseId as string
const tableIdOrName = sourceConfig.tableIdOrName as string
if (!baseId || !tableIdOrName) {
return { valid: false, error: 'Base ID and table name are required' }
}
if (baseId && !baseId.startsWith('app')) {
return { valid: false, error: 'Base ID should start with "app"' }
}
const maxRecords = sourceConfig.maxRecords as string | undefined
if (maxRecords && (Number.isNaN(Number(maxRecords)) || Number(maxRecords) <= 0)) {
return { valid: false, error: 'Max records must be a positive number' }
}
try {
const encodedTable = encodeURIComponent(tableIdOrName)
const url = `${AIRTABLE_API}/${baseId}/${encodedTable}?pageSize=1`
const response = await fetchWithRetry(
url,
{
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
VALIDATE_RETRY_OPTIONS
)
if (!response.ok) {
const errorText = await response.text()
if (response.status === 404 || response.status === 422) {
return { valid: false, error: `Table "${tableIdOrName}" not found in base "${baseId}"` }
}
if (response.status === 403) {
return { valid: false, error: 'Access denied. Check your Airtable permissions.' }
}
return { valid: false, error: `Airtable API error: ${response.status} - ${errorText}` }
}
const viewId = sourceConfig.viewId as string | undefined
if (viewId) {
const viewUrl = `${AIRTABLE_API}/${baseId}/${encodedTable}?pageSize=1&view=${encodeURIComponent(viewId)}`
const viewResponse = await fetchWithRetry(
viewUrl,
{
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
VALIDATE_RETRY_OPTIONS
)
if (!viewResponse.ok) {
return { valid: false, error: `View "${viewId}" not found in table "${tableIdOrName}"` }
}
}
return { valid: true }
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to validate configuration'
return { valid: false, error: message }
}
},
tagDefinitions: [{ id: 'createdTime', displayName: 'Created Time', fieldType: 'date' }],
mapTags: (metadata: Record<string, unknown>): Record<string, unknown> => {
const result: Record<string, unknown> = {}
if (typeof metadata.createdTime === 'string') {
const date = new Date(metadata.createdTime)
if (!Number.isNaN(date.getTime())) result.createdTime = date
}
return result
},
}
interface AirtableRecord {
id: string
fields: Record<string, unknown>
createdTime: string
}
/**
* Converts an Airtable record to an ExternalDocument.
*/
async function recordToDocument(
record: AirtableRecord,
baseId: string,
tableIdOrName: string,
titleField: string | undefined,
fieldNames: Map<string, string>
): Promise<ExternalDocument> {
const plainText = recordToPlainText(record.fields, fieldNames)
const contentHash = await computeContentHash(plainText)
const title = extractTitle(record.fields, titleField)
const encodedTable = encodeURIComponent(tableIdOrName)
const sourceUrl = `https://airtable.com/${baseId}/${encodedTable}/${record.id}`
return {
externalId: record.id,
title,
content: plainText,
mimeType: 'text/plain',
sourceUrl,
contentHash,
metadata: {
createdTime: record.createdTime,
},
}
}
/**
* Fetches the table schema to build a field ID → field name mapping.
*/
async function fetchFieldNames(
accessToken: string,
baseId: string,
tableIdOrName: string,
syncContext?: Record<string, unknown>
): Promise<Map<string, string>> {
const cacheKey = `fieldNames:${baseId}/${tableIdOrName}`
if (syncContext?.[cacheKey]) return syncContext[cacheKey] as Map<string, string>
const fieldNames = new Map<string, string>()
try {
const url = `${AIRTABLE_API}/meta/bases/${baseId}/tables`
const response = await fetchWithRetry(url, {
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
},
})
if (!response.ok) {
logger.warn('Failed to fetch Airtable schema, using raw field keys', {
status: response.status,
})
return fieldNames
}
const data = (await response.json()) as {
tables: { id: string; name: string; fields: { id: string; name: string; type: string }[] }[]
}
const table = data.tables.find((t) => t.id === tableIdOrName || t.name === tableIdOrName)
if (table) {
for (const field of table.fields) {
fieldNames.set(field.id, field.name)
fieldNames.set(field.name, field.name)
}
}
} catch (error) {
logger.warn('Error fetching Airtable schema', {
error: error instanceof Error ? error.message : String(error),
})
}
if (syncContext) syncContext[cacheKey] = fieldNames
return fieldNames
}

View File

@@ -1 +0,0 @@
export { airtableConnector } from '@/connectors/airtable/airtable'

View File

@@ -1,31 +0,0 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { escapeCql } from '@/connectors/confluence/confluence'
describe('escapeCql', () => {
it.concurrent('returns plain strings unchanged', () => {
expect(escapeCql('Engineering')).toBe('Engineering')
})
it.concurrent('escapes double quotes', () => {
expect(escapeCql('say "hello"')).toBe('say \\"hello\\"')
})
it.concurrent('escapes backslashes', () => {
expect(escapeCql('path\\to\\file')).toBe('path\\\\to\\\\file')
})
it.concurrent('escapes backslashes before quotes', () => {
expect(escapeCql('a\\"b')).toBe('a\\\\\\"b')
})
it.concurrent('handles empty string', () => {
expect(escapeCql('')).toBe('')
})
it.concurrent('leaves other special chars unchanged', () => {
expect(escapeCql("it's a test & <tag>")).toBe("it's a test & <tag>")
})
})

View File

@@ -1,619 +0,0 @@
import { createLogger } from '@sim/logger'
import { ConfluenceIcon } from '@/components/icons'
import { fetchWithRetry } from '@/lib/knowledge/documents/utils'
import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types'
import { getConfluenceCloudId } from '@/tools/confluence/utils'
const logger = createLogger('ConfluenceConnector')
/**
* Escapes a value for use inside CQL double-quoted strings.
*/
export function escapeCql(value: string): string {
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
}
/**
* Strips HTML tags from content and decodes HTML entities.
*/
function htmlToPlainText(html: string): string {
let text = html.replace(/<[^>]*>/g, ' ')
text = text
.replace(/&nbsp;/g, ' ')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&amp;/g, '&')
return text.replace(/\s+/g, ' ').trim()
}
/**
* Computes a SHA-256 hash of the given content.
*/
async function computeContentHash(content: string): Promise<string> {
const encoder = new TextEncoder()
const data = encoder.encode(content)
const hashBuffer = await crypto.subtle.digest('SHA-256', data)
const hashArray = Array.from(new Uint8Array(hashBuffer))
return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('')
}
/**
* Fetches labels for a batch of page IDs using the v2 labels endpoint.
*/
async function fetchLabelsForPages(
cloudId: string,
accessToken: string,
pageIds: string[]
): Promise<Map<string, string[]>> {
const labelsByPageId = new Map<string, string[]>()
const results = await Promise.all(
pageIds.map(async (pageId) => {
try {
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/pages/${pageId}/labels`
const response = await fetchWithRetry(url, {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
})
if (!response.ok) {
logger.warn(`Failed to fetch labels for page ${pageId}`, { status: response.status })
return { pageId, labels: [] as string[] }
}
const data = await response.json()
const labels = (data.results || []).map(
(label: Record<string, unknown>) => label.name as string
)
return { pageId, labels }
} catch (error) {
logger.warn(`Error fetching labels for page ${pageId}`, {
error: error instanceof Error ? error.message : String(error),
})
return { pageId, labels: [] as string[] }
}
})
)
for (const { pageId, labels } of results) {
labelsByPageId.set(pageId, labels)
}
return labelsByPageId
}
/**
* Converts a v1 CQL search result item to an ExternalDocument.
*/
async function cqlResultToDocument(
item: Record<string, unknown>,
domain: string
): Promise<ExternalDocument> {
const body = item.body as Record<string, Record<string, string>> | undefined
const rawContent = body?.storage?.value || ''
const plainText = htmlToPlainText(rawContent)
const contentHash = await computeContentHash(plainText)
const version = item.version as Record<string, unknown> | undefined
const links = item._links as Record<string, string> | undefined
const metadata = item.metadata as Record<string, unknown> | undefined
const labelsWrapper = metadata?.labels as Record<string, unknown> | undefined
const labelResults = (labelsWrapper?.results || []) as Record<string, unknown>[]
const labels = labelResults.map((l) => l.name as string)
return {
externalId: String(item.id),
title: (item.title as string) || 'Untitled',
content: plainText,
mimeType: 'text/plain',
sourceUrl: links?.webui ? `https://${domain}/wiki${links.webui}` : undefined,
contentHash,
metadata: {
spaceId: (item.space as Record<string, unknown>)?.key,
status: item.status,
version: version?.number,
labels,
lastModified: version?.when,
},
}
}
export const confluenceConnector: ConnectorConfig = {
id: 'confluence',
name: 'Confluence',
description: 'Sync pages from a Confluence space into your knowledge base',
version: '1.1.0',
icon: ConfluenceIcon,
oauth: {
required: true,
provider: 'confluence',
requiredScopes: ['read:confluence-content.all', 'read:page:confluence', 'offline_access'],
},
configFields: [
{
id: 'domain',
title: 'Confluence Domain',
type: 'short-input',
placeholder: 'yoursite.atlassian.net',
required: true,
},
{
id: 'spaceKey',
title: 'Space Key',
type: 'short-input',
placeholder: 'e.g. ENG, PRODUCT',
required: true,
},
{
id: 'contentType',
title: 'Content Type',
type: 'dropdown',
required: false,
options: [
{ label: 'Pages only', id: 'page' },
{ label: 'Blog posts only', id: 'blogpost' },
{ label: 'All content', id: 'all' },
],
},
{
id: 'labelFilter',
title: 'Filter by Label',
type: 'short-input',
required: false,
placeholder: 'e.g. published, engineering',
},
{
id: 'maxPages',
title: 'Max Pages',
type: 'short-input',
required: false,
placeholder: 'e.g. 500 (default: unlimited)',
},
],
listDocuments: async (
accessToken: string,
sourceConfig: Record<string, unknown>,
cursor?: string
): Promise<ExternalDocumentList> => {
const domain = sourceConfig.domain as string
const spaceKey = sourceConfig.spaceKey as string
const contentType = (sourceConfig.contentType as string) || 'page'
const labelFilter = (sourceConfig.labelFilter as string) || ''
const maxPages = sourceConfig.maxPages ? Number(sourceConfig.maxPages) : 0
const cloudId = await getConfluenceCloudId(domain, accessToken)
// If label filtering is enabled, use CQL search via v1 API
if (labelFilter.trim()) {
return listDocumentsViaCql(
cloudId,
accessToken,
domain,
spaceKey,
contentType,
labelFilter,
maxPages,
cursor
)
}
// Otherwise use v2 API (default path)
const spaceId = await resolveSpaceId(cloudId, accessToken, spaceKey)
if (contentType === 'all') {
return listAllContentTypes(cloudId, accessToken, domain, spaceId, spaceKey, maxPages, cursor)
}
return listDocumentsV2(
cloudId,
accessToken,
domain,
spaceId,
spaceKey,
contentType,
maxPages,
cursor
)
},
getDocument: async (
accessToken: string,
sourceConfig: Record<string, unknown>,
externalId: string
): Promise<ExternalDocument | null> => {
const domain = sourceConfig.domain as string
const cloudId = await getConfluenceCloudId(domain, accessToken)
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/pages/${externalId}?body-format=storage`
const response = await fetchWithRetry(url, {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
})
if (!response.ok) {
if (response.status === 404) return null
throw new Error(`Failed to get Confluence page: ${response.status}`)
}
const page = await response.json()
const rawContent = page.body?.storage?.value || ''
const plainText = htmlToPlainText(rawContent)
const contentHash = await computeContentHash(plainText)
// Fetch labels for this page
const labelMap = await fetchLabelsForPages(cloudId, accessToken, [String(page.id)])
const labels = labelMap.get(String(page.id)) ?? []
return {
externalId: String(page.id),
title: page.title || 'Untitled',
content: plainText,
mimeType: 'text/plain',
sourceUrl: page._links?.webui ? `https://${domain}/wiki${page._links.webui}` : undefined,
contentHash,
metadata: {
spaceId: page.spaceId,
status: page.status,
version: page.version?.number,
labels,
lastModified: page.version?.createdAt,
},
}
},
validateConfig: async (
accessToken: string,
sourceConfig: Record<string, unknown>
): Promise<{ valid: boolean; error?: string }> => {
const domain = sourceConfig.domain as string
const spaceKey = sourceConfig.spaceKey as string
if (!domain || !spaceKey) {
return { valid: false, error: 'Domain and space key are required' }
}
const maxPages = sourceConfig.maxPages as string | undefined
if (maxPages && (Number.isNaN(Number(maxPages)) || Number(maxPages) <= 0)) {
return { valid: false, error: 'Max pages must be a positive number' }
}
try {
const cloudId = await getConfluenceCloudId(domain, accessToken)
await resolveSpaceId(cloudId, accessToken, spaceKey)
return { valid: true }
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to validate configuration'
return { valid: false, error: message }
}
},
tagDefinitions: [
{ id: 'labels', displayName: 'Labels', fieldType: 'text' },
{ id: 'version', displayName: 'Version', fieldType: 'number' },
{ id: 'lastModified', displayName: 'Last Modified', fieldType: 'date' },
],
mapTags: (metadata: Record<string, unknown>): Record<string, unknown> => {
const result: Record<string, unknown> = {}
const labels = Array.isArray(metadata.labels) ? (metadata.labels as string[]) : []
if (labels.length > 0) result.labels = labels.join(', ')
if (metadata.version != null) {
const num = Number(metadata.version)
if (!Number.isNaN(num)) result.version = num
}
if (typeof metadata.lastModified === 'string') {
const date = new Date(metadata.lastModified)
if (!Number.isNaN(date.getTime())) result.lastModified = date
}
return result
},
}
/**
* Lists documents using the v2 API for a single content type (pages or blogposts).
*/
async function listDocumentsV2(
cloudId: string,
accessToken: string,
domain: string,
spaceId: string,
spaceKey: string,
contentType: string,
maxPages: number,
cursor?: string
): Promise<ExternalDocumentList> {
const queryParams = new URLSearchParams()
queryParams.append('limit', '50')
queryParams.append('body-format', 'storage')
if (cursor) {
queryParams.append('cursor', cursor)
}
const endpoint = contentType === 'blogpost' ? 'blogposts' : 'pages'
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/spaces/${spaceId}/${endpoint}?${queryParams.toString()}`
logger.info(`Listing ${endpoint} in space ${spaceKey} (ID: ${spaceId})`)
const response = await fetchWithRetry(url, {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
})
if (!response.ok) {
const errorText = await response.text()
logger.error(`Failed to list Confluence ${endpoint}`, {
status: response.status,
error: errorText,
})
throw new Error(`Failed to list Confluence ${endpoint}: ${response.status}`)
}
const data = await response.json()
const results = data.results || []
// Fetch labels for all pages in this batch
const pageIds = results.map((page: Record<string, unknown>) => String(page.id))
const labelsByPageId = await fetchLabelsForPages(cloudId, accessToken, pageIds)
const documents: ExternalDocument[] = await Promise.all(
results.map(async (page: Record<string, unknown>) => {
const rawContent = (page.body as Record<string, Record<string, string>>)?.storage?.value || ''
const plainText = htmlToPlainText(rawContent)
const contentHash = await computeContentHash(plainText)
const pageId = String(page.id)
return {
externalId: pageId,
title: (page.title as string) || 'Untitled',
content: plainText,
mimeType: 'text/plain',
sourceUrl: (page._links as Record<string, string>)?.webui
? `https://${domain}/wiki${(page._links as Record<string, string>).webui}`
: undefined,
contentHash,
metadata: {
spaceId: page.spaceId,
status: page.status,
version: (page.version as Record<string, unknown>)?.number,
labels: labelsByPageId.get(pageId) ?? [],
lastModified: (page.version as Record<string, unknown>)?.createdAt,
},
}
})
)
// Extract next cursor from _links.next
let nextCursor: string | undefined
const nextLink = (data._links as Record<string, string>)?.next
if (nextLink) {
try {
nextCursor = new URL(nextLink, 'https://placeholder').searchParams.get('cursor') || undefined
} catch {
// Ignore malformed URLs
}
}
// Enforce maxPages limit
if (maxPages > 0 && !cursor) {
// On subsequent pages, the sync engine tracks total count
// We signal stop by clearing hasMore when we'd exceed maxPages
}
return {
documents,
nextCursor,
hasMore: Boolean(nextCursor),
}
}
/**
* Lists both pages and blogposts using a compound cursor that tracks
* pagination state for each content type independently.
*/
async function listAllContentTypes(
cloudId: string,
accessToken: string,
domain: string,
spaceId: string,
spaceKey: string,
maxPages: number,
cursor?: string
): Promise<ExternalDocumentList> {
let pageCursor: string | undefined
let blogCursor: string | undefined
let pagesDone = false
let blogsDone = false
if (cursor) {
try {
const parsed = JSON.parse(cursor)
pageCursor = parsed.page
blogCursor = parsed.blog
pagesDone = parsed.pagesDone === true
blogsDone = parsed.blogsDone === true
} catch {
pageCursor = cursor
}
}
const results: ExternalDocumentList = { documents: [], hasMore: false }
if (!pagesDone) {
const pagesResult = await listDocumentsV2(
cloudId,
accessToken,
domain,
spaceId,
spaceKey,
'page',
maxPages,
pageCursor
)
results.documents.push(...pagesResult.documents)
pageCursor = pagesResult.nextCursor
pagesDone = !pagesResult.hasMore
}
if (!blogsDone) {
const blogResult = await listDocumentsV2(
cloudId,
accessToken,
domain,
spaceId,
spaceKey,
'blogpost',
maxPages,
blogCursor
)
results.documents.push(...blogResult.documents)
blogCursor = blogResult.nextCursor
blogsDone = !blogResult.hasMore
}
results.hasMore = !pagesDone || !blogsDone
if (results.hasMore) {
results.nextCursor = JSON.stringify({
page: pageCursor,
blog: blogCursor,
pagesDone,
blogsDone,
})
}
return results
}
/**
* Lists documents using CQL search via the v1 API (used when label filtering is enabled).
*/
async function listDocumentsViaCql(
cloudId: string,
accessToken: string,
domain: string,
spaceKey: string,
contentType: string,
labelFilter: string,
maxPages: number,
cursor?: string
): Promise<ExternalDocumentList> {
const labels = labelFilter
.split(',')
.map((l) => l.trim())
.filter(Boolean)
// Build CQL query
let cql = `space="${escapeCql(spaceKey)}"`
if (contentType === 'blogpost') {
cql += ' AND type="blogpost"'
} else if (contentType === 'page' || !contentType) {
cql += ' AND type="page"'
}
// contentType === 'all' — no type filter
if (labels.length === 1) {
cql += ` AND label="${escapeCql(labels[0])}"`
} else if (labels.length > 1) {
const labelList = labels.map((l) => `"${escapeCql(l)}"`).join(',')
cql += ` AND label in (${labelList})`
}
const limit = maxPages > 0 ? Math.min(maxPages, 50) : 50
const start = cursor ? Number(cursor) : 0
const queryParams = new URLSearchParams()
queryParams.append('cql', cql)
queryParams.append('limit', String(limit))
queryParams.append('start', String(start))
queryParams.append('expand', 'body.storage,version,metadata.labels')
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/rest/api/content/search?${queryParams.toString()}`
logger.info(`Searching Confluence via CQL: ${cql}`, { start, limit })
const response = await fetchWithRetry(url, {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
})
if (!response.ok) {
const errorText = await response.text()
logger.error('Failed to search Confluence via CQL', {
status: response.status,
error: errorText,
})
throw new Error(`Failed to search Confluence via CQL: ${response.status}`)
}
const data = await response.json()
const results = data.results || []
const documents: ExternalDocument[] = await Promise.all(
results.map((item: Record<string, unknown>) => cqlResultToDocument(item, domain))
)
const totalSize = (data.totalSize as number) ?? 0
const nextStart = start + results.length
const hasMore = nextStart < totalSize && (maxPages <= 0 || nextStart < maxPages)
return {
documents,
nextCursor: hasMore ? String(nextStart) : undefined,
hasMore,
}
}
/**
* Resolves a Confluence space key to its numeric space ID.
*/
async function resolveSpaceId(
cloudId: string,
accessToken: string,
spaceKey: string
): Promise<string> {
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/spaces?keys=${encodeURIComponent(spaceKey)}&limit=1`
const response = await fetchWithRetry(url, {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
})
if (!response.ok) {
throw new Error(`Failed to resolve space key "${spaceKey}": ${response.status}`)
}
const data = await response.json()
const results = data.results || []
if (results.length === 0) {
throw new Error(`Space "${spaceKey}" not found`)
}
return String(results[0].id)
}

View File

@@ -1 +0,0 @@
export { confluenceConnector } from '@/connectors/confluence/confluence'

View File

@@ -1,415 +0,0 @@
import { createLogger } from '@sim/logger'
import { GithubIcon } from '@/components/icons'
import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils'
import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types'
const logger = createLogger('GitHubConnector')
const GITHUB_API_URL = 'https://api.github.com'
const BATCH_SIZE = 30
/**
* Computes a SHA-256 hash of the given content.
*/
async function computeContentHash(content: string): Promise<string> {
const data = new TextEncoder().encode(content)
const hashBuffer = await crypto.subtle.digest('SHA-256', data)
return Array.from(new Uint8Array(hashBuffer))
.map((b) => b.toString(16).padStart(2, '0'))
.join('')
}
/**
* Parses the repository string into owner and repo.
*/
function parseRepo(repository: string): { owner: string; repo: string } {
const cleaned = repository.replace(/^https?:\/\/github\.com\//, '').replace(/\.git$/, '')
const parts = cleaned.split('/')
if (parts.length < 2 || !parts[0] || !parts[1]) {
throw new Error(`Invalid repository format: "${repository}". Use "owner/repo".`)
}
return { owner: parts[0], repo: parts[1] }
}
/**
* File extension filter set from user config. Returns null if no filter (accept all).
*/
function parseExtensions(extensions: string): Set<string> | null {
const trimmed = extensions.trim()
if (!trimmed) return null
const exts = trimmed
.split(',')
.map((e) => e.trim().toLowerCase())
.filter(Boolean)
.map((e) => (e.startsWith('.') ? e : `.${e}`))
return exts.length > 0 ? new Set(exts) : null
}
/**
* Checks whether a file path matches the extension filter.
*/
function matchesExtension(filePath: string, extSet: Set<string> | null): boolean {
if (!extSet) return true
const lastDot = filePath.lastIndexOf('.')
if (lastDot === -1) return false
return extSet.has(filePath.slice(lastDot).toLowerCase())
}
interface TreeItem {
path: string
mode: string
type: string
sha: string
size?: number
}
/**
* Fetches the full recursive tree for a branch.
*/
async function fetchTree(
accessToken: string,
owner: string,
repo: string,
branch: string
): Promise<TreeItem[]> {
const url = `${GITHUB_API_URL}/repos/${owner}/${repo}/git/trees/${encodeURIComponent(branch)}?recursive=1`
const response = await fetchWithRetry(url, {
method: 'GET',
headers: {
Accept: 'application/vnd.github+json',
Authorization: `Bearer ${accessToken}`,
'X-GitHub-Api-Version': '2022-11-28',
},
})
if (!response.ok) {
const errorText = await response.text()
logger.error('Failed to fetch GitHub tree', { status: response.status, error: errorText })
throw new Error(`Failed to fetch repository tree: ${response.status}`)
}
const data = await response.json()
if (data.truncated) {
logger.warn('GitHub tree was truncated — some files may be missing', { owner, repo, branch })
}
return (data.tree || []).filter((item: TreeItem) => item.type === 'blob')
}
/**
* Fetches file content via the Blobs API and decodes base64.
*/
async function fetchBlobContent(
accessToken: string,
owner: string,
repo: string,
sha: string
): Promise<string> {
const url = `${GITHUB_API_URL}/repos/${owner}/${repo}/git/blobs/${sha}`
const response = await fetchWithRetry(url, {
method: 'GET',
headers: {
Accept: 'application/vnd.github+json',
Authorization: `Bearer ${accessToken}`,
'X-GitHub-Api-Version': '2022-11-28',
},
})
if (!response.ok) {
throw new Error(`Failed to fetch blob ${sha}: ${response.status}`)
}
const data = await response.json()
if (data.encoding === 'base64') {
return atob(data.content.replace(/\n/g, ''))
}
return data.content || ''
}
/**
* Converts a tree item to an ExternalDocument by fetching its content.
*/
async function treeItemToDocument(
accessToken: string,
owner: string,
repo: string,
branch: string,
item: TreeItem
): Promise<ExternalDocument> {
const content = await fetchBlobContent(accessToken, owner, repo, item.sha)
const contentHash = await computeContentHash(content)
return {
externalId: item.path,
title: item.path.split('/').pop() || item.path,
content,
mimeType: 'text/plain',
sourceUrl: `https://github.com/${owner}/${repo}/blob/${branch}/${item.path}`,
contentHash,
metadata: {
path: item.path,
sha: item.sha,
size: item.size,
branch,
repository: `${owner}/${repo}`,
},
}
}
export const githubConnector: ConnectorConfig = {
id: 'github',
name: 'GitHub',
description: 'Sync files from a GitHub repository into your knowledge base',
version: '1.0.0',
icon: GithubIcon,
oauth: {
required: true,
provider: 'github',
requiredScopes: ['repo'],
},
configFields: [
{
id: 'repository',
title: 'Repository',
type: 'short-input',
placeholder: 'owner/repo',
required: true,
},
{
id: 'branch',
title: 'Branch',
type: 'short-input',
placeholder: 'main (default)',
required: false,
},
{
id: 'pathPrefix',
title: 'Path Filter',
type: 'short-input',
placeholder: 'e.g. docs/, src/components/',
required: false,
},
{
id: 'extensions',
title: 'File Extensions',
type: 'short-input',
placeholder: 'e.g. .md, .txt, .mdx',
required: false,
},
{
id: 'maxFiles',
title: 'Max Files',
type: 'short-input',
required: false,
placeholder: 'e.g. 500 (default: unlimited)',
},
],
listDocuments: async (
accessToken: string,
sourceConfig: Record<string, unknown>,
cursor?: string
): Promise<ExternalDocumentList> => {
const { owner, repo } = parseRepo(sourceConfig.repository as string)
const branch = ((sourceConfig.branch as string) || 'main').trim()
const pathPrefix = ((sourceConfig.pathPrefix as string) || '').trim()
const extSet = parseExtensions((sourceConfig.extensions as string) || '')
const maxFiles = sourceConfig.maxFiles ? Number(sourceConfig.maxFiles) : 0
const tree = await fetchTree(accessToken, owner, repo, branch)
// Filter by path prefix and extensions
const filtered = tree.filter((item) => {
if (pathPrefix && !item.path.startsWith(pathPrefix)) return false
if (!matchesExtension(item.path, extSet)) return false
return true
})
// Apply max files limit
const capped = maxFiles > 0 ? filtered.slice(0, maxFiles) : filtered
// Paginate using offset cursor
const offset = cursor ? Number(cursor) : 0
const batch = capped.slice(offset, offset + BATCH_SIZE)
logger.info('Listing GitHub files', {
owner,
repo,
branch,
totalFiltered: capped.length,
offset,
batchSize: batch.length,
})
const documents: ExternalDocument[] = []
for (const item of batch) {
try {
const doc = await treeItemToDocument(accessToken, owner, repo, branch, item)
documents.push(doc)
} catch (error) {
logger.warn(`Failed to fetch content for ${item.path}`, {
error: error instanceof Error ? error.message : String(error),
})
}
}
const nextOffset = offset + BATCH_SIZE
const hasMore = nextOffset < capped.length
return {
documents,
nextCursor: hasMore ? String(nextOffset) : undefined,
hasMore,
}
},
getDocument: async (
accessToken: string,
sourceConfig: Record<string, unknown>,
externalId: string
): Promise<ExternalDocument | null> => {
const { owner, repo } = parseRepo(sourceConfig.repository as string)
const branch = ((sourceConfig.branch as string) || 'main').trim()
// externalId is the file path
const path = externalId
try {
const encodedPath = path.split('/').map(encodeURIComponent).join('/')
const url = `${GITHUB_API_URL}/repos/${owner}/${repo}/contents/${encodedPath}?ref=${encodeURIComponent(branch)}`
const response = await fetchWithRetry(url, {
method: 'GET',
headers: {
Accept: 'application/vnd.github+json',
Authorization: `Bearer ${accessToken}`,
'X-GitHub-Api-Version': '2022-11-28',
},
})
if (!response.ok) {
if (response.status === 404) return null
throw new Error(`Failed to fetch file ${path}: ${response.status}`)
}
const data = await response.json()
const content =
data.encoding === 'base64'
? atob((data.content as string).replace(/\n/g, ''))
: (data.content as string) || ''
const contentHash = await computeContentHash(content)
return {
externalId,
title: path.split('/').pop() || path,
content,
mimeType: 'text/plain',
sourceUrl: `https://github.com/${owner}/${repo}/blob/${branch}/${path}`,
contentHash,
metadata: {
path,
sha: data.sha as string,
size: data.size as number,
branch,
repository: `${owner}/${repo}`,
},
}
} catch (error) {
logger.warn(`Failed to fetch GitHub document ${externalId}`, {
error: error instanceof Error ? error.message : String(error),
})
return null
}
},
validateConfig: async (
accessToken: string,
sourceConfig: Record<string, unknown>
): Promise<{ valid: boolean; error?: string }> => {
const repository = (sourceConfig.repository as string)?.trim()
if (!repository) {
return { valid: false, error: 'Repository is required' }
}
let owner: string
let repo: string
try {
const parsed = parseRepo(repository)
owner = parsed.owner
repo = parsed.repo
} catch (error) {
return {
valid: false,
error: error instanceof Error ? error.message : 'Invalid repository format',
}
}
const maxFiles = sourceConfig.maxFiles as string | undefined
if (maxFiles && (Number.isNaN(Number(maxFiles)) || Number(maxFiles) <= 0)) {
return { valid: false, error: 'Max files must be a positive number' }
}
const branch = ((sourceConfig.branch as string) || 'main').trim()
try {
// Verify repo and branch are accessible
const url = `${GITHUB_API_URL}/repos/${owner}/${repo}/branches/${encodeURIComponent(branch)}`
const response = await fetchWithRetry(
url,
{
method: 'GET',
headers: {
Accept: 'application/vnd.github+json',
Authorization: `Bearer ${accessToken}`,
'X-GitHub-Api-Version': '2022-11-28',
},
},
VALIDATE_RETRY_OPTIONS
)
if (response.status === 404) {
return {
valid: false,
error: `Repository "${owner}/${repo}" or branch "${branch}" not found`,
}
}
if (!response.ok) {
return { valid: false, error: `Cannot access repository: ${response.status}` }
}
return { valid: true }
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to validate configuration'
return { valid: false, error: message }
}
},
tagDefinitions: [
{ id: 'path', displayName: 'File Path', fieldType: 'text' },
{ id: 'repository', displayName: 'Repository', fieldType: 'text' },
{ id: 'branch', displayName: 'Branch', fieldType: 'text' },
{ id: 'size', displayName: 'File Size', fieldType: 'number' },
],
mapTags: (metadata: Record<string, unknown>): Record<string, unknown> => {
const result: Record<string, unknown> = {}
if (typeof metadata.path === 'string') result.path = metadata.path
if (typeof metadata.repository === 'string') result.repository = metadata.repository
if (typeof metadata.branch === 'string') result.branch = metadata.branch
if (metadata.size != null) {
const num = Number(metadata.size)
if (!Number.isNaN(num)) result.size = num
}
return result
},
}

View File

@@ -1 +0,0 @@
export { githubConnector } from '@/connectors/github/github'

View File

@@ -1,433 +0,0 @@
import { createLogger } from '@sim/logger'
import { GoogleDriveIcon } from '@/components/icons'
import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils'
import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types'
const logger = createLogger('GoogleDriveConnector')
const GOOGLE_WORKSPACE_MIME_TYPES: Record<string, string> = {
'application/vnd.google-apps.document': 'text/plain',
'application/vnd.google-apps.spreadsheet': 'text/csv',
'application/vnd.google-apps.presentation': 'text/plain',
}
const SUPPORTED_TEXT_MIME_TYPES = [
'text/plain',
'text/csv',
'text/html',
'text/markdown',
'application/json',
'application/xml',
]
const MAX_EXPORT_SIZE = 10 * 1024 * 1024 // 10 MB (Google export limit)
async function computeContentHash(content: string): Promise<string> {
const data = new TextEncoder().encode(content)
const hashBuffer = await crypto.subtle.digest('SHA-256', data)
return Array.from(new Uint8Array(hashBuffer))
.map((b) => b.toString(16).padStart(2, '0'))
.join('')
}
function isGoogleWorkspaceFile(mimeType: string): boolean {
return mimeType in GOOGLE_WORKSPACE_MIME_TYPES
}
function isSupportedTextFile(mimeType: string): boolean {
return SUPPORTED_TEXT_MIME_TYPES.some((t) => mimeType.startsWith(t))
}
function htmlToPlainText(html: string): string {
let text = html.replace(/<[^>]*>/g, ' ')
text = text
.replace(/&nbsp;/g, ' ')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&amp;/g, '&')
return text.replace(/\s+/g, ' ').trim()
}
async function exportGoogleWorkspaceFile(
accessToken: string,
fileId: string,
sourceMimeType: string
): Promise<string> {
const exportMimeType = GOOGLE_WORKSPACE_MIME_TYPES[sourceMimeType]
if (!exportMimeType) {
throw new Error(`Unsupported Google Workspace MIME type: ${sourceMimeType}`)
}
const url = `https://www.googleapis.com/drive/v3/files/${fileId}/export?mimeType=${encodeURIComponent(exportMimeType)}`
const response = await fetchWithRetry(url, {
method: 'GET',
headers: { Authorization: `Bearer ${accessToken}` },
})
if (!response.ok) {
throw new Error(`Failed to export file ${fileId}: ${response.status}`)
}
return response.text()
}
async function downloadTextFile(accessToken: string, fileId: string): Promise<string> {
const url = `https://www.googleapis.com/drive/v3/files/${fileId}?alt=media`
const response = await fetchWithRetry(url, {
method: 'GET',
headers: { Authorization: `Bearer ${accessToken}` },
})
if (!response.ok) {
throw new Error(`Failed to download file ${fileId}: ${response.status}`)
}
const text = await response.text()
if (text.length > MAX_EXPORT_SIZE) {
return text.slice(0, MAX_EXPORT_SIZE)
}
return text
}
async function fetchFileContent(
accessToken: string,
fileId: string,
mimeType: string
): Promise<string> {
if (isGoogleWorkspaceFile(mimeType)) {
return exportGoogleWorkspaceFile(accessToken, fileId, mimeType)
}
if (mimeType === 'text/html') {
const html = await downloadTextFile(accessToken, fileId)
return htmlToPlainText(html)
}
if (isSupportedTextFile(mimeType)) {
return downloadTextFile(accessToken, fileId)
}
throw new Error(`Unsupported MIME type for content extraction: ${mimeType}`)
}
interface DriveFile {
id: string
name: string
mimeType: string
modifiedTime?: string
createdTime?: string
webViewLink?: string
parents?: string[]
owners?: { displayName?: string; emailAddress?: string }[]
size?: string
starred?: boolean
trashed?: boolean
}
function buildQuery(sourceConfig: Record<string, unknown>): string {
const parts: string[] = ['trashed = false']
const folderId = sourceConfig.folderId as string | undefined
if (folderId?.trim()) {
parts.push(`'${folderId.trim()}' in parents`)
}
const fileType = (sourceConfig.fileType as string) || 'all'
switch (fileType) {
case 'documents':
parts.push("mimeType = 'application/vnd.google-apps.document'")
break
case 'spreadsheets':
parts.push("mimeType = 'application/vnd.google-apps.spreadsheet'")
break
case 'presentations':
parts.push("mimeType = 'application/vnd.google-apps.presentation'")
break
case 'text':
parts.push(`(${SUPPORTED_TEXT_MIME_TYPES.map((t) => `mimeType = '${t}'`).join(' or ')})`)
break
default: {
// Include Google Workspace files + plain text files, exclude folders
const allMimeTypes = [
...Object.keys(GOOGLE_WORKSPACE_MIME_TYPES),
...SUPPORTED_TEXT_MIME_TYPES,
]
parts.push(`(${allMimeTypes.map((t) => `mimeType = '${t}'`).join(' or ')})`)
break
}
}
return parts.join(' and ')
}
async function fileToDocument(
accessToken: string,
file: DriveFile
): Promise<ExternalDocument | null> {
try {
const content = await fetchFileContent(accessToken, file.id, file.mimeType)
if (!content.trim()) {
logger.info(`Skipping empty file: ${file.name} (${file.id})`)
return null
}
const contentHash = await computeContentHash(content)
return {
externalId: file.id,
title: file.name || 'Untitled',
content,
mimeType: 'text/plain',
sourceUrl: file.webViewLink || `https://drive.google.com/file/d/${file.id}/view`,
contentHash,
metadata: {
originalMimeType: file.mimeType,
modifiedTime: file.modifiedTime,
createdTime: file.createdTime,
owners: file.owners?.map((o) => o.displayName || o.emailAddress).filter(Boolean),
starred: file.starred,
fileSize: file.size ? Number(file.size) : undefined,
},
}
} catch (error) {
logger.warn(`Failed to extract content from file: ${file.name} (${file.id})`, {
error: error instanceof Error ? error.message : String(error),
})
return null
}
}
export const googleDriveConnector: ConnectorConfig = {
id: 'google_drive',
name: 'Google Drive',
description: 'Sync documents from Google Drive into your knowledge base',
version: '1.0.0',
icon: GoogleDriveIcon,
oauth: {
required: true,
provider: 'google-drive',
requiredScopes: ['https://www.googleapis.com/auth/drive.readonly'],
},
configFields: [
{
id: 'folderId',
title: 'Folder ID',
type: 'short-input',
placeholder: 'e.g. 1aBcDeFgHiJkLmNoPqRsTuVwXyZ (optional)',
required: false,
},
{
id: 'fileType',
title: 'File Type',
type: 'dropdown',
required: false,
options: [
{ label: 'All supported files', id: 'all' },
{ label: 'Google Docs only', id: 'documents' },
{ label: 'Google Sheets only', id: 'spreadsheets' },
{ label: 'Google Slides only', id: 'presentations' },
{ label: 'Plain text files only', id: 'text' },
],
},
{
id: 'maxFiles',
title: 'Max Files',
type: 'short-input',
required: false,
placeholder: 'e.g. 500 (default: unlimited)',
},
],
listDocuments: async (
accessToken: string,
sourceConfig: Record<string, unknown>,
cursor?: string
): Promise<ExternalDocumentList> => {
const query = buildQuery(sourceConfig)
const pageSize = 100
const queryParams = new URLSearchParams({
q: query,
pageSize: String(pageSize),
fields:
'nextPageToken,files(id,name,mimeType,modifiedTime,createdTime,webViewLink,parents,owners,size,starred)',
supportsAllDrives: 'true',
includeItemsFromAllDrives: 'true',
})
if (cursor) {
queryParams.set('pageToken', cursor)
}
const url = `https://www.googleapis.com/drive/v3/files?${queryParams.toString()}`
logger.info('Listing Google Drive files', { query, cursor: cursor ?? 'initial' })
const response = await fetchWithRetry(url, {
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
},
})
if (!response.ok) {
const errorText = await response.text()
logger.error('Failed to list Google Drive files', {
status: response.status,
error: errorText,
})
throw new Error(`Failed to list Google Drive files: ${response.status}`)
}
const data = await response.json()
const files = (data.files || []) as DriveFile[]
const documentResults = await Promise.all(
files.map((file) => fileToDocument(accessToken, file))
)
const documents = documentResults.filter(Boolean) as ExternalDocument[]
const nextPageToken = data.nextPageToken as string | undefined
const hasMore = Boolean(nextPageToken)
return {
documents,
nextCursor: nextPageToken,
hasMore,
}
},
getDocument: async (
accessToken: string,
sourceConfig: Record<string, unknown>,
externalId: string
): Promise<ExternalDocument | null> => {
const fields =
'id,name,mimeType,modifiedTime,createdTime,webViewLink,parents,owners,size,starred,trashed'
const url = `https://www.googleapis.com/drive/v3/files/${externalId}?fields=${encodeURIComponent(fields)}&supportsAllDrives=true`
const response = await fetchWithRetry(url, {
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
},
})
if (!response.ok) {
if (response.status === 404) return null
throw new Error(`Failed to get Google Drive file: ${response.status}`)
}
const file = (await response.json()) as DriveFile
if (file.trashed) return null
return fileToDocument(accessToken, file)
},
validateConfig: async (
accessToken: string,
sourceConfig: Record<string, unknown>
): Promise<{ valid: boolean; error?: string }> => {
const folderId = sourceConfig.folderId as string | undefined
const maxFiles = sourceConfig.maxFiles as string | undefined
if (maxFiles && (Number.isNaN(Number(maxFiles)) || Number(maxFiles) <= 0)) {
return { valid: false, error: 'Max files must be a positive number' }
}
// Verify access to Drive API
try {
if (folderId?.trim()) {
// Verify the folder exists and is accessible
const url = `https://www.googleapis.com/drive/v3/files/${folderId.trim()}?fields=id,name,mimeType&supportsAllDrives=true`
const response = await fetchWithRetry(
url,
{
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
},
},
VALIDATE_RETRY_OPTIONS
)
if (!response.ok) {
if (response.status === 404) {
return { valid: false, error: 'Folder not found. Check the folder ID and permissions.' }
}
return { valid: false, error: `Failed to access folder: ${response.status}` }
}
const folder = await response.json()
if (folder.mimeType !== 'application/vnd.google-apps.folder') {
return { valid: false, error: 'The provided ID is not a folder' }
}
} else {
// Verify basic Drive access by listing one file
const url = 'https://www.googleapis.com/drive/v3/files?pageSize=1&fields=files(id)'
const response = await fetchWithRetry(
url,
{
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
},
},
VALIDATE_RETRY_OPTIONS
)
if (!response.ok) {
return { valid: false, error: `Failed to access Google Drive: ${response.status}` }
}
}
return { valid: true }
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to validate configuration'
return { valid: false, error: message }
}
},
tagDefinitions: [
{ id: 'owners', displayName: 'Owner', fieldType: 'text' },
{ id: 'fileType', displayName: 'File Type', fieldType: 'text' },
{ id: 'lastModified', displayName: 'Last Modified', fieldType: 'date' },
{ id: 'starred', displayName: 'Starred', fieldType: 'boolean' },
],
mapTags: (metadata: Record<string, unknown>): Record<string, unknown> => {
const result: Record<string, unknown> = {}
const owners = Array.isArray(metadata.owners) ? (metadata.owners as string[]) : []
if (owners.length > 0) result.owners = owners.join(', ')
if (typeof metadata.originalMimeType === 'string') {
const mimeType = metadata.originalMimeType
if (mimeType.includes('document')) result.fileType = 'Google Doc'
else if (mimeType.includes('spreadsheet')) result.fileType = 'Google Sheet'
else if (mimeType.includes('presentation')) result.fileType = 'Google Slides'
else if (mimeType.startsWith('text/')) result.fileType = 'Text File'
else result.fileType = mimeType
}
if (typeof metadata.modifiedTime === 'string') {
const date = new Date(metadata.modifiedTime)
if (!Number.isNaN(date.getTime())) result.lastModified = date
}
if (typeof metadata.starred === 'boolean') {
result.starred = metadata.starred
}
return result
},
}

View File

@@ -1 +0,0 @@
export { googleDriveConnector } from '@/connectors/google-drive/google-drive'

View File

@@ -1,9 +0,0 @@
export { CONNECTOR_REGISTRY } from '@/connectors/registry'
export type {
ConnectorConfig,
ConnectorConfigField,
ConnectorRegistry,
ExternalDocument,
ExternalDocumentList,
SyncResult,
} from '@/connectors/types'

View File

@@ -1 +0,0 @@
export { jiraConnector } from '@/connectors/jira/jira'

View File

@@ -1,337 +0,0 @@
import { createLogger } from '@sim/logger'
import { JiraIcon } from '@/components/icons'
import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils'
import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types'
import { extractAdfText, getJiraCloudId } from '@/tools/jira/utils'
const logger = createLogger('JiraConnector')
const PAGE_SIZE = 50
/**
* Computes a SHA-256 hash of the given content.
*/
async function computeContentHash(content: string): Promise<string> {
const data = new TextEncoder().encode(content)
const hashBuffer = await crypto.subtle.digest('SHA-256', data)
return Array.from(new Uint8Array(hashBuffer))
.map((b) => b.toString(16).padStart(2, '0'))
.join('')
}
/**
* Builds a plain-text representation of a Jira issue for knowledge base indexing.
*/
function buildIssueContent(fields: Record<string, unknown>): string {
const parts: string[] = []
const summary = fields.summary as string | undefined
if (summary) parts.push(summary)
const description = extractAdfText(fields.description)
if (description) parts.push(description)
const comments = fields.comment as { comments?: Array<{ body?: unknown }> } | undefined
if (comments?.comments) {
for (const comment of comments.comments) {
const text = extractAdfText(comment.body)
if (text) parts.push(text)
}
}
return parts.join('\n\n').trim()
}
/**
* Converts a Jira issue API response to an ExternalDocument.
*/
async function issueToDocument(
issue: Record<string, unknown>,
domain: string
): Promise<ExternalDocument> {
const fields = (issue.fields || {}) as Record<string, unknown>
const content = buildIssueContent(fields)
const contentHash = await computeContentHash(content)
const key = issue.key as string
const issueType = fields.issuetype as Record<string, unknown> | undefined
const status = fields.status as Record<string, unknown> | undefined
const priority = fields.priority as Record<string, unknown> | undefined
const assignee = fields.assignee as Record<string, unknown> | undefined
const reporter = fields.reporter as Record<string, unknown> | undefined
const project = fields.project as Record<string, unknown> | undefined
const labels = Array.isArray(fields.labels) ? (fields.labels as string[]) : []
return {
externalId: String(issue.id),
title: `${key}: ${(fields.summary as string) || 'Untitled'}`,
content,
mimeType: 'text/plain',
sourceUrl: `https://${domain}/browse/${key}`,
contentHash,
metadata: {
key,
issueType: issueType?.name,
status: status?.name,
priority: priority?.name,
assignee: assignee?.displayName,
reporter: reporter?.displayName,
project: project?.key,
labels,
created: fields.created,
updated: fields.updated,
},
}
}
export const jiraConnector: ConnectorConfig = {
id: 'jira',
name: 'Jira',
description: 'Sync issues from a Jira project into your knowledge base',
version: '1.0.0',
icon: JiraIcon,
oauth: {
required: true,
provider: 'jira',
requiredScopes: ['read:jira-work', 'offline_access'],
},
configFields: [
{
id: 'domain',
title: 'Jira Domain',
type: 'short-input',
placeholder: 'yoursite.atlassian.net',
required: true,
},
{
id: 'projectKey',
title: 'Project Key',
type: 'short-input',
placeholder: 'e.g. ENG, PROJ',
required: true,
},
{
id: 'jql',
title: 'JQL Filter',
type: 'short-input',
required: false,
placeholder: 'e.g. status = "Done" AND type = Bug',
},
{
id: 'maxIssues',
title: 'Max Issues',
type: 'short-input',
required: false,
placeholder: 'e.g. 500 (default: unlimited)',
},
],
listDocuments: async (
accessToken: string,
sourceConfig: Record<string, unknown>,
cursor?: string
): Promise<ExternalDocumentList> => {
const domain = sourceConfig.domain as string
const projectKey = sourceConfig.projectKey as string
const jqlFilter = (sourceConfig.jql as string) || ''
const maxIssues = sourceConfig.maxIssues ? Number(sourceConfig.maxIssues) : 0
const cloudId = await getJiraCloudId(domain, accessToken)
const safeKey = projectKey.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
let jql = `project = "${safeKey}" ORDER BY updated DESC`
if (jqlFilter.trim()) {
jql = `project = "${safeKey}" AND (${jqlFilter.trim()}) ORDER BY updated DESC`
}
const startAt = cursor ? Number(cursor) : 0
const params = new URLSearchParams()
params.append('jql', jql)
params.append('startAt', String(startAt))
params.append('maxResults', String(PAGE_SIZE))
params.append(
'fields',
'summary,description,comment,issuetype,status,priority,assignee,reporter,project,labels,created,updated'
)
const url = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/search?${params.toString()}`
logger.info(`Listing Jira issues for project ${projectKey}`, { startAt })
const response = await fetchWithRetry(url, {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
})
if (!response.ok) {
const errorText = await response.text()
logger.error('Failed to search Jira issues', {
status: response.status,
error: errorText,
})
throw new Error(`Failed to search Jira issues: ${response.status}`)
}
const data = await response.json()
const issues = (data.issues || []) as Record<string, unknown>[]
const total = (data.total as number) ?? 0
const documents: ExternalDocument[] = await Promise.all(
issues.map((issue) => issueToDocument(issue, domain))
)
const nextStart = startAt + issues.length
const hasMore = nextStart < total && (maxIssues <= 0 || nextStart < maxIssues)
return {
documents,
nextCursor: hasMore ? String(nextStart) : undefined,
hasMore,
}
},
getDocument: async (
accessToken: string,
sourceConfig: Record<string, unknown>,
externalId: string
): Promise<ExternalDocument | null> => {
const domain = sourceConfig.domain as string
const cloudId = await getJiraCloudId(domain, accessToken)
const params = new URLSearchParams()
params.append(
'fields',
'summary,description,comment,issuetype,status,priority,assignee,reporter,project,labels,created,updated'
)
const url = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/issue/${externalId}?${params.toString()}`
const response = await fetchWithRetry(url, {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
})
if (!response.ok) {
if (response.status === 404) return null
throw new Error(`Failed to get Jira issue: ${response.status}`)
}
const issue = await response.json()
return issueToDocument(issue, domain)
},
validateConfig: async (
accessToken: string,
sourceConfig: Record<string, unknown>
): Promise<{ valid: boolean; error?: string }> => {
const domain = sourceConfig.domain as string
const projectKey = sourceConfig.projectKey as string
if (!domain || !projectKey) {
return { valid: false, error: 'Domain and project key are required' }
}
const maxIssues = sourceConfig.maxIssues as string | undefined
if (maxIssues && (Number.isNaN(Number(maxIssues)) || Number(maxIssues) <= 0)) {
return { valid: false, error: 'Max issues must be a positive number' }
}
const jqlFilter = (sourceConfig.jql as string | undefined)?.trim() || ''
try {
const cloudId = await getJiraCloudId(domain, accessToken)
const params = new URLSearchParams()
const safeKey = projectKey.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
params.append('jql', `project = "${safeKey}"`)
params.append('maxResults', '0')
const url = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/search?${params.toString()}`
const response = await fetchWithRetry(
url,
{
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
},
VALIDATE_RETRY_OPTIONS
)
if (!response.ok) {
const errorText = await response.text()
if (response.status === 400) {
return { valid: false, error: `Project "${projectKey}" not found or JQL is invalid` }
}
return { valid: false, error: `Failed to validate: ${response.status} - ${errorText}` }
}
if (jqlFilter) {
const filterParams = new URLSearchParams()
filterParams.append('jql', `project = "${safeKey}" AND (${jqlFilter})`)
filterParams.append('maxResults', '0')
const filterUrl = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/search?${filterParams.toString()}`
const filterResponse = await fetchWithRetry(
filterUrl,
{
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
},
VALIDATE_RETRY_OPTIONS
)
if (!filterResponse.ok) {
return { valid: false, error: 'Invalid JQL filter. Check syntax and field names.' }
}
}
return { valid: true }
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to validate configuration'
return { valid: false, error: message }
}
},
tagDefinitions: [
{ id: 'issueType', displayName: 'Issue Type', fieldType: 'text' },
{ id: 'status', displayName: 'Status', fieldType: 'text' },
{ id: 'priority', displayName: 'Priority', fieldType: 'text' },
{ id: 'labels', displayName: 'Labels', fieldType: 'text' },
{ id: 'assignee', displayName: 'Assignee', fieldType: 'text' },
{ id: 'updated', displayName: 'Last Updated', fieldType: 'date' },
],
mapTags: (metadata: Record<string, unknown>): Record<string, unknown> => {
const result: Record<string, unknown> = {}
if (typeof metadata.issueType === 'string') result.issueType = metadata.issueType
if (typeof metadata.status === 'string') result.status = metadata.status
if (typeof metadata.priority === 'string') result.priority = metadata.priority
const labels = Array.isArray(metadata.labels) ? (metadata.labels as string[]) : []
if (labels.length > 0) result.labels = labels.join(', ')
if (typeof metadata.assignee === 'string') result.assignee = metadata.assignee
if (typeof metadata.updated === 'string') {
const date = new Date(metadata.updated)
if (!Number.isNaN(date.getTime())) result.updated = date
}
return result
},
}

View File

@@ -1 +0,0 @@
export { linearConnector } from '@/connectors/linear/linear'

View File

@@ -1,416 +0,0 @@
import { createLogger } from '@sim/logger'
import { LinearIcon } from '@/components/icons'
import { fetchWithRetry } from '@/lib/knowledge/documents/utils'
import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types'
const logger = createLogger('LinearConnector')
const LINEAR_API = 'https://api.linear.app/graphql'
/**
* Computes a SHA-256 hash of the given content.
*/
async function computeContentHash(content: string): Promise<string> {
const data = new TextEncoder().encode(content)
const hashBuffer = await crypto.subtle.digest('SHA-256', data)
return Array.from(new Uint8Array(hashBuffer))
.map((b) => b.toString(16).padStart(2, '0'))
.join('')
}
/**
* Strips Markdown formatting to produce plain text.
*/
function markdownToPlainText(md: string): string {
let text = md
.replace(/!\[.*?\]\(.*?\)/g, '') // images
.replace(/\[([^\]]*)\]\(.*?\)/g, '$1') // links
.replace(/#{1,6}\s+/g, '') // headings
.replace(/(\*\*|__)(.*?)\1/g, '$2') // bold
.replace(/(\*|_)(.*?)\1/g, '$2') // italic
.replace(/~~(.*?)~~/g, '$1') // strikethrough
.replace(/`{3}[\s\S]*?`{3}/g, '') // code blocks
.replace(/`([^`]*)`/g, '$1') // inline code
.replace(/^\s*[-*+]\s+/gm, '') // list items
.replace(/^\s*\d+\.\s+/gm, '') // ordered list items
.replace(/^\s*>\s+/gm, '') // blockquotes
.replace(/---+/g, '') // horizontal rules
text = text.replace(/\s+/g, ' ').trim()
return text
}
/**
* Executes a GraphQL query against the Linear API.
*/
async function linearGraphQL(
accessToken: string,
query: string,
variables?: Record<string, unknown>
): Promise<Record<string, unknown>> {
const response = await fetchWithRetry(LINEAR_API, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify({ query, variables }),
})
if (!response.ok) {
const errorText = await response.text()
logger.error('Linear GraphQL request failed', { status: response.status, error: errorText })
throw new Error(`Linear API error: ${response.status}`)
}
const json = (await response.json()) as { data?: Record<string, unknown>; errors?: unknown[] }
if (json.errors) {
logger.error('Linear GraphQL errors', { errors: json.errors })
throw new Error(`Linear GraphQL error: ${JSON.stringify(json.errors)}`)
}
return json.data as Record<string, unknown>
}
/**
* Builds a formatted text document from a Linear issue.
*/
function buildIssueContent(issue: Record<string, unknown>): string {
const parts: string[] = []
const identifier = issue.identifier as string | undefined
const title = (issue.title as string) || 'Untitled'
parts.push(`${identifier ? `${identifier}: ` : ''}${title}`)
const state = issue.state as Record<string, unknown> | undefined
if (state?.name) parts.push(`Status: ${state.name}`)
const priority = issue.priorityLabel as string | undefined
if (priority) parts.push(`Priority: ${priority}`)
const assignee = issue.assignee as Record<string, unknown> | undefined
if (assignee?.name) parts.push(`Assignee: ${assignee.name}`)
const labelsConn = issue.labels as Record<string, unknown> | undefined
const labelNodes = (labelsConn?.nodes || []) as Record<string, unknown>[]
if (labelNodes.length > 0) {
parts.push(`Labels: ${labelNodes.map((l) => l.name as string).join(', ')}`)
}
const description = issue.description as string | undefined
if (description) {
parts.push('')
parts.push(markdownToPlainText(description))
}
return parts.join('\n')
}
const ISSUE_FIELDS = `
id
identifier
title
description
priority
priorityLabel
url
createdAt
updatedAt
state { name }
assignee { name }
labels { nodes { name } }
team { name key }
project { name }
`
const ISSUE_BY_ID_QUERY = `
query GetIssue($id: String!) {
issue(id: $id) {
${ISSUE_FIELDS}
}
}
`
const TEAMS_QUERY = `
query { teams { nodes { id name key } } }
`
/**
* Dynamically builds a GraphQL issues query with only the filter clauses
* that have values, preventing null comparators from being sent to Linear.
*/
function buildIssuesQuery(sourceConfig: Record<string, unknown>): {
query: string
variables: Record<string, unknown>
} {
const teamId = (sourceConfig.teamId as string) || ''
const projectId = (sourceConfig.projectId as string) || ''
const stateFilter = (sourceConfig.stateFilter as string) || ''
const varDefs: string[] = ['$first: Int!', '$after: String']
const filterClauses: string[] = []
const variables: Record<string, unknown> = {}
if (teamId) {
varDefs.push('$teamId: String!')
filterClauses.push('team: { id: { eq: $teamId } }')
variables.teamId = teamId
}
if (projectId) {
varDefs.push('$projectId: String!')
filterClauses.push('project: { id: { eq: $projectId } }')
variables.projectId = projectId
}
if (stateFilter) {
const states = stateFilter
.split(',')
.map((s) => s.trim())
.filter(Boolean)
if (states.length > 0) {
varDefs.push('$stateFilter: [String!]!')
filterClauses.push('state: { name: { in: $stateFilter } }')
variables.stateFilter = states
}
}
const filterArg = filterClauses.length > 0 ? `, filter: { ${filterClauses.join(', ')} }` : ''
const query = `
query ListIssues(${varDefs.join(', ')}) {
issues(first: $first, after: $after${filterArg}) {
nodes {
${ISSUE_FIELDS}
}
pageInfo {
hasNextPage
endCursor
}
}
}
`
return { query, variables }
}
export const linearConnector: ConnectorConfig = {
id: 'linear',
name: 'Linear',
description: 'Sync issues from Linear into your knowledge base',
version: '1.0.0',
icon: LinearIcon,
oauth: {
required: true,
provider: 'linear',
requiredScopes: ['read'],
},
configFields: [
{
id: 'teamId',
title: 'Team ID',
type: 'short-input',
placeholder: 'e.g. abc123 (leave empty for all teams)',
required: false,
},
{
id: 'projectId',
title: 'Project ID',
type: 'short-input',
placeholder: 'e.g. def456 (leave empty for all projects)',
required: false,
},
{
id: 'stateFilter',
title: 'State Filter',
type: 'short-input',
placeholder: 'e.g. In Progress, Todo',
required: false,
},
{
id: 'maxIssues',
title: 'Max Issues',
type: 'short-input',
required: false,
placeholder: 'e.g. 500 (default: unlimited)',
},
],
listDocuments: async (
accessToken: string,
sourceConfig: Record<string, unknown>,
cursor?: string
): Promise<ExternalDocumentList> => {
const maxIssues = sourceConfig.maxIssues ? Number(sourceConfig.maxIssues) : 0
const pageSize = maxIssues > 0 ? Math.min(maxIssues, 50) : 50
const { query, variables } = buildIssuesQuery(sourceConfig)
const allVars = { ...variables, first: pageSize, after: cursor || undefined }
logger.info('Listing Linear issues', {
cursor,
pageSize,
hasTeamFilter: Boolean(sourceConfig.teamId),
hasProjectFilter: Boolean(sourceConfig.projectId),
})
const data = await linearGraphQL(accessToken, query, allVars)
const issuesConn = data.issues as Record<string, unknown>
const nodes = (issuesConn.nodes || []) as Record<string, unknown>[]
const pageInfo = issuesConn.pageInfo as Record<string, unknown>
const documents: ExternalDocument[] = await Promise.all(
nodes.map(async (issue) => {
const content = buildIssueContent(issue)
const contentHash = await computeContentHash(content)
const labelNodes = ((issue.labels as Record<string, unknown>)?.nodes || []) as Record<
string,
unknown
>[]
return {
externalId: issue.id as string,
title: `${(issue.identifier as string) || ''}: ${(issue.title as string) || 'Untitled'}`,
content,
mimeType: 'text/plain' as const,
sourceUrl: (issue.url as string) || undefined,
contentHash,
metadata: {
identifier: issue.identifier,
state: (issue.state as Record<string, unknown>)?.name,
priority: issue.priorityLabel,
assignee: (issue.assignee as Record<string, unknown>)?.name,
labels: labelNodes.map((l) => l.name as string),
team: (issue.team as Record<string, unknown>)?.name,
project: (issue.project as Record<string, unknown>)?.name,
lastModified: issue.updatedAt,
},
}
})
)
const hasNextPage = Boolean(pageInfo.hasNextPage)
const endCursor = (pageInfo.endCursor as string) || undefined
return {
documents,
nextCursor: hasNextPage ? endCursor : undefined,
hasMore: hasNextPage,
}
},
getDocument: async (
accessToken: string,
sourceConfig: Record<string, unknown>,
externalId: string
): Promise<ExternalDocument | null> => {
try {
const data = await linearGraphQL(accessToken, ISSUE_BY_ID_QUERY, { id: externalId })
const issue = data.issue as Record<string, unknown> | null
if (!issue) return null
const content = buildIssueContent(issue)
const contentHash = await computeContentHash(content)
const labelNodes = ((issue.labels as Record<string, unknown>)?.nodes || []) as Record<
string,
unknown
>[]
return {
externalId: issue.id as string,
title: `${(issue.identifier as string) || ''}: ${(issue.title as string) || 'Untitled'}`,
content,
mimeType: 'text/plain',
sourceUrl: (issue.url as string) || undefined,
contentHash,
metadata: {
identifier: issue.identifier,
state: (issue.state as Record<string, unknown>)?.name,
priority: issue.priorityLabel,
assignee: (issue.assignee as Record<string, unknown>)?.name,
labels: labelNodes.map((l) => l.name as string),
team: (issue.team as Record<string, unknown>)?.name,
project: (issue.project as Record<string, unknown>)?.name,
lastModified: issue.updatedAt,
},
}
} catch (error) {
logger.error('Failed to get Linear issue', {
externalId,
error: error instanceof Error ? error.message : String(error),
})
return null
}
},
validateConfig: async (
accessToken: string,
sourceConfig: Record<string, unknown>
): Promise<{ valid: boolean; error?: string }> => {
const maxIssues = sourceConfig.maxIssues as string | undefined
if (maxIssues && (Number.isNaN(Number(maxIssues)) || Number(maxIssues) <= 0)) {
return { valid: false, error: 'Max issues must be a positive number' }
}
try {
// Verify the token works by fetching teams
const data = await linearGraphQL(accessToken, TEAMS_QUERY)
const teamsConn = data.teams as Record<string, unknown>
const teams = (teamsConn.nodes || []) as Record<string, unknown>[]
if (teams.length === 0) {
return {
valid: false,
error: 'No teams found — check that the OAuth token has read access',
}
}
// If teamId specified, verify it exists
const teamId = sourceConfig.teamId as string | undefined
if (teamId) {
const found = teams.some((t) => t.id === teamId)
if (!found) {
return {
valid: false,
error: `Team ID "${teamId}" not found. Available teams: ${teams.map((t) => `${t.name} (${t.id})`).join(', ')}`,
}
}
}
return { valid: true }
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to validate configuration'
return { valid: false, error: message }
}
},
tagDefinitions: [
{ id: 'labels', displayName: 'Labels', fieldType: 'text' },
{ id: 'state', displayName: 'State', fieldType: 'text' },
{ id: 'priority', displayName: 'Priority', fieldType: 'text' },
{ id: 'assignee', displayName: 'Assignee', fieldType: 'text' },
{ id: 'lastModified', displayName: 'Last Modified', fieldType: 'date' },
],
mapTags: (metadata: Record<string, unknown>): Record<string, unknown> => {
const result: Record<string, unknown> = {}
const labels = Array.isArray(metadata.labels) ? (metadata.labels as string[]) : []
if (labels.length > 0) result.labels = labels.join(', ')
if (typeof metadata.state === 'string') result.state = metadata.state
if (typeof metadata.priority === 'string') result.priority = metadata.priority
if (typeof metadata.assignee === 'string') result.assignee = metadata.assignee
if (typeof metadata.lastModified === 'string') {
const date = new Date(metadata.lastModified)
if (!Number.isNaN(date.getTime())) result.lastModified = date
}
return result
},
}

View File

@@ -1,393 +0,0 @@
/**
* @vitest-environment node
*/
import { describe, expect, it, vi } from 'vitest'
vi.mock('@/components/icons', () => ({
JiraIcon: () => null,
ConfluenceIcon: () => null,
GithubIcon: () => null,
LinearIcon: () => null,
NotionIcon: () => null,
GoogleDriveIcon: () => null,
AirtableIcon: () => null,
}))
vi.mock('@sim/logger', () => ({
createLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }),
}))
vi.mock('@/lib/knowledge/documents/utils', () => ({
fetchWithRetry: vi.fn(),
VALIDATE_RETRY_OPTIONS: {},
}))
vi.mock('@/tools/jira/utils', () => ({ extractAdfText: vi.fn(), getJiraCloudId: vi.fn() }))
vi.mock('@/tools/confluence/utils', () => ({ getConfluenceCloudId: vi.fn() }))
import { airtableConnector } from '@/connectors/airtable/airtable'
import { confluenceConnector } from '@/connectors/confluence/confluence'
import { githubConnector } from '@/connectors/github/github'
import { googleDriveConnector } from '@/connectors/google-drive/google-drive'
import { jiraConnector } from '@/connectors/jira/jira'
import { linearConnector } from '@/connectors/linear/linear'
import { notionConnector } from '@/connectors/notion/notion'
const ISO_DATE = '2025-06-15T10:30:00.000Z'
describe('Jira mapTags', () => {
const mapTags = jiraConnector.mapTags!
it.concurrent('maps all fields when present', () => {
const result = mapTags({
issueType: 'Bug',
status: 'In Progress',
priority: 'High',
labels: ['frontend', 'urgent'],
assignee: 'Alice',
updated: ISO_DATE,
})
expect(result).toEqual({
issueType: 'Bug',
status: 'In Progress',
priority: 'High',
labels: 'frontend, urgent',
assignee: 'Alice',
updated: new Date(ISO_DATE),
})
})
it.concurrent('returns empty object for empty metadata', () => {
expect(mapTags({})).toEqual({})
})
it.concurrent('skips fields with wrong types', () => {
const result = mapTags({
issueType: 123,
status: null,
priority: undefined,
assignee: true,
})
expect(result).toEqual({})
})
it.concurrent('skips updated when date string is invalid', () => {
const result = mapTags({ updated: 'not-a-date' })
expect(result).toEqual({})
})
it.concurrent('skips labels when not an array', () => {
const result = mapTags({ labels: 'not-an-array' })
expect(result).toEqual({})
})
it.concurrent('skips labels when array is empty', () => {
const result = mapTags({ labels: [] })
expect(result).toEqual({})
})
it.concurrent('skips updated when value is not a string', () => {
const result = mapTags({ updated: 12345 })
expect(result).toEqual({})
})
})
describe('Confluence mapTags', () => {
const mapTags = confluenceConnector.mapTags!
it.concurrent('maps all fields when present', () => {
const result = mapTags({
labels: ['docs', 'published'],
version: 5,
lastModified: ISO_DATE,
})
expect(result).toEqual({
labels: 'docs, published',
version: 5,
lastModified: new Date(ISO_DATE),
})
})
it.concurrent('returns empty object for empty metadata', () => {
expect(mapTags({})).toEqual({})
})
it.concurrent('skips labels when not an array', () => {
const result = mapTags({ labels: 'single-label' })
expect(result).toEqual({})
})
it.concurrent('skips version when NaN', () => {
const result = mapTags({ version: 'abc' })
expect(result).toEqual({})
})
it.concurrent('converts string version to number', () => {
const result = mapTags({ version: '3' })
expect(result).toEqual({ version: 3 })
})
it.concurrent('skips lastModified when date is invalid', () => {
const result = mapTags({ lastModified: 'garbage' })
expect(result).toEqual({})
})
it.concurrent('skips lastModified when not a string', () => {
const result = mapTags({ lastModified: 12345 })
expect(result).toEqual({})
})
})
describe('GitHub mapTags', () => {
const mapTags = githubConnector.mapTags!
it.concurrent('maps all fields when present', () => {
const result = mapTags({
path: 'src/index.ts',
repository: 'owner/repo',
branch: 'main',
size: 1024,
})
expect(result).toEqual({
path: 'src/index.ts',
repository: 'owner/repo',
branch: 'main',
size: 1024,
})
})
it.concurrent('returns empty object for empty metadata', () => {
expect(mapTags({})).toEqual({})
})
it.concurrent('skips string fields with wrong types', () => {
const result = mapTags({
path: 42,
repository: null,
branch: true,
})
expect(result).toEqual({})
})
it.concurrent('skips size when NaN', () => {
const result = mapTags({ size: 'not-a-number' })
expect(result).toEqual({})
})
it.concurrent('converts string size to number', () => {
const result = mapTags({ size: '512' })
expect(result).toEqual({ size: 512 })
})
it.concurrent('maps size of zero', () => {
const result = mapTags({ size: 0 })
expect(result).toEqual({ size: 0 })
})
})
describe('Linear mapTags', () => {
const mapTags = linearConnector.mapTags!
it.concurrent('maps all fields when present', () => {
const result = mapTags({
labels: ['bug', 'p0'],
state: 'In Progress',
priority: 'Urgent',
assignee: 'Bob',
lastModified: ISO_DATE,
})
expect(result).toEqual({
labels: 'bug, p0',
state: 'In Progress',
priority: 'Urgent',
assignee: 'Bob',
lastModified: new Date(ISO_DATE),
})
})
it.concurrent('returns empty object for empty metadata', () => {
expect(mapTags({})).toEqual({})
})
it.concurrent('skips string fields with wrong types', () => {
const result = mapTags({
state: 123,
priority: false,
assignee: [],
})
expect(result).toEqual({})
})
it.concurrent('skips labels when not an array', () => {
const result = mapTags({ labels: 'not-array' })
expect(result).toEqual({})
})
it.concurrent('skips lastModified when date is invalid', () => {
const result = mapTags({ lastModified: 'invalid-date' })
expect(result).toEqual({})
})
it.concurrent('skips lastModified when not a string', () => {
const result = mapTags({ lastModified: 99999 })
expect(result).toEqual({})
})
})
describe('Notion mapTags', () => {
const mapTags = notionConnector.mapTags!
it.concurrent('maps all fields when present', () => {
const result = mapTags({
tags: ['engineering', 'docs'],
lastModified: ISO_DATE,
createdTime: '2025-01-01T00:00:00.000Z',
})
expect(result).toEqual({
tags: 'engineering, docs',
lastModified: new Date(ISO_DATE),
created: new Date('2025-01-01T00:00:00.000Z'),
})
})
it.concurrent('returns empty object for empty metadata', () => {
expect(mapTags({})).toEqual({})
})
it.concurrent('skips tags when not an array', () => {
const result = mapTags({ tags: 'single' })
expect(result).toEqual({})
})
it.concurrent('skips lastModified when date is invalid', () => {
const result = mapTags({ lastModified: 'bad-date' })
expect(result).toEqual({})
})
it.concurrent('skips createdTime when date is invalid', () => {
const result = mapTags({ createdTime: 'bad-date' })
expect(result).toEqual({})
})
it.concurrent('skips date fields when not strings', () => {
const result = mapTags({ lastModified: 12345, createdTime: true })
expect(result).toEqual({})
})
it.concurrent('maps createdTime to created key', () => {
const result = mapTags({ createdTime: ISO_DATE })
expect(result).toEqual({ created: new Date(ISO_DATE) })
expect(result).not.toHaveProperty('createdTime')
})
})
describe('Google Drive mapTags', () => {
const mapTags = googleDriveConnector.mapTags!
it.concurrent('maps all fields when present', () => {
const result = mapTags({
owners: ['Alice', 'Bob'],
originalMimeType: 'application/vnd.google-apps.document',
modifiedTime: ISO_DATE,
starred: true,
})
expect(result).toEqual({
owners: 'Alice, Bob',
fileType: 'Google Doc',
lastModified: new Date(ISO_DATE),
starred: true,
})
})
it.concurrent('returns empty object for empty metadata', () => {
expect(mapTags({})).toEqual({})
})
it.concurrent('maps spreadsheet mime type', () => {
const result = mapTags({ originalMimeType: 'application/vnd.google-apps.spreadsheet' })
expect(result).toEqual({ fileType: 'Google Sheet' })
})
it.concurrent('maps presentation mime type', () => {
const result = mapTags({ originalMimeType: 'application/vnd.google-apps.presentation' })
expect(result).toEqual({ fileType: 'Google Slides' })
})
it.concurrent('maps text/ mime types to Text File', () => {
const result = mapTags({ originalMimeType: 'text/plain' })
expect(result).toEqual({ fileType: 'Text File' })
})
it.concurrent('falls back to raw mime type for unknown types', () => {
const result = mapTags({ originalMimeType: 'application/pdf' })
expect(result).toEqual({ fileType: 'application/pdf' })
})
it.concurrent('skips owners when not an array', () => {
const result = mapTags({ owners: 'not-an-array' })
expect(result).toEqual({})
})
it.concurrent('skips modifiedTime when date is invalid', () => {
const result = mapTags({ modifiedTime: 'garbage' })
expect(result).toEqual({})
})
it.concurrent('skips modifiedTime when not a string', () => {
const result = mapTags({ modifiedTime: 99999 })
expect(result).toEqual({})
})
it.concurrent('maps starred false', () => {
const result = mapTags({ starred: false })
expect(result).toEqual({ starred: false })
})
it.concurrent('skips starred when not a boolean', () => {
const result = mapTags({ starred: 'yes' })
expect(result).toEqual({})
})
it.concurrent('maps modifiedTime to lastModified key', () => {
const result = mapTags({ modifiedTime: ISO_DATE })
expect(result).toEqual({ lastModified: new Date(ISO_DATE) })
expect(result).not.toHaveProperty('modifiedTime')
})
it.concurrent('maps originalMimeType to fileType key', () => {
const result = mapTags({ originalMimeType: 'application/vnd.google-apps.document' })
expect(result).toEqual({ fileType: 'Google Doc' })
expect(result).not.toHaveProperty('originalMimeType')
})
})
describe('Airtable mapTags', () => {
const mapTags = airtableConnector.mapTags!
it.concurrent('maps createdTime when present', () => {
const result = mapTags({ createdTime: ISO_DATE })
expect(result).toEqual({ createdTime: new Date(ISO_DATE) })
})
it.concurrent('returns empty object for empty metadata', () => {
expect(mapTags({})).toEqual({})
})
it.concurrent('skips createdTime when date is invalid', () => {
const result = mapTags({ createdTime: 'not-a-date' })
expect(result).toEqual({})
})
it.concurrent('skips createdTime when not a string', () => {
const result = mapTags({ createdTime: 12345 })
expect(result).toEqual({})
})
it.concurrent('ignores unrelated metadata fields', () => {
const result = mapTags({ foo: 'bar', count: 42, createdTime: ISO_DATE })
expect(result).toEqual({ createdTime: new Date(ISO_DATE) })
})
})

View File

@@ -1 +0,0 @@
export { notionConnector } from '@/connectors/notion/notion'

View File

@@ -1,600 +0,0 @@
import { createLogger } from '@sim/logger'
import { NotionIcon } from '@/components/icons'
import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils'
import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types'
const logger = createLogger('NotionConnector')
const NOTION_API_VERSION = '2022-06-28'
const NOTION_BASE_URL = 'https://api.notion.com/v1'
/**
* Computes a SHA-256 hash of the given content.
*/
async function computeContentHash(content: string): Promise<string> {
const data = new TextEncoder().encode(content)
const hashBuffer = await crypto.subtle.digest('SHA-256', data)
return Array.from(new Uint8Array(hashBuffer))
.map((b) => b.toString(16).padStart(2, '0'))
.join('')
}
/**
* Extracts the title from a Notion page's properties.
*/
function extractTitle(properties: Record<string, unknown>): string {
for (const value of Object.values(properties)) {
const prop = value as Record<string, unknown>
if (prop.type === 'title' && Array.isArray(prop.title) && prop.title.length > 0) {
return prop.title.map((t: Record<string, unknown>) => (t.plain_text as string) || '').join('')
}
}
return 'Untitled'
}
/**
* Extracts plain text from a rich_text array.
*/
function richTextToPlain(richText: Record<string, unknown>[]): string {
return richText.map((t) => (t.plain_text as string) || '').join('')
}
/**
* Extracts plain text content from Notion blocks.
*/
function blocksToPlainText(blocks: Record<string, unknown>[]): string {
return blocks
.map((block) => {
const type = block.type as string
const blockData = block[type] as Record<string, unknown> | undefined
if (!blockData) return ''
const richText = blockData.rich_text as Record<string, unknown>[] | undefined
if (!richText) return ''
const text = richTextToPlain(richText)
switch (type) {
case 'heading_1':
return `# ${text}`
case 'heading_2':
return `## ${text}`
case 'heading_3':
return `### ${text}`
case 'bulleted_list_item':
return `- ${text}`
case 'numbered_list_item':
return `1. ${text}`
case 'to_do': {
const checked = (blockData.checked as boolean) ? '[x]' : '[ ]'
return `${checked} ${text}`
}
case 'quote':
return `> ${text}`
case 'callout':
return text
case 'toggle':
return text
default:
return text
}
})
.filter(Boolean)
.join('\n\n')
}
/**
* Fetches all block children for a page, handling pagination.
*/
async function fetchAllBlocks(
accessToken: string,
pageId: string
): Promise<Record<string, unknown>[]> {
const allBlocks: Record<string, unknown>[] = []
let cursor: string | undefined
let hasMore = true
while (hasMore) {
const params = new URLSearchParams({ page_size: '100' })
if (cursor) params.append('start_cursor', cursor)
const response = await fetchWithRetry(
`${NOTION_BASE_URL}/blocks/${pageId}/children?${params.toString()}`,
{
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
'Notion-Version': NOTION_API_VERSION,
},
}
)
if (!response.ok) {
logger.warn(`Failed to fetch blocks for page ${pageId}`, { status: response.status })
break
}
const data = await response.json()
allBlocks.push(...(data.results || []))
cursor = data.next_cursor ?? undefined
hasMore = data.has_more === true
}
return allBlocks
}
/**
* Extracts multi_select tags from page properties.
*/
function extractTags(properties: Record<string, unknown>): string[] {
const tags: string[] = []
for (const value of Object.values(properties)) {
const prop = value as Record<string, unknown>
if (prop.type === 'multi_select' && Array.isArray(prop.multi_select)) {
for (const item of prop.multi_select) {
const name = (item as Record<string, unknown>).name as string
if (name) tags.push(name)
}
}
if (prop.type === 'select' && prop.select) {
const name = (prop.select as Record<string, unknown>).name as string
if (name) tags.push(name)
}
}
return tags
}
/**
* Converts a Notion page to an ExternalDocument by fetching its block content.
*/
async function pageToExternalDocument(
accessToken: string,
page: Record<string, unknown>
): Promise<ExternalDocument> {
const pageId = page.id as string
const properties = (page.properties || {}) as Record<string, unknown>
const title = extractTitle(properties)
const url = page.url as string
// Fetch page content
const blocks = await fetchAllBlocks(accessToken, pageId)
const plainText = blocksToPlainText(blocks)
const contentHash = await computeContentHash(plainText)
// Extract tags from multi_select/select properties
const tags = extractTags(properties)
return {
externalId: pageId,
title: title || 'Untitled',
content: plainText,
mimeType: 'text/plain',
sourceUrl: url,
contentHash,
metadata: {
tags,
lastModified: page.last_edited_time as string,
createdTime: page.created_time as string,
parentType: (page.parent as Record<string, unknown>)?.type,
},
}
}
export const notionConnector: ConnectorConfig = {
id: 'notion',
name: 'Notion',
description: 'Sync pages from a Notion workspace into your knowledge base',
version: '1.0.0',
icon: NotionIcon,
oauth: {
required: true,
provider: 'notion',
requiredScopes: [],
},
configFields: [
{
id: 'scope',
title: 'Sync Scope',
type: 'dropdown',
required: false,
options: [
{ label: 'Entire workspace', id: 'workspace' },
{ label: 'Specific database', id: 'database' },
{ label: 'Specific page (and children)', id: 'page' },
],
},
{
id: 'databaseId',
title: 'Database ID',
type: 'short-input',
required: false,
placeholder: 'e.g. 8a3b5f6e-1234-5678-abcd-ef0123456789',
},
{
id: 'rootPageId',
title: 'Page ID',
type: 'short-input',
required: false,
placeholder: 'e.g. 8a3b5f6e-1234-5678-abcd-ef0123456789',
},
{
id: 'searchQuery',
title: 'Search Filter',
type: 'short-input',
required: false,
placeholder: 'e.g. meeting notes, project plan',
},
{
id: 'maxPages',
title: 'Max Pages',
type: 'short-input',
required: false,
placeholder: 'e.g. 500 (default: unlimited)',
},
],
listDocuments: async (
accessToken: string,
sourceConfig: Record<string, unknown>,
cursor?: string
): Promise<ExternalDocumentList> => {
const scope = (sourceConfig.scope as string) || 'workspace'
const databaseId = (sourceConfig.databaseId as string)?.trim()
const rootPageId = (sourceConfig.rootPageId as string)?.trim()
const maxPages = sourceConfig.maxPages ? Number(sourceConfig.maxPages) : 0
if (scope === 'database' && databaseId) {
return listFromDatabase(accessToken, databaseId, maxPages, cursor)
}
if (scope === 'page' && rootPageId) {
return listFromParentPage(accessToken, rootPageId, maxPages, cursor)
}
// Default: workspace-wide search
const searchQuery = (sourceConfig.searchQuery as string) || ''
return listFromWorkspace(accessToken, searchQuery, maxPages, cursor)
},
getDocument: async (
accessToken: string,
_sourceConfig: Record<string, unknown>,
externalId: string
): Promise<ExternalDocument | null> => {
const response = await fetchWithRetry(`${NOTION_BASE_URL}/pages/${externalId}`, {
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
'Notion-Version': NOTION_API_VERSION,
},
})
if (!response.ok) {
if (response.status === 404) return null
throw new Error(`Failed to get Notion page: ${response.status}`)
}
const page = await response.json()
return pageToExternalDocument(accessToken, page)
},
validateConfig: async (
accessToken: string,
sourceConfig: Record<string, unknown>
): Promise<{ valid: boolean; error?: string }> => {
const scope = (sourceConfig.scope as string) || 'workspace'
const databaseId = (sourceConfig.databaseId as string)?.trim()
const rootPageId = (sourceConfig.rootPageId as string)?.trim()
const maxPages = sourceConfig.maxPages as string | undefined
if (maxPages && (Number.isNaN(Number(maxPages)) || Number(maxPages) <= 0)) {
return { valid: false, error: 'Max pages must be a positive number' }
}
if (scope === 'database' && !databaseId) {
return { valid: false, error: 'Database ID is required when scope is "Specific database"' }
}
if (scope === 'page' && !rootPageId) {
return { valid: false, error: 'Page ID is required when scope is "Specific page"' }
}
try {
// Verify the token works
if (scope === 'database' && databaseId) {
// Verify database is accessible
const response = await fetchWithRetry(
`${NOTION_BASE_URL}/databases/${databaseId}`,
{
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
'Notion-Version': NOTION_API_VERSION,
},
},
VALIDATE_RETRY_OPTIONS
)
if (!response.ok) {
return { valid: false, error: `Cannot access database: ${response.status}` }
}
} else if (scope === 'page' && rootPageId) {
// Verify page is accessible
const response = await fetchWithRetry(
`${NOTION_BASE_URL}/pages/${rootPageId}`,
{
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
'Notion-Version': NOTION_API_VERSION,
},
},
VALIDATE_RETRY_OPTIONS
)
if (!response.ok) {
return { valid: false, error: `Cannot access page: ${response.status}` }
}
} else {
// Workspace scope — just verify token works
const response = await fetchWithRetry(
`${NOTION_BASE_URL}/search`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Notion-Version': NOTION_API_VERSION,
'Content-Type': 'application/json',
},
body: JSON.stringify({ page_size: 1 }),
},
VALIDATE_RETRY_OPTIONS
)
if (!response.ok) {
const errorText = await response.text()
return { valid: false, error: `Cannot access Notion workspace: ${errorText}` }
}
}
return { valid: true }
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to validate configuration'
return { valid: false, error: message }
}
},
tagDefinitions: [
{ id: 'tags', displayName: 'Tags', fieldType: 'text' },
{ id: 'lastModified', displayName: 'Last Modified', fieldType: 'date' },
{ id: 'created', displayName: 'Created', fieldType: 'date' },
],
mapTags: (metadata: Record<string, unknown>): Record<string, unknown> => {
const result: Record<string, unknown> = {}
const tags = Array.isArray(metadata.tags) ? (metadata.tags as string[]) : []
if (tags.length > 0) result.tags = tags.join(', ')
if (typeof metadata.lastModified === 'string') {
const date = new Date(metadata.lastModified)
if (!Number.isNaN(date.getTime())) result.lastModified = date
}
if (typeof metadata.createdTime === 'string') {
const date = new Date(metadata.createdTime)
if (!Number.isNaN(date.getTime())) result.created = date
}
return result
},
}
/**
* Lists pages from the entire workspace using the search API.
*/
async function listFromWorkspace(
accessToken: string,
searchQuery: string,
maxPages: number,
cursor?: string
): Promise<ExternalDocumentList> {
const body: Record<string, unknown> = {
page_size: 20,
filter: { value: 'page', property: 'object' },
sort: { direction: 'descending', timestamp: 'last_edited_time' },
}
if (searchQuery.trim()) {
body.query = searchQuery.trim()
}
if (cursor) {
body.start_cursor = cursor
}
logger.info('Listing Notion pages from workspace', { searchQuery, cursor })
const response = await fetchWithRetry(`${NOTION_BASE_URL}/search`, {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Notion-Version': NOTION_API_VERSION,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
})
if (!response.ok) {
const errorText = await response.text()
logger.error('Failed to search Notion', { status: response.status, error: errorText })
throw new Error(`Failed to search Notion: ${response.status}`)
}
const data = await response.json()
const results = (data.results || []) as Record<string, unknown>[]
const pages = results.filter((r) => r.object === 'page' && !(r.archived as boolean))
const documents = await processPages(accessToken, pages)
const nextCursor = (data.next_cursor as string) ?? undefined
return {
documents,
nextCursor,
hasMore: data.has_more === true && (maxPages <= 0 || documents.length < maxPages),
}
}
/**
* Lists pages from a specific Notion database.
*/
async function listFromDatabase(
accessToken: string,
databaseId: string,
maxPages: number,
cursor?: string
): Promise<ExternalDocumentList> {
const body: Record<string, unknown> = {
page_size: 20,
}
if (cursor) {
body.start_cursor = cursor
}
logger.info('Querying Notion database', { databaseId, cursor })
const response = await fetchWithRetry(`${NOTION_BASE_URL}/databases/${databaseId}/query`, {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Notion-Version': NOTION_API_VERSION,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
})
if (!response.ok) {
const errorText = await response.text()
logger.error('Failed to query Notion database', { status: response.status, error: errorText })
throw new Error(`Failed to query Notion database: ${response.status}`)
}
const data = await response.json()
const results = (data.results || []) as Record<string, unknown>[]
const pages = results.filter((r) => r.object === 'page' && !(r.archived as boolean))
const documents = await processPages(accessToken, pages)
const nextCursor = (data.next_cursor as string) ?? undefined
return {
documents,
nextCursor,
hasMore: data.has_more === true && (maxPages <= 0 || documents.length < maxPages),
}
}
/**
* Lists child pages under a specific parent page.
*
* Uses the blocks children endpoint to find child_page blocks,
* then fetches each page's content.
*/
async function listFromParentPage(
accessToken: string,
rootPageId: string,
maxPages: number,
cursor?: string
): Promise<ExternalDocumentList> {
const params = new URLSearchParams({ page_size: '100' })
if (cursor) params.append('start_cursor', cursor)
logger.info('Listing child pages under root page', { rootPageId, cursor })
const response = await fetchWithRetry(
`${NOTION_BASE_URL}/blocks/${rootPageId}/children?${params.toString()}`,
{
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
'Notion-Version': NOTION_API_VERSION,
},
}
)
if (!response.ok) {
const errorText = await response.text()
logger.error('Failed to list child blocks', { status: response.status, error: errorText })
throw new Error(`Failed to list child blocks: ${response.status}`)
}
const data = await response.json()
const blocks = (data.results || []) as Record<string, unknown>[]
// Filter to child_page and child_database blocks
const childPageIds = blocks
.filter((b) => b.type === 'child_page' || b.type === 'child_database')
.map((b) => b.id as string)
// Also include the root page itself on the first call (no cursor)
const pageIdsToFetch = !cursor ? [rootPageId, ...childPageIds] : childPageIds
// Fetch each child page
const documents: ExternalDocument[] = []
for (const pageId of pageIdsToFetch) {
if (maxPages > 0 && documents.length >= maxPages) break
try {
const pageResponse = await fetchWithRetry(`${NOTION_BASE_URL}/pages/${pageId}`, {
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
'Notion-Version': NOTION_API_VERSION,
},
})
if (!pageResponse.ok) {
logger.warn(`Failed to fetch child page ${pageId}`, { status: pageResponse.status })
continue
}
const page = await pageResponse.json()
if (page.archived) continue
const doc = await pageToExternalDocument(accessToken, page)
documents.push(doc)
} catch (error) {
logger.warn(`Failed to process child page ${pageId}`, {
error: error instanceof Error ? error.message : String(error),
})
}
}
const nextCursor = (data.next_cursor as string) ?? undefined
return {
documents,
nextCursor,
hasMore: data.has_more === true && (maxPages <= 0 || documents.length < maxPages),
}
}
/**
* Converts an array of Notion page objects to ExternalDocuments.
*/
async function processPages(
accessToken: string,
pages: Record<string, unknown>[]
): Promise<ExternalDocument[]> {
const documents: ExternalDocument[] = []
for (const page of pages) {
try {
const doc = await pageToExternalDocument(accessToken, page)
documents.push(doc)
} catch (error) {
logger.warn(`Failed to process Notion page ${page.id}`, {
error: error instanceof Error ? error.message : String(error),
})
}
}
return documents
}

View File

@@ -1,18 +0,0 @@
import { airtableConnector } from '@/connectors/airtable'
import { confluenceConnector } from '@/connectors/confluence'
import { githubConnector } from '@/connectors/github'
import { googleDriveConnector } from '@/connectors/google-drive'
import { jiraConnector } from '@/connectors/jira'
import { linearConnector } from '@/connectors/linear'
import { notionConnector } from '@/connectors/notion'
import type { ConnectorRegistry } from '@/connectors/types'
export const CONNECTOR_REGISTRY: ConnectorRegistry = {
airtable: airtableConnector,
confluence: confluenceConnector,
github: githubConnector,
google_drive: googleDriveConnector,
jira: jiraConnector,
linear: linearConnector,
notion: notionConnector,
}

View File

@@ -1,165 +0,0 @@
import type { OAuthService } from '@/lib/oauth/types'
/**
* A single document fetched from an external source.
*/
export interface ExternalDocument {
/** Source-specific unique ID (page ID, file ID) */
externalId: string
/** Document title / filename */
title: string
/** Extracted text content */
content: string
/** MIME type of the content */
mimeType: string
/** Link back to the original document */
sourceUrl?: string
/** SHA-256 of content for change detection */
contentHash: string
/** Additional source-specific metadata */
metadata?: Record<string, unknown>
}
/**
* Paginated result from listing documents in an external source.
*/
export interface ExternalDocumentList {
documents: ExternalDocument[]
nextCursor?: string
hasMore: boolean
}
/**
* Result of a sync operation.
*/
export interface SyncResult {
docsAdded: number
docsUpdated: number
docsDeleted: number
docsUnchanged: number
error?: string
}
/**
* Config field for source-specific settings (rendered in the add-connector UI).
*/
export interface ConnectorConfigField {
id: string
title: string
type: 'short-input' | 'dropdown'
placeholder?: string
required?: boolean
description?: string
options?: { label: string; id: string }[]
}
/**
* Declarative config for a knowledge source connector.
*
* Mirrors ToolConfig/TriggerConfig pattern:
* - Purely declarative metadata (id, name, icon, oauth, configFields)
* - Runtime functions for data fetching (listDocuments, getDocument, validateConfig)
*
* Adding a new connector = creating one of these + registering it.
*/
export interface ConnectorConfig {
/** Unique connector identifier, e.g. 'confluence', 'google_drive', 'notion' */
id: string
/** Human-readable name, e.g. 'Confluence', 'Google Drive' */
name: string
/** Short description of the connector */
description: string
/** Semver version */
version: string
/** Icon component for the connector */
icon: React.ComponentType<{ className?: string }>
/** OAuth configuration (same pattern as ToolConfig.oauth) */
oauth: {
required: true
provider: OAuthService
requiredScopes?: string[]
}
/** Source configuration fields rendered in the add-connector UI */
configFields: ConnectorConfigField[]
/**
* List all documents from the configured source (handles pagination via cursor).
* syncContext is a mutable object shared across all pages of a single sync run —
* connectors can use it to cache expensive lookups (e.g. schema fetches) without
* leaking state into module-level globals.
*/
listDocuments: (
accessToken: string,
sourceConfig: Record<string, unknown>,
cursor?: string,
syncContext?: Record<string, unknown>
) => Promise<ExternalDocumentList>
/** Fetch a single document by its external ID */
getDocument: (
accessToken: string,
sourceConfig: Record<string, unknown>,
externalId: string
) => Promise<ExternalDocument | null>
/** Validate that sourceConfig is correct and accessible (called on save) */
validateConfig: (
accessToken: string,
sourceConfig: Record<string, unknown>
) => Promise<{ valid: boolean; error?: string }>
/** Map source metadata to semantic tag keys (translated to slots by the sync engine) */
mapTags?: (metadata: Record<string, unknown>) => Record<string, unknown>
/**
* Tag definitions this connector populates. Shown in the add-connector modal
* as opt-out checkboxes. On connector creation, tag definitions are auto-created
* on the KB for enabled slots, and mapTags output is filtered to only include them.
*/
tagDefinitions?: ConnectorTagDefinition[]
}
/**
* A tag that a connector populates, with a semantic ID and human-readable name.
* Slots are dynamically assigned on connector creation via getNextAvailableSlot.
*/
export interface ConnectorTagDefinition {
/** Semantic ID matching a key returned by mapTags (e.g. 'labels', 'version') */
id: string
/** Human-readable name shown in UI (e.g. 'Labels', 'Last Modified') */
displayName: string
/** Field type determines which slot pool to draw from */
fieldType: 'text' | 'number' | 'date' | 'boolean'
}
/**
* Tag slots available on the document table for connector metadata mapping.
*/
export interface DocumentTags {
tag1?: string
tag2?: string
tag3?: string
tag4?: string
tag5?: string
tag6?: string
tag7?: string
number1?: number
number2?: number
number3?: number
number4?: number
number5?: number
date1?: Date
date2?: Date
boolean1?: boolean
boolean2?: boolean
boolean3?: boolean
}
/**
* Registry mapping connector IDs to their configs.
*/
export interface ConnectorRegistry {
[connectorId: string]: ConnectorConfig
}

View File

@@ -3,7 +3,7 @@
import { useCallback } from 'react' import { useCallback } from 'react'
import { useQueryClient } from '@tanstack/react-query' import { useQueryClient } from '@tanstack/react-query'
import type { AllTagSlot } from '@/lib/knowledge/constants' import type { AllTagSlot } from '@/lib/knowledge/constants'
import { knowledgeKeys, useTagDefinitionsQuery } from '@/hooks/queries/kb/knowledge' import { knowledgeKeys, useTagDefinitionsQuery } from '@/hooks/queries/knowledge'
export interface TagDefinition { export interface TagDefinition {
id: string id: string

View File

@@ -2,7 +2,6 @@ import { useCallback, useMemo } from 'react'
import { useQueryClient } from '@tanstack/react-query' import { useQueryClient } from '@tanstack/react-query'
import type { ChunkData, DocumentData, KnowledgeBaseData } from '@/lib/knowledge/types' import type { ChunkData, DocumentData, KnowledgeBaseData } from '@/lib/knowledge/types'
import { import {
type DocumentTagFilter,
type KnowledgeChunksResponse, type KnowledgeChunksResponse,
type KnowledgeDocumentsResponse, type KnowledgeDocumentsResponse,
knowledgeKeys, knowledgeKeys,
@@ -13,7 +12,7 @@ import {
useKnowledgeBasesQuery, useKnowledgeBasesQuery,
useKnowledgeChunksQuery, useKnowledgeChunksQuery,
useKnowledgeDocumentsQuery, useKnowledgeDocumentsQuery,
} from '@/hooks/queries/kb/knowledge' } from '@/hooks/queries/knowledge'
const DEFAULT_PAGE_SIZE = 50 const DEFAULT_PAGE_SIZE = 50
@@ -73,14 +72,12 @@ export function useKnowledgeBaseDocuments(
| false | false
| ((data: KnowledgeDocumentsResponse | undefined) => number | false) | ((data: KnowledgeDocumentsResponse | undefined) => number | false)
enabledFilter?: 'all' | 'enabled' | 'disabled' enabledFilter?: 'all' | 'enabled' | 'disabled'
tagFilters?: DocumentTagFilter[]
} }
) { ) {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const requestLimit = options?.limit ?? DEFAULT_PAGE_SIZE const requestLimit = options?.limit ?? DEFAULT_PAGE_SIZE
const requestOffset = options?.offset ?? 0 const requestOffset = options?.offset ?? 0
const enabledFilter = options?.enabledFilter ?? 'all' const enabledFilter = options?.enabledFilter ?? 'all'
const tagFilters = options?.tagFilters
const paramsKey = serializeDocumentParams({ const paramsKey = serializeDocumentParams({
knowledgeBaseId, knowledgeBaseId,
limit: requestLimit, limit: requestLimit,
@@ -89,7 +86,6 @@ export function useKnowledgeBaseDocuments(
sortBy: options?.sortBy, sortBy: options?.sortBy,
sortOrder: options?.sortOrder, sortOrder: options?.sortOrder,
enabledFilter, enabledFilter,
tagFilters,
}) })
const refetchIntervalFn = useMemo(() => { const refetchIntervalFn = useMemo(() => {
@@ -111,7 +107,6 @@ export function useKnowledgeBaseDocuments(
sortBy: options?.sortBy, sortBy: options?.sortBy,
sortOrder: options?.sortOrder, sortOrder: options?.sortOrder,
enabledFilter, enabledFilter,
tagFilters,
}, },
{ {
enabled: (options?.enabled ?? true) && Boolean(knowledgeBaseId), enabled: (options?.enabled ?? true) && Boolean(knowledgeBaseId),
@@ -232,8 +227,7 @@ export function useDocumentChunks(
knowledgeBaseId: string, knowledgeBaseId: string,
documentId: string, documentId: string,
page = 1, page = 1,
search = '', search = ''
enabledFilter: 'all' | 'enabled' | 'disabled' = 'all'
) { ) {
const queryClient = useQueryClient() const queryClient = useQueryClient()
@@ -247,7 +241,6 @@ export function useDocumentChunks(
limit: DEFAULT_PAGE_SIZE, limit: DEFAULT_PAGE_SIZE,
offset, offset,
search: search || undefined, search: search || undefined,
enabledFilter,
}, },
{ {
enabled: Boolean(knowledgeBaseId && documentId), enabled: Boolean(knowledgeBaseId && documentId),
@@ -279,12 +272,11 @@ export function useDocumentChunks(
limit: DEFAULT_PAGE_SIZE, limit: DEFAULT_PAGE_SIZE,
offset, offset,
search: search || undefined, search: search || undefined,
enabledFilter,
}) })
await queryClient.invalidateQueries({ await queryClient.invalidateQueries({
queryKey: knowledgeKeys.chunks(knowledgeBaseId, documentId, paramsKey), queryKey: knowledgeKeys.chunks(knowledgeBaseId, documentId, paramsKey),
}) })
}, [knowledgeBaseId, documentId, offset, search, enabledFilter, queryClient]) }, [knowledgeBaseId, documentId, offset, search, queryClient])
const updateChunk = useCallback( const updateChunk = useCallback(
(chunkId: string, updates: Partial<ChunkData>) => { (chunkId: string, updates: Partial<ChunkData>) => {
@@ -294,7 +286,6 @@ export function useDocumentChunks(
limit: DEFAULT_PAGE_SIZE, limit: DEFAULT_PAGE_SIZE,
offset, offset,
search: search || undefined, search: search || undefined,
enabledFilter,
}) })
queryClient.setQueryData<KnowledgeChunksResponse>( queryClient.setQueryData<KnowledgeChunksResponse>(
knowledgeKeys.chunks(knowledgeBaseId, documentId, paramsKey), knowledgeKeys.chunks(knowledgeBaseId, documentId, paramsKey),
@@ -309,7 +300,7 @@ export function useDocumentChunks(
} }
) )
}, },
[knowledgeBaseId, documentId, offset, search, enabledFilter, queryClient] [knowledgeBaseId, documentId, offset, search, queryClient]
) )
return { return {

View File

@@ -9,7 +9,7 @@ import {
useDeleteDocumentTagDefinitions, useDeleteDocumentTagDefinitions,
useDocumentTagDefinitionsQuery, useDocumentTagDefinitionsQuery,
useSaveDocumentTagDefinitions, useSaveDocumentTagDefinitions,
} from '@/hooks/queries/kb/knowledge' } from '@/hooks/queries/knowledge'
export interface TagDefinition { export interface TagDefinition {
id: string id: string

View File

@@ -1,426 +0,0 @@
import { createLogger } from '@sim/logger'
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { knowledgeKeys } from '@/hooks/queries/kb/knowledge'
const logger = createLogger('KnowledgeConnectorQueries')
export interface ConnectorData {
id: string
knowledgeBaseId: string
connectorType: string
credentialId: string
sourceConfig: Record<string, unknown>
syncMode: string
syncIntervalMinutes: number
status: 'active' | 'paused' | 'syncing' | 'error'
lastSyncAt: string | null
lastSyncError: string | null
lastSyncDocCount: number | null
nextSyncAt: string | null
consecutiveFailures: number
createdAt: string
updatedAt: string
}
export interface SyncLogData {
id: string
connectorId: string
status: string
startedAt: string
completedAt: string | null
docsAdded: number
docsUpdated: number
docsDeleted: number
docsUnchanged: number
errorMessage: string | null
}
export interface ConnectorDetailData extends ConnectorData {
syncLogs: SyncLogData[]
}
export const connectorKeys = {
all: (knowledgeBaseId: string) =>
[...knowledgeKeys.detail(knowledgeBaseId), 'connectors'] as const,
list: (knowledgeBaseId?: string) =>
[...knowledgeKeys.detail(knowledgeBaseId), 'connectors', 'list'] as const,
detail: (knowledgeBaseId?: string, connectorId?: string) =>
[...knowledgeKeys.detail(knowledgeBaseId), 'connectors', 'detail', connectorId ?? ''] as const,
}
async function fetchConnectors(knowledgeBaseId: string): Promise<ConnectorData[]> {
const response = await fetch(`/api/knowledge/${knowledgeBaseId}/connectors`)
if (!response.ok) {
throw new Error(`Failed to fetch connectors: ${response.status}`)
}
const result = await response.json()
if (!result?.success) {
throw new Error(result?.error || 'Failed to fetch connectors')
}
return Array.isArray(result.data) ? result.data : []
}
async function fetchConnectorDetail(
knowledgeBaseId: string,
connectorId: string
): Promise<ConnectorDetailData> {
const response = await fetch(`/api/knowledge/${knowledgeBaseId}/connectors/${connectorId}`)
if (!response.ok) {
throw new Error(`Failed to fetch connector: ${response.status}`)
}
const result = await response.json()
if (!result?.success || !result?.data) {
throw new Error(result?.error || 'Failed to fetch connector')
}
return result.data
}
export function useConnectorList(knowledgeBaseId?: string) {
return useQuery({
queryKey: connectorKeys.list(knowledgeBaseId),
queryFn: () => fetchConnectors(knowledgeBaseId as string),
enabled: Boolean(knowledgeBaseId),
staleTime: 30 * 1000,
placeholderData: keepPreviousData,
refetchInterval: (query) => {
const connectors = query.state.data
const hasSyncing = connectors?.some((c) => c.status === 'syncing')
return hasSyncing ? 3000 : false
},
})
}
export function useConnectorDetail(knowledgeBaseId?: string, connectorId?: string) {
return useQuery({
queryKey: connectorKeys.detail(knowledgeBaseId, connectorId),
queryFn: () => fetchConnectorDetail(knowledgeBaseId as string, connectorId as string),
enabled: Boolean(knowledgeBaseId && connectorId),
staleTime: 30 * 1000,
placeholderData: keepPreviousData,
})
}
export interface CreateConnectorParams {
knowledgeBaseId: string
connectorType: string
credentialId: string
sourceConfig: Record<string, unknown>
syncIntervalMinutes?: number
}
async function createConnector({
knowledgeBaseId,
...body
}: CreateConnectorParams): Promise<ConnectorData> {
const response = await fetch(`/api/knowledge/${knowledgeBaseId}/connectors`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (!response.ok) {
const result = await response.json()
throw new Error(result.error || 'Failed to create connector')
}
const result = await response.json()
if (!result?.success || !result?.data) {
throw new Error(result?.error || 'Failed to create connector')
}
return result.data
}
export function useCreateConnector() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: createConnector,
onSuccess: (_, { knowledgeBaseId }) => {
queryClient.invalidateQueries({
queryKey: connectorKeys.list(knowledgeBaseId),
})
},
})
}
export interface UpdateConnectorParams {
knowledgeBaseId: string
connectorId: string
updates: {
sourceConfig?: Record<string, unknown>
syncIntervalMinutes?: number
status?: 'active' | 'paused'
}
}
async function updateConnector({
knowledgeBaseId,
connectorId,
updates,
}: UpdateConnectorParams): Promise<ConnectorData> {
const response = await fetch(`/api/knowledge/${knowledgeBaseId}/connectors/${connectorId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates),
})
if (!response.ok) {
const result = await response.json()
throw new Error(result.error || 'Failed to update connector')
}
const result = await response.json()
if (!result?.success) {
throw new Error(result?.error || 'Failed to update connector')
}
return result.data
}
export function useUpdateConnector() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: updateConnector,
onSuccess: (_, { knowledgeBaseId, connectorId }) => {
queryClient.invalidateQueries({
queryKey: connectorKeys.list(knowledgeBaseId),
})
queryClient.invalidateQueries({
queryKey: connectorKeys.detail(knowledgeBaseId, connectorId),
})
},
})
}
export interface DeleteConnectorParams {
knowledgeBaseId: string
connectorId: string
}
async function deleteConnector({
knowledgeBaseId,
connectorId,
}: DeleteConnectorParams): Promise<void> {
const response = await fetch(`/api/knowledge/${knowledgeBaseId}/connectors/${connectorId}`, {
method: 'DELETE',
})
if (!response.ok) {
const result = await response.json()
throw new Error(result.error || 'Failed to delete connector')
}
const result = await response.json()
if (!result?.success) {
throw new Error(result?.error || 'Failed to delete connector')
}
}
export function useDeleteConnector() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: deleteConnector,
onSuccess: (_, { knowledgeBaseId }) => {
queryClient.invalidateQueries({
queryKey: connectorKeys.list(knowledgeBaseId),
})
queryClient.invalidateQueries({
queryKey: knowledgeKeys.detail(knowledgeBaseId),
})
},
})
}
export interface TriggerSyncParams {
knowledgeBaseId: string
connectorId: string
}
async function triggerSync({ knowledgeBaseId, connectorId }: TriggerSyncParams): Promise<void> {
const response = await fetch(`/api/knowledge/${knowledgeBaseId}/connectors/${connectorId}/sync`, {
method: 'POST',
})
if (!response.ok) {
const result = await response.json()
throw new Error(result.error || 'Failed to trigger sync')
}
}
export function useTriggerSync() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: triggerSync,
onSuccess: (_, { knowledgeBaseId, connectorId }) => {
queryClient.invalidateQueries({
queryKey: connectorKeys.list(knowledgeBaseId),
})
queryClient.invalidateQueries({
queryKey: connectorKeys.detail(knowledgeBaseId, connectorId),
})
queryClient.invalidateQueries({
queryKey: knowledgeKeys.detail(knowledgeBaseId),
})
},
})
}
export interface ConnectorDocumentData {
id: string
filename: string
externalId: string | null
sourceUrl: string | null
enabled: boolean
deletedAt: string | null
userExcluded: boolean
uploadedAt: string
processingStatus: string
}
export interface ConnectorDocumentsResponse {
documents: ConnectorDocumentData[]
counts: { active: number; excluded: number }
}
export const connectorDocumentKeys = {
list: (knowledgeBaseId?: string, connectorId?: string) =>
[...connectorKeys.detail(knowledgeBaseId, connectorId), 'documents'] as const,
}
async function fetchConnectorDocuments(
knowledgeBaseId: string,
connectorId: string,
includeExcluded: boolean
): Promise<ConnectorDocumentsResponse> {
const params = includeExcluded ? '?includeExcluded=true' : ''
const response = await fetch(
`/api/knowledge/${knowledgeBaseId}/connectors/${connectorId}/documents${params}`
)
if (!response.ok) {
throw new Error(`Failed to fetch connector documents: ${response.status}`)
}
const result = await response.json()
if (!result?.success) {
throw new Error(result?.error || 'Failed to fetch connector documents')
}
return result.data
}
export function useConnectorDocuments(
knowledgeBaseId?: string,
connectorId?: string,
options?: { includeExcluded?: boolean }
) {
return useQuery({
queryKey: [
...connectorDocumentKeys.list(knowledgeBaseId, connectorId),
options?.includeExcluded ?? false,
],
queryFn: () =>
fetchConnectorDocuments(
knowledgeBaseId as string,
connectorId as string,
options?.includeExcluded ?? false
),
enabled: Boolean(knowledgeBaseId && connectorId),
staleTime: 30 * 1000,
placeholderData: keepPreviousData,
})
}
interface ConnectorDocumentMutationParams {
knowledgeBaseId: string
connectorId: string
documentIds: string[]
}
async function excludeConnectorDocuments({
knowledgeBaseId,
connectorId,
documentIds,
}: ConnectorDocumentMutationParams): Promise<{ excludedCount: number }> {
const response = await fetch(
`/api/knowledge/${knowledgeBaseId}/connectors/${connectorId}/documents`,
{
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ operation: 'exclude', documentIds }),
}
)
if (!response.ok) {
const result = await response.json()
throw new Error(result.error || 'Failed to exclude documents')
}
const result = await response.json()
return result.data
}
export function useExcludeConnectorDocument() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: excludeConnectorDocuments,
onSuccess: (_, { knowledgeBaseId, connectorId }) => {
queryClient.invalidateQueries({
queryKey: connectorDocumentKeys.list(knowledgeBaseId, connectorId),
})
queryClient.invalidateQueries({
queryKey: knowledgeKeys.detail(knowledgeBaseId),
})
},
})
}
async function restoreConnectorDocuments({
knowledgeBaseId,
connectorId,
documentIds,
}: ConnectorDocumentMutationParams): Promise<{ restoredCount: number }> {
const response = await fetch(
`/api/knowledge/${knowledgeBaseId}/connectors/${connectorId}/documents`,
{
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ operation: 'restore', documentIds }),
}
)
if (!response.ok) {
const result = await response.json()
throw new Error(result.error || 'Failed to restore documents')
}
const result = await response.json()
return result.data
}
export function useRestoreConnectorDocument() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: restoreConnectorDocuments,
onSuccess: (_, { knowledgeBaseId, connectorId }) => {
queryClient.invalidateQueries({
queryKey: connectorDocumentKeys.list(knowledgeBaseId, connectorId),
})
queryClient.invalidateQueries({
queryKey: knowledgeKeys.detail(knowledgeBaseId),
})
},
})
}

View File

@@ -79,14 +79,6 @@ export async function fetchDocument(
return result.data return result.data
} }
export interface DocumentTagFilter {
tagSlot: string
fieldType: 'text' | 'number' | 'date' | 'boolean'
operator: string
value: string
valueTo?: string
}
export interface KnowledgeDocumentsParams { export interface KnowledgeDocumentsParams {
knowledgeBaseId: string knowledgeBaseId: string
search?: string search?: string
@@ -95,7 +87,6 @@ export interface KnowledgeDocumentsParams {
sortBy?: string sortBy?: string
sortOrder?: string sortOrder?: string
enabledFilter?: 'all' | 'enabled' | 'disabled' enabledFilter?: 'all' | 'enabled' | 'disabled'
tagFilters?: DocumentTagFilter[]
} }
export interface KnowledgeDocumentsResponse { export interface KnowledgeDocumentsResponse {
@@ -111,7 +102,6 @@ export async function fetchKnowledgeDocuments({
sortBy, sortBy,
sortOrder, sortOrder,
enabledFilter, enabledFilter,
tagFilters,
}: KnowledgeDocumentsParams): Promise<KnowledgeDocumentsResponse> { }: KnowledgeDocumentsParams): Promise<KnowledgeDocumentsResponse> {
const params = new URLSearchParams() const params = new URLSearchParams()
if (search) params.set('search', search) if (search) params.set('search', search)
@@ -120,7 +110,6 @@ export async function fetchKnowledgeDocuments({
params.set('limit', limit.toString()) params.set('limit', limit.toString())
params.set('offset', offset.toString()) params.set('offset', offset.toString())
if (enabledFilter) params.set('enabledFilter', enabledFilter) if (enabledFilter) params.set('enabledFilter', enabledFilter)
if (tagFilters && tagFilters.length > 0) params.set('tagFilters', JSON.stringify(tagFilters))
const url = `/api/knowledge/${knowledgeBaseId}/documents${params.toString() ? `?${params.toString()}` : ''}` const url = `/api/knowledge/${knowledgeBaseId}/documents${params.toString() ? `?${params.toString()}` : ''}`
const response = await fetch(url) const response = await fetch(url)
@@ -158,7 +147,6 @@ export interface KnowledgeChunksParams {
knowledgeBaseId: string knowledgeBaseId: string
documentId: string documentId: string
search?: string search?: string
enabledFilter?: 'all' | 'enabled' | 'disabled'
limit?: number limit?: number
offset?: number offset?: number
} }
@@ -172,15 +160,11 @@ export async function fetchKnowledgeChunks({
knowledgeBaseId, knowledgeBaseId,
documentId, documentId,
search, search,
enabledFilter,
limit = 50, limit = 50,
offset = 0, offset = 0,
}: KnowledgeChunksParams): Promise<KnowledgeChunksResponse> { }: KnowledgeChunksParams): Promise<KnowledgeChunksResponse> {
const params = new URLSearchParams() const params = new URLSearchParams()
if (search) params.set('search', search) if (search) params.set('search', search)
if (enabledFilter && enabledFilter !== 'all') {
params.set('enabled', enabledFilter === 'enabled' ? 'true' : 'false')
}
if (limit) params.set('limit', limit.toString()) if (limit) params.set('limit', limit.toString())
if (offset) params.set('offset', offset.toString()) if (offset) params.set('offset', offset.toString())
@@ -250,7 +234,6 @@ export const serializeDocumentParams = (params: KnowledgeDocumentsParams) =>
sortBy: params.sortBy ?? '', sortBy: params.sortBy ?? '',
sortOrder: params.sortOrder ?? '', sortOrder: params.sortOrder ?? '',
enabledFilter: params.enabledFilter ?? 'all', enabledFilter: params.enabledFilter ?? 'all',
tagFilters: params.tagFilters ?? [],
}) })
export function useKnowledgeDocumentsQuery( export function useKnowledgeDocumentsQuery(
@@ -277,7 +260,6 @@ export function useKnowledgeDocumentsQuery(
export const serializeChunkParams = (params: KnowledgeChunksParams) => export const serializeChunkParams = (params: KnowledgeChunksParams) =>
JSON.stringify({ JSON.stringify({
search: params.search ?? '', search: params.search ?? '',
enabledFilter: params.enabledFilter ?? 'all',
limit: params.limit ?? 50, limit: params.limit ?? 50,
offset: params.offset ?? 0, offset: params.offset ?? 0,
}) })

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