mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-19 20:08:04 -05:00
Compare commits
24 Commits
v0.5.62
...
improvemen
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51af1b50ff | ||
|
|
1fce9864d7 | ||
|
|
08cefa67c8 | ||
|
|
5f45db4343 | ||
|
|
81cbfe7af4 | ||
|
|
739341b08e | ||
|
|
3c43779ba3 | ||
|
|
1861f77283 | ||
|
|
72c2ba7443 | ||
|
|
037dad6975 | ||
|
|
408597e12b | ||
|
|
932f8fd654 | ||
|
|
b4c2294e67 | ||
|
|
1dbf92db3f | ||
|
|
3a923648cb | ||
|
|
5e2468cfd3 | ||
|
|
7c0f43305b | ||
|
|
ee7572185a | ||
|
|
19a8daedf7 | ||
|
|
0fcd52683a | ||
|
|
b8b20576d3 | ||
|
|
4b8534ebd0 | ||
|
|
f6960a4bd4 | ||
|
|
8740566f6a |
@@ -86,27 +86,112 @@ export async function GET(request: NextRequest) {
|
||||
)
|
||||
.limit(candidateLimit)
|
||||
|
||||
const seenIds = new Set<string>()
|
||||
const mergedResults = []
|
||||
const knownLocales = ['en', 'es', 'fr', 'de', 'ja', 'zh']
|
||||
|
||||
for (let i = 0; i < Math.max(vectorResults.length, keywordResults.length); i++) {
|
||||
if (i < vectorResults.length && !seenIds.has(vectorResults[i].chunkId)) {
|
||||
mergedResults.push(vectorResults[i])
|
||||
seenIds.add(vectorResults[i].chunkId)
|
||||
}
|
||||
if (i < keywordResults.length && !seenIds.has(keywordResults[i].chunkId)) {
|
||||
mergedResults.push(keywordResults[i])
|
||||
seenIds.add(keywordResults[i].chunkId)
|
||||
const vectorRankMap = new Map<string, number>()
|
||||
vectorResults.forEach((r, idx) => vectorRankMap.set(r.chunkId, idx + 1))
|
||||
|
||||
const keywordRankMap = new Map<string, number>()
|
||||
keywordResults.forEach((r, idx) => keywordRankMap.set(r.chunkId, idx + 1))
|
||||
|
||||
const allChunkIds = new Set([
|
||||
...vectorResults.map((r) => r.chunkId),
|
||||
...keywordResults.map((r) => r.chunkId),
|
||||
])
|
||||
|
||||
const k = 60
|
||||
type ResultWithRRF = (typeof vectorResults)[0] & { rrfScore: number }
|
||||
const scoredResults: ResultWithRRF[] = []
|
||||
|
||||
for (const chunkId of allChunkIds) {
|
||||
const vectorRank = vectorRankMap.get(chunkId) ?? Number.POSITIVE_INFINITY
|
||||
const keywordRank = keywordRankMap.get(chunkId) ?? Number.POSITIVE_INFINITY
|
||||
|
||||
const rrfScore = 1 / (k + vectorRank) + 1 / (k + keywordRank)
|
||||
|
||||
const result =
|
||||
vectorResults.find((r) => r.chunkId === chunkId) ||
|
||||
keywordResults.find((r) => r.chunkId === chunkId)
|
||||
|
||||
if (result) {
|
||||
scoredResults.push({ ...result, rrfScore })
|
||||
}
|
||||
}
|
||||
|
||||
const filteredResults = mergedResults.slice(0, limit)
|
||||
const searchResults = filteredResults.map((result) => {
|
||||
scoredResults.sort((a, b) => b.rrfScore - a.rrfScore)
|
||||
|
||||
const localeFilteredResults = scoredResults.filter((result) => {
|
||||
const firstPart = result.sourceDocument.split('/')[0]
|
||||
if (knownLocales.includes(firstPart)) {
|
||||
return firstPart === locale
|
||||
}
|
||||
return locale === 'en'
|
||||
})
|
||||
|
||||
const queryLower = query.toLowerCase()
|
||||
const getTitleBoost = (result: ResultWithRRF): number => {
|
||||
const fileName = result.sourceDocument
|
||||
.replace('.mdx', '')
|
||||
.split('/')
|
||||
.pop()
|
||||
?.toLowerCase()
|
||||
?.replace(/_/g, ' ')
|
||||
|
||||
if (fileName === queryLower) return 0.01
|
||||
if (fileName?.includes(queryLower)) return 0.005
|
||||
return 0
|
||||
}
|
||||
|
||||
localeFilteredResults.sort((a, b) => {
|
||||
return b.rrfScore + getTitleBoost(b) - (a.rrfScore + getTitleBoost(a))
|
||||
})
|
||||
|
||||
const pageMap = new Map<string, ResultWithRRF>()
|
||||
|
||||
for (const result of localeFilteredResults) {
|
||||
const pageKey = result.sourceDocument
|
||||
const existing = pageMap.get(pageKey)
|
||||
|
||||
if (!existing || result.rrfScore > existing.rrfScore) {
|
||||
pageMap.set(pageKey, result)
|
||||
}
|
||||
}
|
||||
|
||||
const deduplicatedResults = Array.from(pageMap.values())
|
||||
.sort((a, b) => b.rrfScore + getTitleBoost(b) - (a.rrfScore + getTitleBoost(a)))
|
||||
.slice(0, limit)
|
||||
|
||||
const searchResults = deduplicatedResults.map((result) => {
|
||||
const title = result.headerText || result.sourceDocument.replace('.mdx', '')
|
||||
|
||||
const pathParts = result.sourceDocument
|
||||
.replace('.mdx', '')
|
||||
.split('/')
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.filter((part) => part !== 'index' && !knownLocales.includes(part))
|
||||
.map((part) => {
|
||||
return part
|
||||
.replace(/_/g, ' ')
|
||||
.split(' ')
|
||||
.map((word) => {
|
||||
const acronyms = [
|
||||
'api',
|
||||
'mcp',
|
||||
'sdk',
|
||||
'url',
|
||||
'http',
|
||||
'json',
|
||||
'xml',
|
||||
'html',
|
||||
'css',
|
||||
'ai',
|
||||
]
|
||||
if (acronyms.includes(word.toLowerCase())) {
|
||||
return word.toUpperCase()
|
||||
}
|
||||
return word.charAt(0).toUpperCase() + word.slice(1)
|
||||
})
|
||||
.join(' ')
|
||||
})
|
||||
|
||||
return {
|
||||
id: result.chunkId,
|
||||
|
||||
@@ -1739,12 +1739,12 @@ export function BrowserUseIcon(props: SVGProps<SVGSVGElement>) {
|
||||
{...props}
|
||||
version='1.0'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='150pt'
|
||||
height='150pt'
|
||||
width='28'
|
||||
height='28'
|
||||
viewBox='0 0 150 150'
|
||||
preserveAspectRatio='xMidYMid meet'
|
||||
>
|
||||
<g transform='translate(0,150) scale(0.05,-0.05)' fill='#000000' stroke='none'>
|
||||
<g transform='translate(0,150) scale(0.05,-0.05)' fill='currentColor' stroke='none'>
|
||||
<path
|
||||
d='M786 2713 c-184 -61 -353 -217 -439 -405 -76 -165 -65 -539 19 -666
|
||||
l57 -85 -48 -124 c-203 -517 -79 -930 346 -1155 159 -85 441 -71 585 28 l111
|
||||
|
||||
@@ -7,7 +7,7 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="browser_use"
|
||||
color="#E0E0E0"
|
||||
color="#181C1E"
|
||||
/>
|
||||
|
||||
{/* MANUAL-CONTENT-START:intro */}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -119,6 +119,145 @@ Get a specific event from Google Calendar. Returns API-aligned fields only.
|
||||
| `creator` | json | Event creator |
|
||||
| `organizer` | json | Event organizer |
|
||||
|
||||
### `google_calendar_update`
|
||||
|
||||
Update an existing event in Google Calendar. Returns API-aligned fields only.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `calendarId` | string | No | Calendar ID \(defaults to primary\) |
|
||||
| `eventId` | string | Yes | Event ID to update |
|
||||
| `summary` | string | No | New event title/summary |
|
||||
| `description` | string | No | New event description |
|
||||
| `location` | string | No | New event location |
|
||||
| `startDateTime` | string | No | New start date and time. MUST include timezone offset \(e.g., 2025-06-03T10:00:00-08:00\) OR provide timeZone parameter |
|
||||
| `endDateTime` | string | No | New end date and time. MUST include timezone offset \(e.g., 2025-06-03T11:00:00-08:00\) OR provide timeZone parameter |
|
||||
| `timeZone` | string | No | Time zone \(e.g., America/Los_Angeles\). Required if datetime does not include offset. |
|
||||
| `attendees` | array | No | Array of attendee email addresses \(replaces existing attendees\) |
|
||||
| `sendUpdates` | string | No | How to send updates to attendees: all, externalOnly, or none |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `id` | string | Event ID |
|
||||
| `htmlLink` | string | Event link |
|
||||
| `status` | string | Event status |
|
||||
| `summary` | string | Event title |
|
||||
| `description` | string | Event description |
|
||||
| `location` | string | Event location |
|
||||
| `start` | json | Event start |
|
||||
| `end` | json | Event end |
|
||||
| `attendees` | json | Event attendees |
|
||||
| `creator` | json | Event creator |
|
||||
| `organizer` | json | Event organizer |
|
||||
|
||||
### `google_calendar_delete`
|
||||
|
||||
Delete an event from Google Calendar. Returns API-aligned fields only.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `calendarId` | string | No | Calendar ID \(defaults to primary\) |
|
||||
| `eventId` | string | Yes | Event ID to delete |
|
||||
| `sendUpdates` | string | No | How to send updates to attendees: all, externalOnly, or none |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `eventId` | string | Deleted event ID |
|
||||
| `deleted` | boolean | Whether deletion was successful |
|
||||
|
||||
### `google_calendar_move`
|
||||
|
||||
Move an event to a different calendar. Returns API-aligned fields only.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `calendarId` | string | No | Source calendar ID \(defaults to primary\) |
|
||||
| `eventId` | string | Yes | Event ID to move |
|
||||
| `destinationCalendarId` | string | Yes | Destination calendar ID |
|
||||
| `sendUpdates` | string | No | How to send updates to attendees: all, externalOnly, or none |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `id` | string | Event ID |
|
||||
| `htmlLink` | string | Event link |
|
||||
| `status` | string | Event status |
|
||||
| `summary` | string | Event title |
|
||||
| `description` | string | Event description |
|
||||
| `location` | string | Event location |
|
||||
| `start` | json | Event start |
|
||||
| `end` | json | Event end |
|
||||
| `attendees` | json | Event attendees |
|
||||
| `creator` | json | Event creator |
|
||||
| `organizer` | json | Event organizer |
|
||||
|
||||
### `google_calendar_instances`
|
||||
|
||||
Get instances of a recurring event from Google Calendar. Returns API-aligned fields only.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `calendarId` | string | No | Calendar ID \(defaults to primary\) |
|
||||
| `eventId` | string | Yes | Recurring event ID to get instances of |
|
||||
| `timeMin` | string | No | Lower bound for instances \(RFC3339 timestamp, e.g., 2025-06-03T00:00:00Z\) |
|
||||
| `timeMax` | string | No | Upper bound for instances \(RFC3339 timestamp, e.g., 2025-06-04T00:00:00Z\) |
|
||||
| `maxResults` | number | No | Maximum number of instances to return \(default 250, max 2500\) |
|
||||
| `pageToken` | string | No | Token for retrieving subsequent pages of results |
|
||||
| `showDeleted` | boolean | No | Include deleted instances |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `nextPageToken` | string | Next page token |
|
||||
| `timeZone` | string | Calendar time zone |
|
||||
| `instances` | json | List of recurring event instances |
|
||||
|
||||
### `google_calendar_list_calendars`
|
||||
|
||||
List all calendars in the user
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `minAccessRole` | string | No | Minimum access role for returned calendars: freeBusyReader, reader, writer, or owner |
|
||||
| `maxResults` | number | No | Maximum number of calendars to return \(default 100, max 250\) |
|
||||
| `pageToken` | string | No | Token for retrieving subsequent pages of results |
|
||||
| `showDeleted` | boolean | No | Include deleted calendars |
|
||||
| `showHidden` | boolean | No | Include hidden calendars |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `nextPageToken` | string | Next page token |
|
||||
| `calendars` | array | List of calendars |
|
||||
| ↳ `id` | string | Calendar ID |
|
||||
| ↳ `summary` | string | Calendar title |
|
||||
| ↳ `description` | string | Calendar description |
|
||||
| ↳ `location` | string | Calendar location |
|
||||
| ↳ `timeZone` | string | Calendar time zone |
|
||||
| ↳ `accessRole` | string | Access role for the calendar |
|
||||
| ↳ `backgroundColor` | string | Calendar background color |
|
||||
| ↳ `foregroundColor` | string | Calendar foreground color |
|
||||
| ↳ `primary` | boolean | Whether this is the primary calendar |
|
||||
| ↳ `hidden` | boolean | Whether the calendar is hidden |
|
||||
| ↳ `selected` | boolean | Whether the calendar is selected |
|
||||
|
||||
### `google_calendar_quick_add`
|
||||
|
||||
Create events from natural language text. Returns API-aligned fields only.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: Google Drive
|
||||
description: Create, upload, and list files
|
||||
description: Manage files, folders, and permissions
|
||||
---
|
||||
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
@@ -40,217 +40,12 @@ In Sim, the Google Drive integration enables your agents to interact directly wi
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Integrate Google Drive into the workflow. Can create, upload, and list files.
|
||||
Integrate Google Drive into the workflow. Can create, upload, download, copy, move, delete, share files and manage permissions.
|
||||
|
||||
|
||||
|
||||
## Tools
|
||||
|
||||
### `google_drive_upload`
|
||||
|
||||
Upload a file to Google Drive with complete metadata returned
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `fileName` | string | Yes | The name of the file to upload |
|
||||
| `file` | file | No | Binary file to upload \(UserFile object\) |
|
||||
| `content` | string | No | Text content to upload \(use this OR file, not both\) |
|
||||
| `mimeType` | string | No | The MIME type of the file to upload \(auto-detected from file if not provided\) |
|
||||
| `folderSelector` | string | No | Select the folder to upload the file to |
|
||||
| `folderId` | string | No | The ID of the folder to upload the file to \(internal use\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `file` | object | Complete uploaded file metadata from Google Drive |
|
||||
| ↳ `id` | string | Google Drive file ID |
|
||||
| ↳ `name` | string | File name |
|
||||
| ↳ `mimeType` | string | MIME type |
|
||||
| ↳ `kind` | string | Resource type identifier |
|
||||
| ↳ `description` | string | File description |
|
||||
| ↳ `originalFilename` | string | Original uploaded filename |
|
||||
| ↳ `fullFileExtension` | string | Full file extension |
|
||||
| ↳ `fileExtension` | string | File extension |
|
||||
| ↳ `owners` | json | List of file owners |
|
||||
| ↳ `permissions` | json | File permissions |
|
||||
| ↳ `permissionIds` | json | Permission IDs |
|
||||
| ↳ `shared` | boolean | Whether file is shared |
|
||||
| ↳ `ownedByMe` | boolean | Whether owned by current user |
|
||||
| ↳ `writersCanShare` | boolean | Whether writers can share |
|
||||
| ↳ `viewersCanCopyContent` | boolean | Whether viewers can copy |
|
||||
| ↳ `copyRequiresWriterPermission` | boolean | Whether copy requires writer permission |
|
||||
| ↳ `sharingUser` | json | User who shared the file |
|
||||
| ↳ `starred` | boolean | Whether file is starred |
|
||||
| ↳ `trashed` | boolean | Whether file is in trash |
|
||||
| ↳ `explicitlyTrashed` | boolean | Whether explicitly trashed |
|
||||
| ↳ `appProperties` | json | App-specific properties |
|
||||
| ↳ `createdTime` | string | File creation time |
|
||||
| ↳ `modifiedTime` | string | Last modification time |
|
||||
| ↳ `modifiedByMeTime` | string | When modified by current user |
|
||||
| ↳ `viewedByMeTime` | string | When last viewed by current user |
|
||||
| ↳ `sharedWithMeTime` | string | When shared with current user |
|
||||
| ↳ `lastModifyingUser` | json | User who last modified the file |
|
||||
| ↳ `viewedByMe` | boolean | Whether viewed by current user |
|
||||
| ↳ `modifiedByMe` | boolean | Whether modified by current user |
|
||||
| ↳ `webViewLink` | string | URL to view in browser |
|
||||
| ↳ `webContentLink` | string | Direct download URL |
|
||||
| ↳ `iconLink` | string | URL to file icon |
|
||||
| ↳ `thumbnailLink` | string | URL to thumbnail |
|
||||
| ↳ `exportLinks` | json | Export format links |
|
||||
| ↳ `size` | string | File size in bytes |
|
||||
| ↳ `quotaBytesUsed` | string | Storage quota used |
|
||||
| ↳ `md5Checksum` | string | MD5 hash |
|
||||
| ↳ `sha1Checksum` | string | SHA-1 hash |
|
||||
| ↳ `sha256Checksum` | string | SHA-256 hash |
|
||||
| ↳ `parents` | json | Parent folder IDs |
|
||||
| ↳ `spaces` | json | Spaces containing file |
|
||||
| ↳ `driveId` | string | Shared drive ID |
|
||||
| ↳ `capabilities` | json | User capabilities on file |
|
||||
| ↳ `version` | string | Version number |
|
||||
| ↳ `headRevisionId` | string | Head revision ID |
|
||||
| ↳ `hasThumbnail` | boolean | Whether has thumbnail |
|
||||
| ↳ `thumbnailVersion` | string | Thumbnail version |
|
||||
| ↳ `imageMediaMetadata` | json | Image-specific metadata |
|
||||
| ↳ `videoMediaMetadata` | json | Video-specific metadata |
|
||||
| ↳ `isAppAuthorized` | boolean | Whether created by requesting app |
|
||||
| ↳ `contentRestrictions` | json | Content restrictions |
|
||||
| ↳ `linkShareMetadata` | json | Link share metadata |
|
||||
|
||||
### `google_drive_create_folder`
|
||||
|
||||
Create a new folder in Google Drive with complete metadata returned
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `fileName` | string | Yes | Name of the folder to create |
|
||||
| `folderSelector` | string | No | Select the parent folder to create the folder in |
|
||||
| `folderId` | string | No | ID of the parent folder \(internal use\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `file` | object | Complete created folder metadata from Google Drive |
|
||||
| ↳ `id` | string | Google Drive folder ID |
|
||||
| ↳ `name` | string | Folder name |
|
||||
| ↳ `mimeType` | string | MIME type \(application/vnd.google-apps.folder\) |
|
||||
| ↳ `kind` | string | Resource type identifier |
|
||||
| ↳ `description` | string | Folder description |
|
||||
| ↳ `owners` | json | List of folder owners |
|
||||
| ↳ `permissions` | json | Folder permissions |
|
||||
| ↳ `permissionIds` | json | Permission IDs |
|
||||
| ↳ `shared` | boolean | Whether folder is shared |
|
||||
| ↳ `ownedByMe` | boolean | Whether owned by current user |
|
||||
| ↳ `writersCanShare` | boolean | Whether writers can share |
|
||||
| ↳ `viewersCanCopyContent` | boolean | Whether viewers can copy |
|
||||
| ↳ `copyRequiresWriterPermission` | boolean | Whether copy requires writer permission |
|
||||
| ↳ `sharingUser` | json | User who shared the folder |
|
||||
| ↳ `starred` | boolean | Whether folder is starred |
|
||||
| ↳ `trashed` | boolean | Whether folder is in trash |
|
||||
| ↳ `explicitlyTrashed` | boolean | Whether explicitly trashed |
|
||||
| ↳ `appProperties` | json | App-specific properties |
|
||||
| ↳ `folderColorRgb` | string | Folder color |
|
||||
| ↳ `createdTime` | string | Folder creation time |
|
||||
| ↳ `modifiedTime` | string | Last modification time |
|
||||
| ↳ `modifiedByMeTime` | string | When modified by current user |
|
||||
| ↳ `viewedByMeTime` | string | When last viewed by current user |
|
||||
| ↳ `sharedWithMeTime` | string | When shared with current user |
|
||||
| ↳ `lastModifyingUser` | json | User who last modified the folder |
|
||||
| ↳ `viewedByMe` | boolean | Whether viewed by current user |
|
||||
| ↳ `modifiedByMe` | boolean | Whether modified by current user |
|
||||
| ↳ `webViewLink` | string | URL to view in browser |
|
||||
| ↳ `iconLink` | string | URL to folder icon |
|
||||
| ↳ `parents` | json | Parent folder IDs |
|
||||
| ↳ `spaces` | json | Spaces containing folder |
|
||||
| ↳ `driveId` | string | Shared drive ID |
|
||||
| ↳ `capabilities` | json | User capabilities on folder |
|
||||
| ↳ `version` | string | Version number |
|
||||
| ↳ `isAppAuthorized` | boolean | Whether created by requesting app |
|
||||
| ↳ `contentRestrictions` | json | Content restrictions |
|
||||
| ↳ `linkShareMetadata` | json | Link share metadata |
|
||||
|
||||
### `google_drive_download`
|
||||
|
||||
Download a file from Google Drive with complete metadata (exports Google Workspace files automatically)
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `fileId` | string | Yes | The ID of the file to download |
|
||||
| `mimeType` | string | No | The MIME type to export Google Workspace files to \(optional\) |
|
||||
| `fileName` | string | No | Optional filename override |
|
||||
| `includeRevisions` | boolean | No | Whether to include revision history in the metadata \(default: true, returns first 100 revisions\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `file` | object | Downloaded file data |
|
||||
| ↳ `name` | string | File name |
|
||||
| ↳ `mimeType` | string | MIME type of the file |
|
||||
| ↳ `data` | string | File content as base64-encoded string |
|
||||
| ↳ `size` | number | File size in bytes |
|
||||
| `metadata` | object | Complete file metadata from Google Drive |
|
||||
| ↳ `id` | string | Google Drive file ID |
|
||||
| ↳ `name` | string | File name |
|
||||
| ↳ `mimeType` | string | MIME type |
|
||||
| ↳ `kind` | string | Resource type identifier |
|
||||
| ↳ `description` | string | File description |
|
||||
| ↳ `originalFilename` | string | Original uploaded filename |
|
||||
| ↳ `fullFileExtension` | string | Full file extension |
|
||||
| ↳ `fileExtension` | string | File extension |
|
||||
| ↳ `owners` | json | List of file owners |
|
||||
| ↳ `permissions` | json | File permissions |
|
||||
| ↳ `permissionIds` | json | Permission IDs |
|
||||
| ↳ `shared` | boolean | Whether file is shared |
|
||||
| ↳ `ownedByMe` | boolean | Whether owned by current user |
|
||||
| ↳ `writersCanShare` | boolean | Whether writers can share |
|
||||
| ↳ `viewersCanCopyContent` | boolean | Whether viewers can copy |
|
||||
| ↳ `copyRequiresWriterPermission` | boolean | Whether copy requires writer permission |
|
||||
| ↳ `sharingUser` | json | User who shared the file |
|
||||
| ↳ `starred` | boolean | Whether file is starred |
|
||||
| ↳ `trashed` | boolean | Whether file is in trash |
|
||||
| ↳ `explicitlyTrashed` | boolean | Whether explicitly trashed |
|
||||
| ↳ `appProperties` | json | App-specific properties |
|
||||
| ↳ `createdTime` | string | File creation time |
|
||||
| ↳ `modifiedTime` | string | Last modification time |
|
||||
| ↳ `modifiedByMeTime` | string | When modified by current user |
|
||||
| ↳ `viewedByMeTime` | string | When last viewed by current user |
|
||||
| ↳ `sharedWithMeTime` | string | When shared with current user |
|
||||
| ↳ `lastModifyingUser` | json | User who last modified the file |
|
||||
| ↳ `viewedByMe` | boolean | Whether viewed by current user |
|
||||
| ↳ `modifiedByMe` | boolean | Whether modified by current user |
|
||||
| ↳ `webViewLink` | string | URL to view in browser |
|
||||
| ↳ `webContentLink` | string | Direct download URL |
|
||||
| ↳ `iconLink` | string | URL to file icon |
|
||||
| ↳ `thumbnailLink` | string | URL to thumbnail |
|
||||
| ↳ `exportLinks` | json | Export format links |
|
||||
| ↳ `size` | string | File size in bytes |
|
||||
| ↳ `quotaBytesUsed` | string | Storage quota used |
|
||||
| ↳ `md5Checksum` | string | MD5 hash |
|
||||
| ↳ `sha1Checksum` | string | SHA-1 hash |
|
||||
| ↳ `sha256Checksum` | string | SHA-256 hash |
|
||||
| ↳ `parents` | json | Parent folder IDs |
|
||||
| ↳ `spaces` | json | Spaces containing file |
|
||||
| ↳ `driveId` | string | Shared drive ID |
|
||||
| ↳ `capabilities` | json | User capabilities on file |
|
||||
| ↳ `version` | string | Version number |
|
||||
| ↳ `headRevisionId` | string | Head revision ID |
|
||||
| ↳ `hasThumbnail` | boolean | Whether has thumbnail |
|
||||
| ↳ `thumbnailVersion` | string | Thumbnail version |
|
||||
| ↳ `imageMediaMetadata` | json | Image-specific metadata |
|
||||
| ↳ `videoMediaMetadata` | json | Video-specific metadata |
|
||||
| ↳ `isAppAuthorized` | boolean | Whether created by requesting app |
|
||||
| ↳ `contentRestrictions` | json | Content restrictions |
|
||||
| ↳ `linkShareMetadata` | json | Link share metadata |
|
||||
| ↳ `revisions` | json | File revision history \(first 100 revisions only\) |
|
||||
|
||||
### `google_drive_list`
|
||||
|
||||
List files and folders in Google Drive with complete metadata
|
||||
@@ -271,9 +66,9 @@ List files and folders in Google Drive with complete metadata
|
||||
| --------- | ---- | ----------- |
|
||||
| `files` | array | Array of file metadata objects from Google Drive |
|
||||
| ↳ `id` | string | Google Drive file ID |
|
||||
| ↳ `kind` | string | Resource type identifier |
|
||||
| ↳ `name` | string | File name |
|
||||
| ↳ `mimeType` | string | MIME type |
|
||||
| ↳ `kind` | string | Resource type identifier |
|
||||
| ↳ `description` | string | File description |
|
||||
| ↳ `originalFilename` | string | Original uploaded filename |
|
||||
| ↳ `fullFileExtension` | string | Full file extension |
|
||||
@@ -324,4 +119,455 @@ List files and folders in Google Drive with complete metadata
|
||||
| ↳ `linkShareMetadata` | json | Link share metadata |
|
||||
| `nextPageToken` | string | Token for fetching the next page of results |
|
||||
|
||||
### `google_drive_get_file`
|
||||
|
||||
Get metadata for a specific file in Google Drive by its ID
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `fileId` | string | Yes | The ID of the file to retrieve |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `file` | json | The file metadata |
|
||||
| ↳ `id` | string | Google Drive file ID |
|
||||
| ↳ `kind` | string | Resource type identifier |
|
||||
| ↳ `name` | string | File name |
|
||||
| ↳ `mimeType` | string | MIME type |
|
||||
| ↳ `description` | string | File description |
|
||||
| ↳ `size` | string | File size in bytes |
|
||||
| ↳ `starred` | boolean | Whether file is starred |
|
||||
| ↳ `trashed` | boolean | Whether file is in trash |
|
||||
| ↳ `webViewLink` | string | URL to view in browser |
|
||||
| ↳ `webContentLink` | string | Direct download URL |
|
||||
| ↳ `iconLink` | string | URL to file icon |
|
||||
| ↳ `thumbnailLink` | string | URL to thumbnail |
|
||||
| ↳ `parents` | json | Parent folder IDs |
|
||||
| ↳ `owners` | json | List of file owners |
|
||||
| ↳ `permissions` | json | File permissions |
|
||||
| ↳ `createdTime` | string | File creation time |
|
||||
| ↳ `modifiedTime` | string | Last modification time |
|
||||
| ↳ `lastModifyingUser` | json | User who last modified the file |
|
||||
| ↳ `shared` | boolean | Whether file is shared |
|
||||
| ↳ `ownedByMe` | boolean | Whether owned by current user |
|
||||
| ↳ `capabilities` | json | User capabilities on file |
|
||||
| ↳ `md5Checksum` | string | MD5 hash |
|
||||
| ↳ `version` | string | Version number |
|
||||
|
||||
### `google_drive_create_folder`
|
||||
|
||||
Create a new folder in Google Drive with complete metadata returned
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `fileName` | string | Yes | Name of the folder to create |
|
||||
| `folderSelector` | string | No | Select the parent folder to create the folder in |
|
||||
| `folderId` | string | No | ID of the parent folder \(internal use\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `file` | object | Complete created folder metadata from Google Drive |
|
||||
| ↳ `id` | string | Google Drive folder ID |
|
||||
| ↳ `kind` | string | Resource type identifier |
|
||||
| ↳ `name` | string | Folder name |
|
||||
| ↳ `mimeType` | string | MIME type \(application/vnd.google-apps.folder\) |
|
||||
| ↳ `description` | string | Folder description |
|
||||
| ↳ `owners` | json | List of folder owners |
|
||||
| ↳ `permissions` | json | Folder permissions |
|
||||
| ↳ `permissionIds` | json | Permission IDs |
|
||||
| ↳ `shared` | boolean | Whether folder is shared |
|
||||
| ↳ `ownedByMe` | boolean | Whether owned by current user |
|
||||
| ↳ `writersCanShare` | boolean | Whether writers can share |
|
||||
| ↳ `viewersCanCopyContent` | boolean | Whether viewers can copy |
|
||||
| ↳ `copyRequiresWriterPermission` | boolean | Whether copy requires writer permission |
|
||||
| ↳ `sharingUser` | json | User who shared the folder |
|
||||
| ↳ `starred` | boolean | Whether folder is starred |
|
||||
| ↳ `trashed` | boolean | Whether folder is in trash |
|
||||
| ↳ `explicitlyTrashed` | boolean | Whether explicitly trashed |
|
||||
| ↳ `appProperties` | json | App-specific properties |
|
||||
| ↳ `folderColorRgb` | string | Folder color |
|
||||
| ↳ `createdTime` | string | Folder creation time |
|
||||
| ↳ `modifiedTime` | string | Last modification time |
|
||||
| ↳ `modifiedByMeTime` | string | When modified by current user |
|
||||
| ↳ `viewedByMeTime` | string | When last viewed by current user |
|
||||
| ↳ `sharedWithMeTime` | string | When shared with current user |
|
||||
| ↳ `lastModifyingUser` | json | User who last modified the folder |
|
||||
| ↳ `viewedByMe` | boolean | Whether viewed by current user |
|
||||
| ↳ `modifiedByMe` | boolean | Whether modified by current user |
|
||||
| ↳ `webViewLink` | string | URL to view in browser |
|
||||
| ↳ `iconLink` | string | URL to folder icon |
|
||||
| ↳ `parents` | json | Parent folder IDs |
|
||||
| ↳ `spaces` | json | Spaces containing folder |
|
||||
| ↳ `driveId` | string | Shared drive ID |
|
||||
| ↳ `capabilities` | json | User capabilities on folder |
|
||||
| ↳ `version` | string | Version number |
|
||||
| ↳ `isAppAuthorized` | boolean | Whether created by requesting app |
|
||||
| ↳ `contentRestrictions` | json | Content restrictions |
|
||||
| ↳ `linkShareMetadata` | json | Link share metadata |
|
||||
|
||||
### `google_drive_upload`
|
||||
|
||||
Upload a file to Google Drive with complete metadata returned
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `fileName` | string | Yes | The name of the file to upload |
|
||||
| `file` | file | No | Binary file to upload \(UserFile object\) |
|
||||
| `content` | string | No | Text content to upload \(use this OR file, not both\) |
|
||||
| `mimeType` | string | No | The MIME type of the file to upload \(auto-detected from file if not provided\) |
|
||||
| `folderSelector` | string | No | Select the folder to upload the file to |
|
||||
| `folderId` | string | No | The ID of the folder to upload the file to \(internal use\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `file` | object | Complete uploaded file metadata from Google Drive |
|
||||
| ↳ `id` | string | Google Drive file ID |
|
||||
| ↳ `kind` | string | Resource type identifier |
|
||||
| ↳ `name` | string | File name |
|
||||
| ↳ `mimeType` | string | MIME type |
|
||||
| ↳ `description` | string | File description |
|
||||
| ↳ `originalFilename` | string | Original uploaded filename |
|
||||
| ↳ `fullFileExtension` | string | Full file extension |
|
||||
| ↳ `fileExtension` | string | File extension |
|
||||
| ↳ `owners` | json | List of file owners |
|
||||
| ↳ `permissions` | json | File permissions |
|
||||
| ↳ `permissionIds` | json | Permission IDs |
|
||||
| ↳ `shared` | boolean | Whether file is shared |
|
||||
| ↳ `ownedByMe` | boolean | Whether owned by current user |
|
||||
| ↳ `writersCanShare` | boolean | Whether writers can share |
|
||||
| ↳ `viewersCanCopyContent` | boolean | Whether viewers can copy |
|
||||
| ↳ `copyRequiresWriterPermission` | boolean | Whether copy requires writer permission |
|
||||
| ↳ `sharingUser` | json | User who shared the file |
|
||||
| ↳ `starred` | boolean | Whether file is starred |
|
||||
| ↳ `trashed` | boolean | Whether file is in trash |
|
||||
| ↳ `explicitlyTrashed` | boolean | Whether explicitly trashed |
|
||||
| ↳ `appProperties` | json | App-specific properties |
|
||||
| ↳ `createdTime` | string | File creation time |
|
||||
| ↳ `modifiedTime` | string | Last modification time |
|
||||
| ↳ `modifiedByMeTime` | string | When modified by current user |
|
||||
| ↳ `viewedByMeTime` | string | When last viewed by current user |
|
||||
| ↳ `sharedWithMeTime` | string | When shared with current user |
|
||||
| ↳ `lastModifyingUser` | json | User who last modified the file |
|
||||
| ↳ `viewedByMe` | boolean | Whether viewed by current user |
|
||||
| ↳ `modifiedByMe` | boolean | Whether modified by current user |
|
||||
| ↳ `webViewLink` | string | URL to view in browser |
|
||||
| ↳ `webContentLink` | string | Direct download URL |
|
||||
| ↳ `iconLink` | string | URL to file icon |
|
||||
| ↳ `thumbnailLink` | string | URL to thumbnail |
|
||||
| ↳ `exportLinks` | json | Export format links |
|
||||
| ↳ `size` | string | File size in bytes |
|
||||
| ↳ `quotaBytesUsed` | string | Storage quota used |
|
||||
| ↳ `md5Checksum` | string | MD5 hash |
|
||||
| ↳ `sha1Checksum` | string | SHA-1 hash |
|
||||
| ↳ `sha256Checksum` | string | SHA-256 hash |
|
||||
| ↳ `parents` | json | Parent folder IDs |
|
||||
| ↳ `spaces` | json | Spaces containing file |
|
||||
| ↳ `driveId` | string | Shared drive ID |
|
||||
| ↳ `capabilities` | json | User capabilities on file |
|
||||
| ↳ `version` | string | Version number |
|
||||
| ↳ `headRevisionId` | string | Head revision ID |
|
||||
| ↳ `hasThumbnail` | boolean | Whether has thumbnail |
|
||||
| ↳ `thumbnailVersion` | string | Thumbnail version |
|
||||
| ↳ `imageMediaMetadata` | json | Image-specific metadata |
|
||||
| ↳ `videoMediaMetadata` | json | Video-specific metadata |
|
||||
| ↳ `isAppAuthorized` | boolean | Whether created by requesting app |
|
||||
| ↳ `contentRestrictions` | json | Content restrictions |
|
||||
| ↳ `linkShareMetadata` | json | Link share metadata |
|
||||
|
||||
### `google_drive_download`
|
||||
|
||||
Download a file from Google Drive with complete metadata (exports Google Workspace files automatically)
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `fileId` | string | Yes | The ID of the file to download |
|
||||
| `mimeType` | string | No | The MIME type to export Google Workspace files to \(optional\) |
|
||||
| `fileName` | string | No | Optional filename override |
|
||||
| `includeRevisions` | boolean | No | Whether to include revision history in the metadata \(default: true, returns first 100 revisions\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `file` | object | Downloaded file data |
|
||||
| ↳ `name` | string | File name |
|
||||
| ↳ `mimeType` | string | MIME type of the file |
|
||||
| ↳ `data` | string | File content as base64-encoded string |
|
||||
| ↳ `size` | number | File size in bytes |
|
||||
| `metadata` | object | Complete file metadata from Google Drive |
|
||||
| ↳ `id` | string | Google Drive file ID |
|
||||
| ↳ `kind` | string | Resource type identifier |
|
||||
| ↳ `name` | string | File name |
|
||||
| ↳ `mimeType` | string | MIME type |
|
||||
| ↳ `description` | string | File description |
|
||||
| ↳ `originalFilename` | string | Original uploaded filename |
|
||||
| ↳ `fullFileExtension` | string | Full file extension |
|
||||
| ↳ `fileExtension` | string | File extension |
|
||||
| ↳ `owners` | json | List of file owners |
|
||||
| ↳ `permissions` | json | File permissions |
|
||||
| ↳ `permissionIds` | json | Permission IDs |
|
||||
| ↳ `shared` | boolean | Whether file is shared |
|
||||
| ↳ `ownedByMe` | boolean | Whether owned by current user |
|
||||
| ↳ `writersCanShare` | boolean | Whether writers can share |
|
||||
| ↳ `viewersCanCopyContent` | boolean | Whether viewers can copy |
|
||||
| ↳ `copyRequiresWriterPermission` | boolean | Whether copy requires writer permission |
|
||||
| ↳ `sharingUser` | json | User who shared the file |
|
||||
| ↳ `starred` | boolean | Whether file is starred |
|
||||
| ↳ `trashed` | boolean | Whether file is in trash |
|
||||
| ↳ `explicitlyTrashed` | boolean | Whether explicitly trashed |
|
||||
| ↳ `appProperties` | json | App-specific properties |
|
||||
| ↳ `createdTime` | string | File creation time |
|
||||
| ↳ `modifiedTime` | string | Last modification time |
|
||||
| ↳ `modifiedByMeTime` | string | When modified by current user |
|
||||
| ↳ `viewedByMeTime` | string | When last viewed by current user |
|
||||
| ↳ `sharedWithMeTime` | string | When shared with current user |
|
||||
| ↳ `lastModifyingUser` | json | User who last modified the file |
|
||||
| ↳ `viewedByMe` | boolean | Whether viewed by current user |
|
||||
| ↳ `modifiedByMe` | boolean | Whether modified by current user |
|
||||
| ↳ `webViewLink` | string | URL to view in browser |
|
||||
| ↳ `webContentLink` | string | Direct download URL |
|
||||
| ↳ `iconLink` | string | URL to file icon |
|
||||
| ↳ `thumbnailLink` | string | URL to thumbnail |
|
||||
| ↳ `exportLinks` | json | Export format links |
|
||||
| ↳ `size` | string | File size in bytes |
|
||||
| ↳ `quotaBytesUsed` | string | Storage quota used |
|
||||
| ↳ `md5Checksum` | string | MD5 hash |
|
||||
| ↳ `sha1Checksum` | string | SHA-1 hash |
|
||||
| ↳ `sha256Checksum` | string | SHA-256 hash |
|
||||
| ↳ `parents` | json | Parent folder IDs |
|
||||
| ↳ `spaces` | json | Spaces containing file |
|
||||
| ↳ `driveId` | string | Shared drive ID |
|
||||
| ↳ `capabilities` | json | User capabilities on file |
|
||||
| ↳ `version` | string | Version number |
|
||||
| ↳ `headRevisionId` | string | Head revision ID |
|
||||
| ↳ `hasThumbnail` | boolean | Whether has thumbnail |
|
||||
| ↳ `thumbnailVersion` | string | Thumbnail version |
|
||||
| ↳ `imageMediaMetadata` | json | Image-specific metadata |
|
||||
| ↳ `videoMediaMetadata` | json | Video-specific metadata |
|
||||
| ↳ `isAppAuthorized` | boolean | Whether created by requesting app |
|
||||
| ↳ `contentRestrictions` | json | Content restrictions |
|
||||
| ↳ `linkShareMetadata` | json | Link share metadata |
|
||||
| ↳ `revisions` | json | File revision history \(first 100 revisions only\) |
|
||||
|
||||
### `google_drive_copy`
|
||||
|
||||
Create a copy of a file in Google Drive
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `fileId` | string | Yes | The ID of the file to copy |
|
||||
| `newName` | string | No | Name for the copied file \(defaults to "Copy of \[original name\]"\) |
|
||||
| `destinationFolderId` | string | No | ID of the folder to place the copy in \(defaults to same location as original\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `file` | json | The copied file metadata |
|
||||
| ↳ `id` | string | Google Drive file ID of the copy |
|
||||
| ↳ `kind` | string | Resource type identifier |
|
||||
| ↳ `name` | string | File name |
|
||||
| ↳ `mimeType` | string | MIME type |
|
||||
| ↳ `webViewLink` | string | URL to view in browser |
|
||||
| ↳ `parents` | json | Parent folder IDs |
|
||||
| ↳ `createdTime` | string | File creation time |
|
||||
| ↳ `modifiedTime` | string | Last modification time |
|
||||
| ↳ `owners` | json | List of file owners |
|
||||
| ↳ `size` | string | File size in bytes |
|
||||
|
||||
### `google_drive_update`
|
||||
|
||||
Update file metadata in Google Drive (rename, move, star, add description)
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `fileId` | string | Yes | The ID of the file to update |
|
||||
| `name` | string | No | New name for the file |
|
||||
| `description` | string | No | New description for the file |
|
||||
| `addParents` | string | No | Comma-separated list of parent folder IDs to add \(moves file to these folders\) |
|
||||
| `removeParents` | string | No | Comma-separated list of parent folder IDs to remove |
|
||||
| `starred` | boolean | No | Whether to star or unstar the file |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `file` | json | The updated file metadata |
|
||||
| ↳ `id` | string | Google Drive file ID |
|
||||
| ↳ `kind` | string | Resource type identifier |
|
||||
| ↳ `name` | string | File name |
|
||||
| ↳ `mimeType` | string | MIME type |
|
||||
| ↳ `description` | string | File description |
|
||||
| ↳ `starred` | boolean | Whether file is starred |
|
||||
| ↳ `webViewLink` | string | URL to view in browser |
|
||||
| ↳ `parents` | json | Parent folder IDs |
|
||||
| ↳ `modifiedTime` | string | Last modification time |
|
||||
|
||||
### `google_drive_trash`
|
||||
|
||||
Move a file to the trash in Google Drive (can be restored later)
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `fileId` | string | Yes | The ID of the file to move to trash |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `file` | json | The trashed file metadata |
|
||||
| ↳ `id` | string | Google Drive file ID |
|
||||
| ↳ `kind` | string | Resource type identifier |
|
||||
| ↳ `name` | string | File name |
|
||||
| ↳ `mimeType` | string | MIME type |
|
||||
| ↳ `trashed` | boolean | Whether file is in trash \(should be true\) |
|
||||
| ↳ `trashedTime` | string | When file was trashed |
|
||||
| ↳ `webViewLink` | string | URL to view in browser |
|
||||
|
||||
### `google_drive_delete`
|
||||
|
||||
Permanently delete a file from Google Drive (bypasses trash)
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `fileId` | string | Yes | The ID of the file to permanently delete |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `deleted` | boolean | Whether the file was successfully deleted |
|
||||
| `fileId` | string | The ID of the deleted file |
|
||||
|
||||
### `google_drive_share`
|
||||
|
||||
Share a file with a user, group, domain, or make it public
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `fileId` | string | Yes | The ID of the file to share |
|
||||
| `type` | string | Yes | Type of grantee: user, group, domain, or anyone |
|
||||
| `role` | string | Yes | Permission role: owner \(transfer ownership\), organizer \(shared drive only\), fileOrganizer \(shared drive only\), writer \(edit\), commenter \(view and comment\), reader \(view only\) |
|
||||
| `email` | string | No | Email address of the user or group \(required for type=user or type=group\) |
|
||||
| `domain` | string | No | Domain to share with \(required for type=domain\) |
|
||||
| `transferOwnership` | boolean | No | Required when role is owner. Transfers ownership to the specified user. |
|
||||
| `moveToNewOwnersRoot` | boolean | No | When transferring ownership, move the file to the new owner's My Drive root folder. |
|
||||
| `sendNotification` | boolean | No | Whether to send an email notification \(default: true\) |
|
||||
| `emailMessage` | string | No | Custom message to include in the notification email |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `permission` | json | The created permission details |
|
||||
| ↳ `id` | string | Permission ID |
|
||||
| ↳ `type` | string | Grantee type \(user, group, domain, anyone\) |
|
||||
| ↳ `role` | string | Permission role |
|
||||
| ↳ `emailAddress` | string | Email of the grantee |
|
||||
| ↳ `displayName` | string | Display name of the grantee |
|
||||
| ↳ `domain` | string | Domain of the grantee |
|
||||
| ↳ `expirationTime` | string | Expiration time |
|
||||
| ↳ `deleted` | boolean | Whether grantee is deleted |
|
||||
|
||||
### `google_drive_unshare`
|
||||
|
||||
Remove a permission from a file (revoke access)
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `fileId` | string | Yes | The ID of the file to modify permissions on |
|
||||
| `permissionId` | string | Yes | The ID of the permission to remove \(use list_permissions to find this\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `removed` | boolean | Whether the permission was successfully removed |
|
||||
| `fileId` | string | The ID of the file |
|
||||
| `permissionId` | string | The ID of the removed permission |
|
||||
|
||||
### `google_drive_list_permissions`
|
||||
|
||||
List all permissions (who has access) for a file in Google Drive
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `fileId` | string | Yes | The ID of the file to list permissions for |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `permissions` | array | List of permissions on the file |
|
||||
| ↳ `id` | string | Permission ID \(use to remove permission\) |
|
||||
| ↳ `type` | string | Grantee type \(user, group, domain, anyone\) |
|
||||
| ↳ `role` | string | Permission role \(owner, organizer, fileOrganizer, writer, commenter, reader\) |
|
||||
| ↳ `emailAddress` | string | Email of the grantee |
|
||||
| ↳ `displayName` | string | Display name of the grantee |
|
||||
| ↳ `photoLink` | string | Photo URL of the grantee |
|
||||
| ↳ `domain` | string | Domain of the grantee |
|
||||
| ↳ `expirationTime` | string | When permission expires |
|
||||
| ↳ `deleted` | boolean | Whether grantee account is deleted |
|
||||
| ↳ `allowFileDiscovery` | boolean | Whether file is discoverable by grantee |
|
||||
| ↳ `pendingOwner` | boolean | Whether ownership transfer is pending |
|
||||
| ↳ `permissionDetails` | json | Details about inherited permissions |
|
||||
| `nextPageToken` | string | Token for fetching the next page of permissions |
|
||||
|
||||
### `google_drive_get_about`
|
||||
|
||||
Get information about the user and their Google Drive (storage quota, capabilities)
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `user` | json | Information about the authenticated user |
|
||||
| ↳ `displayName` | string | User display name |
|
||||
| ↳ `emailAddress` | string | User email address |
|
||||
| ↳ `photoLink` | string | URL to user profile photo |
|
||||
| ↳ `permissionId` | string | User permission ID |
|
||||
| ↳ `me` | boolean | Whether this is the authenticated user |
|
||||
| `storageQuota` | json | Storage quota information in bytes |
|
||||
| ↳ `limit` | string | Total storage limit in bytes \(null for unlimited\) |
|
||||
| ↳ `usage` | string | Total storage used in bytes |
|
||||
| ↳ `usageInDrive` | string | Storage used by Drive files in bytes |
|
||||
| ↳ `usageInDriveTrash` | string | Storage used by trashed files in bytes |
|
||||
| `canCreateDrives` | boolean | Whether user can create shared drives |
|
||||
| `importFormats` | json | Map of MIME types that can be imported and their target formats |
|
||||
| `exportFormats` | json | Map of Google Workspace MIME types and their exportable formats |
|
||||
| `maxUploadSize` | string | Maximum upload size in bytes |
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: Google Forms
|
||||
description: Read responses from a Google Form
|
||||
description: Manage Google Forms and responses
|
||||
---
|
||||
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
@@ -29,7 +29,7 @@ In Sim, the Google Forms integration enables your agents to programmatically acc
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Integrate Google Forms into your workflow. Provide a Form ID to list responses, or specify a Response ID to fetch a single response. Requires OAuth.
|
||||
Integrate Google Forms into your workflow. Read form structure, get responses, create forms, update content, and manage notification watches.
|
||||
|
||||
|
||||
|
||||
@@ -37,15 +37,246 @@ Integrate Google Forms into your workflow. Provide a Form ID to list responses,
|
||||
|
||||
### `google_forms_get_responses`
|
||||
|
||||
Retrieve a single response or list responses from a Google Form
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `formId` | string | Yes | The ID of the Google Form |
|
||||
| `responseId` | string | No | If provided, returns this specific response |
|
||||
| `pageSize` | number | No | Maximum number of responses to return \(service may return fewer\). Defaults to 5000. |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `data` | json | Response or list of responses |
|
||||
| `responses` | array | Array of form responses \(when no responseId provided\) |
|
||||
| ↳ `responseId` | string | Unique response ID |
|
||||
| ↳ `createTime` | string | When the response was created |
|
||||
| ↳ `lastSubmittedTime` | string | When the response was last submitted |
|
||||
| ↳ `answers` | json | Map of question IDs to answer values |
|
||||
| `response` | object | Single form response \(when responseId is provided\) |
|
||||
| ↳ `responseId` | string | Unique response ID |
|
||||
| ↳ `createTime` | string | When the response was created |
|
||||
| ↳ `lastSubmittedTime` | string | When the response was last submitted |
|
||||
| ↳ `answers` | json | Map of question IDs to answer values |
|
||||
| `raw` | json | Raw API response data |
|
||||
|
||||
### `google_forms_get_form`
|
||||
|
||||
Retrieve a form structure including its items, settings, and metadata
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `formId` | string | Yes | The ID of the Google Form to retrieve |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `formId` | string | The form ID |
|
||||
| `title` | string | The form title visible to responders |
|
||||
| `description` | string | The form description |
|
||||
| `documentTitle` | string | The document title visible in Drive |
|
||||
| `responderUri` | string | The URI to share with responders |
|
||||
| `linkedSheetId` | string | The ID of the linked Google Sheet |
|
||||
| `revisionId` | string | The revision ID of the form |
|
||||
| `items` | array | The form items \(questions, sections, etc.\) |
|
||||
| ↳ `itemId` | string | Item ID |
|
||||
| ↳ `title` | string | Item title |
|
||||
| ↳ `description` | string | Item description |
|
||||
| `settings` | json | Form settings |
|
||||
| `publishSettings` | json | Form publish settings |
|
||||
|
||||
### `google_forms_create_form`
|
||||
|
||||
Create a new Google Form with a title
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `title` | string | Yes | The title of the form visible to responders |
|
||||
| `documentTitle` | string | No | The document title visible in Drive \(defaults to form title\) |
|
||||
| `unpublished` | boolean | No | If true, create an unpublished form that does not accept responses |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `formId` | string | The ID of the created form |
|
||||
| `title` | string | The form title |
|
||||
| `documentTitle` | string | The document title in Drive |
|
||||
| `responderUri` | string | The URI to share with responders |
|
||||
| `revisionId` | string | The revision ID of the form |
|
||||
|
||||
### `google_forms_batch_update`
|
||||
|
||||
Apply multiple updates to a form (add items, update info, change settings, etc.)
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `formId` | string | Yes | The ID of the Google Form to update |
|
||||
| `requests` | json | Yes | Array of update requests \(updateFormInfo, updateSettings, createItem, updateItem, moveItem, deleteItem\) |
|
||||
| `includeFormInResponse` | boolean | No | Whether to return the updated form in the response |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `replies` | array | The replies from each update request |
|
||||
| `writeControl` | object | Write control information with revision IDs |
|
||||
| ↳ `requiredRevisionId` | string | Required revision ID for conflict detection |
|
||||
| ↳ `targetRevisionId` | string | Target revision ID |
|
||||
| `form` | object | The updated form \(if includeFormInResponse was true\) |
|
||||
| ↳ `formId` | string | The form ID |
|
||||
| ↳ `info` | object | Form info containing title and description |
|
||||
| ↳ `title` | string | The form title visible to responders |
|
||||
| ↳ `description` | string | The form description |
|
||||
| ↳ `documentTitle` | string | The document title visible in Drive |
|
||||
| ↳ `title` | string | Item title |
|
||||
| ↳ `description` | string | Item description |
|
||||
| ↳ `documentTitle` | string | The document title visible in Drive |
|
||||
| ↳ `settings` | object | Form settings |
|
||||
| ↳ `quizSettings` | object | Quiz settings |
|
||||
| ↳ `isQuiz` | boolean | Whether the form is a quiz |
|
||||
| ↳ `isQuiz` | boolean | Whether the form is a quiz |
|
||||
| ↳ `emailCollectionType` | string | Email collection type |
|
||||
| ↳ `quizSettings` | object | Quiz settings |
|
||||
| ↳ `isQuiz` | boolean | Whether the form is a quiz |
|
||||
| ↳ `isQuiz` | boolean | Whether the form is a quiz |
|
||||
| ↳ `emailCollectionType` | string | Email collection type |
|
||||
| ↳ `itemId` | string | Item ID |
|
||||
| ↳ `questionItem` | json | Question item configuration |
|
||||
| ↳ `questionGroupItem` | json | Question group configuration |
|
||||
| ↳ `pageBreakItem` | json | Page break configuration |
|
||||
| ↳ `textItem` | json | Text item configuration |
|
||||
| ↳ `imageItem` | json | Image item configuration |
|
||||
| ↳ `videoItem` | json | Video item configuration |
|
||||
| ↳ `revisionId` | string | The revision ID of the form |
|
||||
| ↳ `responderUri` | string | The URI to share with responders |
|
||||
| ↳ `linkedSheetId` | string | The ID of the linked Google Sheet |
|
||||
| ↳ `publishSettings` | object | Form publish settings |
|
||||
| ↳ `publishState` | object | Current publish state |
|
||||
| ↳ `isPublished` | boolean | Whether the form is published |
|
||||
| ↳ `isAcceptingResponses` | boolean | Whether the form is accepting responses |
|
||||
| ↳ `isPublished` | boolean | Whether the form is published |
|
||||
| ↳ `isAcceptingResponses` | boolean | Whether the form is accepting responses |
|
||||
| ↳ `publishState` | object | Current publish state |
|
||||
| ↳ `isPublished` | boolean | Whether the form is published |
|
||||
| ↳ `isAcceptingResponses` | boolean | Whether the form is accepting responses |
|
||||
| ↳ `isPublished` | boolean | Whether the form is published |
|
||||
| ↳ `isAcceptingResponses` | boolean | Whether the form is accepting responses |
|
||||
|
||||
### `google_forms_set_publish_settings`
|
||||
|
||||
Update the publish settings of a form (publish/unpublish, accept responses)
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `formId` | string | Yes | The ID of the Google Form |
|
||||
| `isPublished` | boolean | Yes | Whether the form is published and visible to others |
|
||||
| `isAcceptingResponses` | boolean | No | Whether the form accepts responses \(forced to false if isPublished is false\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `formId` | string | The form ID |
|
||||
| `publishSettings` | json | The updated publish settings |
|
||||
| ↳ `publishState` | object | The publish state |
|
||||
| ↳ `isPublished` | boolean | Whether the form is published |
|
||||
| ↳ `isAcceptingResponses` | boolean | Whether the form accepts responses |
|
||||
| ↳ `isPublished` | boolean | Whether the form is published |
|
||||
| ↳ `isAcceptingResponses` | boolean | Whether the form accepts responses |
|
||||
|
||||
### `google_forms_create_watch`
|
||||
|
||||
Create a notification watch for form changes (schema changes or new responses)
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `formId` | string | Yes | The ID of the Google Form to watch |
|
||||
| `eventType` | string | Yes | Event type to watch: SCHEMA \(form changes\) or RESPONSES \(new submissions\) |
|
||||
| `topicName` | string | Yes | The Cloud Pub/Sub topic name \(format: projects/\{project\}/topics/\{topic\}\) |
|
||||
| `watchId` | string | No | Custom watch ID \(4-63 chars, lowercase letters, numbers, hyphens\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `id` | string | The watch ID |
|
||||
| `eventType` | string | The event type being watched |
|
||||
| `topicName` | string | The Cloud Pub/Sub topic |
|
||||
| `createTime` | string | When the watch was created |
|
||||
| `expireTime` | string | When the watch expires \(7 days after creation\) |
|
||||
| `state` | string | The watch state \(ACTIVE, SUSPENDED\) |
|
||||
|
||||
### `google_forms_list_watches`
|
||||
|
||||
List all notification watches for a form
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `formId` | string | Yes | The ID of the Google Form |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `watches` | array | List of watches for the form |
|
||||
| ↳ `id` | string | Watch ID |
|
||||
| ↳ `eventType` | string | Event type \(SCHEMA or RESPONSES\) |
|
||||
| ↳ `createTime` | string | When the watch was created |
|
||||
| ↳ `expireTime` | string | When the watch expires |
|
||||
| ↳ `state` | string | Watch state |
|
||||
|
||||
### `google_forms_delete_watch`
|
||||
|
||||
Delete a notification watch from a form
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `formId` | string | Yes | The ID of the Google Form |
|
||||
| `watchId` | string | Yes | The ID of the watch to delete |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `deleted` | boolean | Whether the watch was successfully deleted |
|
||||
|
||||
### `google_forms_renew_watch`
|
||||
|
||||
Renew a notification watch for another 7 days
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `formId` | string | Yes | The ID of the Google Form |
|
||||
| `watchId` | string | Yes | The ID of the watch to renew |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `id` | string | The watch ID |
|
||||
| `eventType` | string | The event type being watched |
|
||||
| `expireTime` | string | The new expiration time |
|
||||
| `state` | string | The watch state |
|
||||
|
||||
|
||||
|
||||
@@ -215,4 +215,191 @@ Check if a user is a member of a Google Group
|
||||
| --------- | ---- | ----------- |
|
||||
| `isMember` | boolean | Whether the user is a member of the group |
|
||||
|
||||
### `google_groups_list_aliases`
|
||||
|
||||
List all email aliases for a Google Group
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `groupKey` | string | Yes | Group email address or unique group ID |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `aliases` | array | List of email aliases for the group |
|
||||
| ↳ `id` | string | Unique group identifier |
|
||||
| ↳ `primaryEmail` | string | Group |
|
||||
| ↳ `alias` | string | Alias email address |
|
||||
| ↳ `kind` | string | API resource type |
|
||||
| ↳ `etag` | string | Resource version identifier |
|
||||
|
||||
### `google_groups_add_alias`
|
||||
|
||||
Add an email alias to a Google Group
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `groupKey` | string | Yes | Group email address or unique group ID |
|
||||
| `alias` | string | Yes | The email alias to add to the group |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `id` | string | Unique group identifier |
|
||||
| `primaryEmail` | string | Group |
|
||||
| `alias` | string | The alias that was added |
|
||||
| `kind` | string | API resource type |
|
||||
| `etag` | string | Resource version identifier |
|
||||
|
||||
### `google_groups_remove_alias`
|
||||
|
||||
Remove an email alias from a Google Group
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `groupKey` | string | Yes | Group email address or unique group ID |
|
||||
| `alias` | string | Yes | The email alias to remove from the group |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `deleted` | boolean | Whether the alias was successfully deleted |
|
||||
|
||||
### `google_groups_get_settings`
|
||||
|
||||
Get the settings for a Google Group including access permissions, moderation, and posting options
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `groupEmail` | string | Yes | The email address of the group |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `email` | string | The group |
|
||||
| `name` | string | The group name \(max 75 characters\) |
|
||||
| `description` | string | The group description \(max 4096 characters\) |
|
||||
| `whoCanJoin` | string | Who can join the group \(ANYONE_CAN_JOIN, ALL_IN_DOMAIN_CAN_JOIN, INVITED_CAN_JOIN, CAN_REQUEST_TO_JOIN\) |
|
||||
| `whoCanViewMembership` | string | Who can view group membership |
|
||||
| `whoCanViewGroup` | string | Who can view group messages |
|
||||
| `whoCanPostMessage` | string | Who can post messages to the group |
|
||||
| `allowExternalMembers` | string | Whether external users can be members |
|
||||
| `allowWebPosting` | string | Whether web posting is allowed |
|
||||
| `primaryLanguage` | string | The group |
|
||||
| `isArchived` | string | Whether messages are archived |
|
||||
| `archiveOnly` | string | Whether the group is archive-only \(inactive\) |
|
||||
| `messageModerationLevel` | string | Message moderation level |
|
||||
| `spamModerationLevel` | string | Spam handling level \(ALLOW, MODERATE, SILENTLY_MODERATE, REJECT\) |
|
||||
| `replyTo` | string | Default reply destination |
|
||||
| `customReplyTo` | string | Custom email for replies |
|
||||
| `includeCustomFooter` | string | Whether to include custom footer |
|
||||
| `customFooterText` | string | Custom footer text \(max 1000 characters\) |
|
||||
| `sendMessageDenyNotification` | string | Whether to send rejection notifications |
|
||||
| `defaultMessageDenyNotificationText` | string | Default rejection message text |
|
||||
| `membersCanPostAsTheGroup` | string | Whether members can post as the group |
|
||||
| `includeInGlobalAddressList` | string | Whether included in Global Address List |
|
||||
| `whoCanLeaveGroup` | string | Who can leave the group |
|
||||
| `whoCanContactOwner` | string | Who can contact the group owner |
|
||||
| `favoriteRepliesOnTop` | string | Whether favorite replies appear at top |
|
||||
| `whoCanApproveMembers` | string | Who can approve new members |
|
||||
| `whoCanBanUsers` | string | Who can ban users |
|
||||
| `whoCanModerateMembers` | string | Who can manage members |
|
||||
| `whoCanModerateContent` | string | Who can moderate content |
|
||||
| `whoCanAssistContent` | string | Who can assist with content metadata |
|
||||
| `enableCollaborativeInbox` | string | Whether collaborative inbox is enabled |
|
||||
| `whoCanDiscoverGroup` | string | Who can discover the group |
|
||||
| `defaultSender` | string | Default sender identity \(DEFAULT_SELF or GROUP\) |
|
||||
|
||||
### `google_groups_update_settings`
|
||||
|
||||
Update the settings for a Google Group including access permissions, moderation, and posting options
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `groupEmail` | string | Yes | The email address of the group |
|
||||
| `name` | string | No | The group name \(max 75 characters\) |
|
||||
| `description` | string | No | The group description \(max 4096 characters\) |
|
||||
| `whoCanJoin` | string | No | Who can join: ANYONE_CAN_JOIN, ALL_IN_DOMAIN_CAN_JOIN, INVITED_CAN_JOIN, CAN_REQUEST_TO_JOIN |
|
||||
| `whoCanViewMembership` | string | No | Who can view membership: ALL_IN_DOMAIN_CAN_VIEW, ALL_MEMBERS_CAN_VIEW, ALL_MANAGERS_CAN_VIEW |
|
||||
| `whoCanViewGroup` | string | No | Who can view group messages: ANYONE_CAN_VIEW, ALL_IN_DOMAIN_CAN_VIEW, ALL_MEMBERS_CAN_VIEW, ALL_MANAGERS_CAN_VIEW |
|
||||
| `whoCanPostMessage` | string | No | Who can post: NONE_CAN_POST, ALL_MANAGERS_CAN_POST, ALL_MEMBERS_CAN_POST, ALL_OWNERS_CAN_POST, ALL_IN_DOMAIN_CAN_POST, ANYONE_CAN_POST |
|
||||
| `allowExternalMembers` | string | No | Whether external users can be members: true or false |
|
||||
| `allowWebPosting` | string | No | Whether web posting is allowed: true or false |
|
||||
| `primaryLanguage` | string | No | The group's primary language \(e.g., en\) |
|
||||
| `isArchived` | string | No | Whether messages are archived: true or false |
|
||||
| `archiveOnly` | string | No | Whether the group is archive-only \(inactive\): true or false |
|
||||
| `messageModerationLevel` | string | No | Message moderation: MODERATE_ALL_MESSAGES, MODERATE_NON_MEMBERS, MODERATE_NEW_MEMBERS, MODERATE_NONE |
|
||||
| `spamModerationLevel` | string | No | Spam handling: ALLOW, MODERATE, SILENTLY_MODERATE, REJECT |
|
||||
| `replyTo` | string | No | Default reply: REPLY_TO_CUSTOM, REPLY_TO_SENDER, REPLY_TO_LIST, REPLY_TO_OWNER, REPLY_TO_IGNORE, REPLY_TO_MANAGERS |
|
||||
| `customReplyTo` | string | No | Custom email for replies \(when replyTo is REPLY_TO_CUSTOM\) |
|
||||
| `includeCustomFooter` | string | No | Whether to include custom footer: true or false |
|
||||
| `customFooterText` | string | No | Custom footer text \(max 1000 characters\) |
|
||||
| `sendMessageDenyNotification` | string | No | Whether to send rejection notifications: true or false |
|
||||
| `defaultMessageDenyNotificationText` | string | No | Default rejection message text |
|
||||
| `membersCanPostAsTheGroup` | string | No | Whether members can post as the group: true or false |
|
||||
| `includeInGlobalAddressList` | string | No | Whether included in Global Address List: true or false |
|
||||
| `whoCanLeaveGroup` | string | No | Who can leave: ALL_MANAGERS_CAN_LEAVE, ALL_MEMBERS_CAN_LEAVE, NONE_CAN_LEAVE |
|
||||
| `whoCanContactOwner` | string | No | Who can contact owner: ALL_IN_DOMAIN_CAN_CONTACT, ALL_MANAGERS_CAN_CONTACT, ALL_MEMBERS_CAN_CONTACT, ANYONE_CAN_CONTACT |
|
||||
| `favoriteRepliesOnTop` | string | No | Whether favorite replies appear at top: true or false |
|
||||
| `whoCanApproveMembers` | string | No | Who can approve members: ALL_OWNERS_CAN_APPROVE, ALL_MANAGERS_CAN_APPROVE, ALL_MEMBERS_CAN_APPROVE, NONE_CAN_APPROVE |
|
||||
| `whoCanBanUsers` | string | No | Who can ban users: OWNERS_ONLY, OWNERS_AND_MANAGERS, NONE |
|
||||
| `whoCanModerateMembers` | string | No | Who can manage members: OWNERS_ONLY, OWNERS_AND_MANAGERS, ALL_MEMBERS, NONE |
|
||||
| `whoCanModerateContent` | string | No | Who can moderate content: OWNERS_ONLY, OWNERS_AND_MANAGERS, ALL_MEMBERS, NONE |
|
||||
| `whoCanAssistContent` | string | No | Who can assist with content metadata: OWNERS_ONLY, OWNERS_AND_MANAGERS, ALL_MEMBERS, NONE |
|
||||
| `enableCollaborativeInbox` | string | No | Whether collaborative inbox is enabled: true or false |
|
||||
| `whoCanDiscoverGroup` | string | No | Who can discover: ANYONE_CAN_DISCOVER, ALL_IN_DOMAIN_CAN_DISCOVER, ALL_MEMBERS_CAN_DISCOVER |
|
||||
| `defaultSender` | string | No | Default sender: DEFAULT_SELF or GROUP |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `email` | string | The group |
|
||||
| `name` | string | The group name |
|
||||
| `description` | string | The group description |
|
||||
| `whoCanJoin` | string | Who can join the group |
|
||||
| `whoCanViewMembership` | string | Who can view group membership |
|
||||
| `whoCanViewGroup` | string | Who can view group messages |
|
||||
| `whoCanPostMessage` | string | Who can post messages to the group |
|
||||
| `allowExternalMembers` | string | Whether external users can be members |
|
||||
| `allowWebPosting` | string | Whether web posting is allowed |
|
||||
| `primaryLanguage` | string | The group |
|
||||
| `isArchived` | string | Whether messages are archived |
|
||||
| `archiveOnly` | string | Whether the group is archive-only |
|
||||
| `messageModerationLevel` | string | Message moderation level |
|
||||
| `spamModerationLevel` | string | Spam handling level |
|
||||
| `replyTo` | string | Default reply destination |
|
||||
| `customReplyTo` | string | Custom email for replies |
|
||||
| `includeCustomFooter` | string | Whether to include custom footer |
|
||||
| `customFooterText` | string | Custom footer text |
|
||||
| `sendMessageDenyNotification` | string | Whether to send rejection notifications |
|
||||
| `defaultMessageDenyNotificationText` | string | Default rejection message text |
|
||||
| `membersCanPostAsTheGroup` | string | Whether members can post as the group |
|
||||
| `includeInGlobalAddressList` | string | Whether included in Global Address List |
|
||||
| `whoCanLeaveGroup` | string | Who can leave the group |
|
||||
| `whoCanContactOwner` | string | Who can contact the group owner |
|
||||
| `favoriteRepliesOnTop` | string | Whether favorite replies appear at top |
|
||||
| `whoCanApproveMembers` | string | Who can approve new members |
|
||||
| `whoCanBanUsers` | string | Who can ban users |
|
||||
| `whoCanModerateMembers` | string | Who can manage members |
|
||||
| `whoCanModerateContent` | string | Who can moderate content |
|
||||
| `whoCanAssistContent` | string | Who can assist with content metadata |
|
||||
| `enableCollaborativeInbox` | string | Whether collaborative inbox is enabled |
|
||||
| `whoCanDiscoverGroup` | string | Who can discover the group |
|
||||
| `defaultSender` | string | Default sender identity |
|
||||
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ In Sim, the Google Sheets integration empowers your agents to automate reading f
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Integrate Google Sheets into the workflow with explicit sheet selection. Can read, write, append, and update data in specific sheets.
|
||||
Integrate Google Sheets into the workflow with explicit sheet selection. Can read, write, append, update, clear data, create spreadsheets, get spreadsheet info, and copy sheets.
|
||||
|
||||
|
||||
|
||||
@@ -42,9 +42,8 @@ Read data from a specific sheet in a Google Sheets spreadsheet
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `spreadsheetId` | string | Yes | The ID of the spreadsheet |
|
||||
| `sheetName` | string | Yes | The name of the sheet/tab to read from |
|
||||
| `cellRange` | string | No | The cell range to read \(e.g. "A1:D10"\). Defaults to "A1:Z1000" if not specified. |
|
||||
| `spreadsheetId` | string | Yes | The ID of the spreadsheet \(found in the URL: docs.google.com/spreadsheets/d/\{SPREADSHEET_ID\}/edit\). |
|
||||
| `range` | string | No | The A1 notation range to read \(e.g. "Sheet1!A1:D10", "A1:B5"\). Defaults to first sheet A1:Z1000 if not specified. |
|
||||
|
||||
#### Output
|
||||
|
||||
@@ -66,8 +65,7 @@ Write data to a specific sheet in a Google Sheets spreadsheet
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `spreadsheetId` | string | Yes | The ID of the spreadsheet |
|
||||
| `sheetName` | string | Yes | The name of the sheet/tab to write to |
|
||||
| `cellRange` | string | No | The cell range to write to \(e.g. "A1:D10", "A1"\). Defaults to "A1" if not specified. |
|
||||
| `range` | string | No | The A1 notation range to write to \(e.g. "Sheet1!A1:D10", "A1:B5"\) |
|
||||
| `values` | array | Yes | The data to write as a 2D array \(e.g. \[\["Name", "Age"\], \["Alice", 30\], \["Bob", 25\]\]\) or array of objects. |
|
||||
| `valueInputOption` | string | No | The format of the data to write |
|
||||
| `includeValuesInResponse` | boolean | No | Whether to include the written values in the response |
|
||||
@@ -93,8 +91,7 @@ Update data in a specific sheet in a Google Sheets spreadsheet
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `spreadsheetId` | string | Yes | The ID of the spreadsheet to update |
|
||||
| `sheetName` | string | Yes | The name of the sheet/tab to update |
|
||||
| `cellRange` | string | No | The cell range to update \(e.g. "A1:D10", "A1"\). Defaults to "A1" if not specified. |
|
||||
| `range` | string | No | The A1 notation range to update \(e.g. "Sheet1!A1:D10", "A1:B5"\) |
|
||||
| `values` | array | Yes | The data to update as a 2D array \(e.g. \[\["Name", "Age"\], \["Alice", 30\]\]\) or array of objects. |
|
||||
| `valueInputOption` | string | No | The format of the data to update |
|
||||
| `includeValuesInResponse` | boolean | No | Whether to include the updated values in the response |
|
||||
@@ -120,7 +117,7 @@ Append data to the end of a specific sheet in a Google Sheets spreadsheet
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `spreadsheetId` | string | Yes | The ID of the spreadsheet to append to |
|
||||
| `sheetName` | string | Yes | The name of the sheet/tab to append to |
|
||||
| `range` | string | No | The A1 notation range to append after \(e.g. "Sheet1", "Sheet1!A:D"\) |
|
||||
| `values` | array | Yes | The data to append as a 2D array \(e.g. \[\["Alice", 30\], \["Bob", 25\]\]\) or array of objects. |
|
||||
| `valueInputOption` | string | No | The format of the data to append |
|
||||
| `insertDataOption` | string | No | How to insert the data \(OVERWRITE or INSERT_ROWS\) |
|
||||
@@ -139,4 +136,180 @@ Append data to the end of a specific sheet in a Google Sheets spreadsheet
|
||||
| ↳ `spreadsheetId` | string | Google Sheets spreadsheet ID |
|
||||
| ↳ `spreadsheetUrl` | string | Spreadsheet URL |
|
||||
|
||||
### `google_sheets_clear`
|
||||
|
||||
Clear values from a specific range in a Google Sheets spreadsheet
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `spreadsheetId` | string | Yes | The ID of the spreadsheet |
|
||||
| `sheetName` | string | Yes | The name of the sheet/tab to clear |
|
||||
| `cellRange` | string | No | The cell range to clear \(e.g. "A1:D10"\). Clears entire sheet if not specified. |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `clearedRange` | string | The range that was cleared |
|
||||
| `sheetName` | string | Name of the sheet that was cleared |
|
||||
| `metadata` | json | Spreadsheet metadata including ID and URL |
|
||||
| ↳ `spreadsheetId` | string | Google Sheets spreadsheet ID |
|
||||
| ↳ `spreadsheetUrl` | string | Spreadsheet URL |
|
||||
|
||||
### `google_sheets_get_spreadsheet`
|
||||
|
||||
Get metadata about a Google Sheets spreadsheet including title and sheet list
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `spreadsheetId` | string | Yes | The ID of the spreadsheet |
|
||||
| `includeGridData` | boolean | No | Whether to include grid data \(cell values\). Defaults to false. |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `spreadsheetId` | string | The spreadsheet ID |
|
||||
| `title` | string | The title of the spreadsheet |
|
||||
| `locale` | string | The locale of the spreadsheet |
|
||||
| `timeZone` | string | The time zone of the spreadsheet |
|
||||
| `spreadsheetUrl` | string | URL to the spreadsheet |
|
||||
| `sheets` | array | List of sheets in the spreadsheet |
|
||||
| ↳ `sheetId` | number | The sheet ID |
|
||||
| ↳ `title` | string | The sheet title/name |
|
||||
| ↳ `index` | number | The sheet index \(position\) |
|
||||
| ↳ `rowCount` | number | Number of rows in the sheet |
|
||||
| ↳ `columnCount` | number | Number of columns in the sheet |
|
||||
| ↳ `hidden` | boolean | Whether the sheet is hidden |
|
||||
|
||||
### `google_sheets_create_spreadsheet`
|
||||
|
||||
Create a new Google Sheets spreadsheet
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `title` | string | Yes | The title of the new spreadsheet |
|
||||
| `sheetTitles` | json | No | Array of sheet names to create \(e.g., \["Sheet1", "Data", "Summary"\]\). Defaults to a single "Sheet1". |
|
||||
| `locale` | string | No | The locale of the spreadsheet \(e.g., "en_US"\) |
|
||||
| `timeZone` | string | No | The time zone of the spreadsheet \(e.g., "America/New_York"\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `spreadsheetId` | string | The ID of the created spreadsheet |
|
||||
| `title` | string | The title of the created spreadsheet |
|
||||
| `spreadsheetUrl` | string | URL to the created spreadsheet |
|
||||
| `sheets` | array | List of sheets created in the spreadsheet |
|
||||
| ↳ `sheetId` | number | The sheet ID |
|
||||
| ↳ `title` | string | The sheet title/name |
|
||||
| ↳ `index` | number | The sheet index \(position\) |
|
||||
|
||||
### `google_sheets_batch_get`
|
||||
|
||||
Read multiple ranges from a Google Sheets spreadsheet in a single request
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `spreadsheetId` | string | Yes | The ID of the spreadsheet |
|
||||
| `ranges` | json | Yes | Array of ranges to read \(e.g., \["Sheet1!A1:D10", "Sheet2!A1:B5"\]\). Each range should include sheet name. |
|
||||
| `majorDimension` | string | No | The major dimension of values: "ROWS" \(default\) or "COLUMNS" |
|
||||
| `valueRenderOption` | string | No | How values should be rendered: "FORMATTED_VALUE" \(default\), "UNFORMATTED_VALUE", or "FORMULA" |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `spreadsheetId` | string | The spreadsheet ID |
|
||||
| `valueRanges` | array | Array of value ranges read from the spreadsheet |
|
||||
| ↳ `range` | string | The range that was read |
|
||||
| ↳ `majorDimension` | string | Major dimension \(ROWS or COLUMNS\) |
|
||||
| ↳ `values` | array | The cell values as a 2D array |
|
||||
| `metadata` | json | Spreadsheet metadata including ID and URL |
|
||||
| ↳ `spreadsheetId` | string | Google Sheets spreadsheet ID |
|
||||
| ↳ `spreadsheetUrl` | string | Spreadsheet URL |
|
||||
|
||||
### `google_sheets_batch_update`
|
||||
|
||||
Update multiple ranges in a Google Sheets spreadsheet in a single request
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `spreadsheetId` | string | Yes | The ID of the spreadsheet |
|
||||
| `data` | json | Yes | Array of value ranges to update. Each item should have "range" \(e.g., "Sheet1!A1:D10"\) and "values" \(2D array\). |
|
||||
| `valueInputOption` | string | No | How input data should be interpreted: "RAW" or "USER_ENTERED" \(default\). USER_ENTERED parses formulas. |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `spreadsheetId` | string | The spreadsheet ID |
|
||||
| `totalUpdatedRows` | number | Total number of rows updated |
|
||||
| `totalUpdatedColumns` | number | Total number of columns updated |
|
||||
| `totalUpdatedCells` | number | Total number of cells updated |
|
||||
| `totalUpdatedSheets` | number | Total number of sheets updated |
|
||||
| `responses` | array | Array of update responses for each range |
|
||||
| ↳ `spreadsheetId` | string | The spreadsheet ID |
|
||||
| ↳ `updatedRange` | string | The range that was updated |
|
||||
| ↳ `updatedRows` | number | Number of rows updated in this range |
|
||||
| ↳ `updatedColumns` | number | Number of columns updated in this range |
|
||||
| ↳ `updatedCells` | number | Number of cells updated in this range |
|
||||
| `metadata` | json | Spreadsheet metadata including ID and URL |
|
||||
| ↳ `spreadsheetId` | string | Google Sheets spreadsheet ID |
|
||||
| ↳ `spreadsheetUrl` | string | Spreadsheet URL |
|
||||
|
||||
### `google_sheets_batch_clear`
|
||||
|
||||
Clear multiple ranges in a Google Sheets spreadsheet in a single request
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `spreadsheetId` | string | Yes | The ID of the spreadsheet |
|
||||
| `ranges` | json | Yes | Array of ranges to clear \(e.g., \["Sheet1!A1:D10", "Sheet2!A1:B5"\]\). Each range should include sheet name. |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `spreadsheetId` | string | The spreadsheet ID |
|
||||
| `clearedRanges` | array | Array of ranges that were cleared |
|
||||
| `metadata` | json | Spreadsheet metadata including ID and URL |
|
||||
| ↳ `spreadsheetId` | string | Google Sheets spreadsheet ID |
|
||||
| ↳ `spreadsheetUrl` | string | Spreadsheet URL |
|
||||
|
||||
### `google_sheets_copy_sheet`
|
||||
|
||||
Copy a sheet from one spreadsheet to another
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `sourceSpreadsheetId` | string | Yes | The ID of the source spreadsheet |
|
||||
| `sheetId` | number | Yes | The ID of the sheet to copy \(numeric ID, not the sheet name\). Use Get Spreadsheet to find sheet IDs. |
|
||||
| `destinationSpreadsheetId` | string | Yes | The ID of the destination spreadsheet where the sheet will be copied |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `sheetId` | number | The ID of the newly created sheet in the destination |
|
||||
| `title` | string | The title of the copied sheet |
|
||||
| `index` | number | The index \(position\) of the copied sheet |
|
||||
| `sheetType` | string | The type of the sheet \(GRID, CHART, etc.\) |
|
||||
| `destinationSpreadsheetId` | string | The ID of the destination spreadsheet |
|
||||
| `destinationSpreadsheetUrl` | string | URL to the destination spreadsheet |
|
||||
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ In Sim, the Google Slides integration enables your agents to interact directly w
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Integrate Google Slides into the workflow. Can read, write, create presentations, replace text, add slides, add images, and get thumbnails.
|
||||
Integrate Google Slides into the workflow. Can read, write, create presentations, replace text, add slides, add images, get thumbnails, get page details, delete objects, duplicate objects, reorder slides, create tables, create shapes, and insert text.
|
||||
|
||||
|
||||
|
||||
@@ -52,6 +52,15 @@ Read content from a Google Slides presentation
|
||||
| --------- | ---- | ----------- |
|
||||
| `slides` | json | Array of slides with their content |
|
||||
| `metadata` | json | Presentation metadata including ID, title, and URL |
|
||||
| ↳ `presentationId` | string | The presentation ID |
|
||||
| ↳ `title` | string | The presentation title |
|
||||
| ↳ `pageSize` | object | Presentation page size |
|
||||
| ↳ `width` | json | Page width as a Dimension object |
|
||||
| ↳ `height` | json | Page height as a Dimension object |
|
||||
| ↳ `width` | json | Page width as a Dimension object |
|
||||
| ↳ `height` | json | Page height as a Dimension object |
|
||||
| ↳ `mimeType` | string | The mime type of the presentation |
|
||||
| ↳ `url` | string | URL to open the presentation |
|
||||
|
||||
### `google_slides_write`
|
||||
|
||||
@@ -71,6 +80,10 @@ Write or update content in a Google Slides presentation
|
||||
| --------- | ---- | ----------- |
|
||||
| `updatedContent` | boolean | Indicates if presentation content was updated successfully |
|
||||
| `metadata` | json | Updated presentation metadata including ID, title, and URL |
|
||||
| ↳ `presentationId` | string | The presentation ID |
|
||||
| ↳ `title` | string | The presentation title |
|
||||
| ↳ `mimeType` | string | The mime type of the presentation |
|
||||
| ↳ `url` | string | URL to open the presentation |
|
||||
|
||||
### `google_slides_create`
|
||||
|
||||
@@ -90,6 +103,10 @@ Create a new Google Slides presentation
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `metadata` | json | Created presentation metadata including ID, title, and URL |
|
||||
| ↳ `presentationId` | string | The presentation ID |
|
||||
| ↳ `title` | string | The presentation title |
|
||||
| ↳ `mimeType` | string | The mime type of the presentation |
|
||||
| ↳ `url` | string | URL to open the presentation |
|
||||
|
||||
### `google_slides_replace_all_text`
|
||||
|
||||
@@ -111,6 +128,10 @@ Find and replace all occurrences of text throughout a Google Slides presentation
|
||||
| --------- | ---- | ----------- |
|
||||
| `occurrencesChanged` | number | Number of text occurrences that were replaced |
|
||||
| `metadata` | json | Operation metadata including presentation ID and URL |
|
||||
| ↳ `presentationId` | string | The presentation ID |
|
||||
| ↳ `findText` | string | The text that was searched for |
|
||||
| ↳ `replaceText` | string | The text that replaced the matches |
|
||||
| ↳ `url` | string | URL to open the presentation |
|
||||
|
||||
### `google_slides_add_slide`
|
||||
|
||||
@@ -131,6 +152,10 @@ Add a new slide to a Google Slides presentation with a specified layout
|
||||
| --------- | ---- | ----------- |
|
||||
| `slideId` | string | The object ID of the newly created slide |
|
||||
| `metadata` | json | Operation metadata including presentation ID, layout, and URL |
|
||||
| ↳ `presentationId` | string | The presentation ID |
|
||||
| ↳ `layout` | string | The layout used for the new slide |
|
||||
| ↳ `insertionIndex` | number | The zero-based index where the slide was inserted |
|
||||
| ↳ `url` | string | URL to open the presentation |
|
||||
|
||||
### `google_slides_add_image`
|
||||
|
||||
@@ -154,6 +179,10 @@ Insert an image into a specific slide in a Google Slides presentation
|
||||
| --------- | ---- | ----------- |
|
||||
| `imageId` | string | The object ID of the newly created image |
|
||||
| `metadata` | json | Operation metadata including presentation ID and image URL |
|
||||
| ↳ `presentationId` | string | The presentation ID |
|
||||
| ↳ `pageObjectId` | string | The page object ID where the image was inserted |
|
||||
| ↳ `imageUrl` | string | The source image URL |
|
||||
| ↳ `url` | string | URL to open the presentation |
|
||||
|
||||
### `google_slides_get_thumbnail`
|
||||
|
||||
@@ -176,5 +205,182 @@ Generate a thumbnail image of a specific slide in a Google Slides presentation
|
||||
| `width` | number | Width of the thumbnail in pixels |
|
||||
| `height` | number | Height of the thumbnail in pixels |
|
||||
| `metadata` | json | Operation metadata including presentation ID and page object ID |
|
||||
| ↳ `presentationId` | string | The presentation ID |
|
||||
| ↳ `pageObjectId` | string | The page object ID for the thumbnail |
|
||||
| ↳ `thumbnailSize` | string | The requested thumbnail size |
|
||||
| ↳ `mimeType` | string | The thumbnail MIME type |
|
||||
|
||||
### `google_slides_get_page`
|
||||
|
||||
Get detailed information about a specific slide/page in a Google Slides presentation
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `presentationId` | string | Yes | The ID of the presentation |
|
||||
| `pageObjectId` | string | Yes | The object ID of the slide/page to retrieve |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `objectId` | string | The object ID of the page |
|
||||
| `pageType` | string | The type of page \(SLIDE, MASTER, LAYOUT, NOTES, NOTES_MASTER\) |
|
||||
| `pageElements` | array | Array of page elements \(shapes, images, tables, etc.\) on this page |
|
||||
| `slideProperties` | object | Properties specific to slides \(layout, master, notes\) |
|
||||
| ↳ `layoutObjectId` | string | Object ID of the layout this slide is based on |
|
||||
| ↳ `masterObjectId` | string | Object ID of the master this slide is based on |
|
||||
| ↳ `notesPage` | json | The notes page associated with the slide |
|
||||
| `metadata` | object | Operation metadata including presentation ID and URL |
|
||||
| ↳ `presentationId` | string | The presentation ID |
|
||||
| ↳ `url` | string | URL to the presentation |
|
||||
|
||||
### `google_slides_delete_object`
|
||||
|
||||
Delete a page element (shape, image, table, etc.) or an entire slide from a Google Slides presentation
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `presentationId` | string | Yes | The ID of the presentation |
|
||||
| `objectId` | string | Yes | The object ID of the element or slide to delete |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `deleted` | boolean | Whether the object was successfully deleted |
|
||||
| `objectId` | string | The object ID that was deleted |
|
||||
| `metadata` | object | Operation metadata including presentation ID and URL |
|
||||
| ↳ `presentationId` | string | The presentation ID |
|
||||
| ↳ `url` | string | URL to the presentation |
|
||||
|
||||
### `google_slides_duplicate_object`
|
||||
|
||||
Duplicate an object (slide, shape, image, table, etc.) in a Google Slides presentation
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `presentationId` | string | Yes | The ID of the presentation |
|
||||
| `objectId` | string | Yes | The object ID of the element or slide to duplicate |
|
||||
| `objectIds` | string | No | Optional JSON object mapping source object IDs \(within the slide being duplicated\) to new object IDs for the duplicates. Format: \{"sourceId1":"newId1","sourceId2":"newId2"\} |
|
||||
| `Format` | string | No | No description |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `duplicatedObjectId` | string | The object ID of the newly created duplicate |
|
||||
| `metadata` | object | Operation metadata including presentation ID and source object ID |
|
||||
| ↳ `presentationId` | string | The presentation ID |
|
||||
| ↳ `sourceObjectId` | string | The original object ID that was duplicated |
|
||||
| ↳ `url` | string | URL to the presentation |
|
||||
|
||||
### `google_slides_update_slides_position`
|
||||
|
||||
Move one or more slides to a new position in a Google Slides presentation
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `presentationId` | string | Yes | The ID of the presentation |
|
||||
| `slideObjectIds` | string | Yes | Comma-separated list of slide object IDs to move. The slides will maintain their relative order. |
|
||||
| `insertionIndex` | number | Yes | The zero-based index where the slides should be moved. All slides with indices greater than or equal to this will be shifted right. |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `moved` | boolean | Whether the slides were successfully moved |
|
||||
| `slideObjectIds` | array | The slide object IDs that were moved |
|
||||
| `insertionIndex` | number | The index where the slides were moved to |
|
||||
| `metadata` | object | Operation metadata including presentation ID and URL |
|
||||
| ↳ `presentationId` | string | The presentation ID |
|
||||
| ↳ `url` | string | URL to the presentation |
|
||||
|
||||
### `google_slides_create_table`
|
||||
|
||||
Create a new table on a slide in a Google Slides presentation
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `presentationId` | string | Yes | The ID of the presentation |
|
||||
| `pageObjectId` | string | Yes | The object ID of the slide/page to add the table to |
|
||||
| `rows` | number | Yes | Number of rows in the table \(minimum 1\) |
|
||||
| `columns` | number | Yes | Number of columns in the table \(minimum 1\) |
|
||||
| `width` | number | No | Width of the table in points \(default: 400\) |
|
||||
| `height` | number | No | Height of the table in points \(default: 200\) |
|
||||
| `positionX` | number | No | X position from the left edge in points \(default: 100\) |
|
||||
| `positionY` | number | No | Y position from the top edge in points \(default: 100\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `tableId` | string | The object ID of the newly created table |
|
||||
| `rows` | number | Number of rows in the table |
|
||||
| `columns` | number | Number of columns in the table |
|
||||
| `metadata` | object | Operation metadata including presentation ID and page object ID |
|
||||
| ↳ `presentationId` | string | The presentation ID |
|
||||
| ↳ `pageObjectId` | string | The page object ID where the table was created |
|
||||
| ↳ `url` | string | URL to the presentation |
|
||||
|
||||
### `google_slides_create_shape`
|
||||
|
||||
Create a shape (rectangle, ellipse, text box, arrow, etc.) on a slide in a Google Slides presentation
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `presentationId` | string | Yes | The ID of the presentation |
|
||||
| `pageObjectId` | string | Yes | The object ID of the slide/page to add the shape to |
|
||||
| `shapeType` | string | Yes | The type of shape to create. Common types: TEXT_BOX, RECTANGLE, ROUND_RECTANGLE, ELLIPSE, TRIANGLE, DIAMOND, STAR_5, ARROW_EAST, HEART, CLOUD |
|
||||
| `width` | number | No | Width of the shape in points \(default: 200\) |
|
||||
| `height` | number | No | Height of the shape in points \(default: 100\) |
|
||||
| `positionX` | number | No | X position from the left edge in points \(default: 100\) |
|
||||
| `positionY` | number | No | Y position from the top edge in points \(default: 100\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `shapeId` | string | The object ID of the newly created shape |
|
||||
| `shapeType` | string | The type of shape that was created |
|
||||
| `metadata` | object | Operation metadata including presentation ID and page object ID |
|
||||
| ↳ `presentationId` | string | The presentation ID |
|
||||
| ↳ `pageObjectId` | string | The page object ID where the shape was created |
|
||||
| ↳ `url` | string | URL to the presentation |
|
||||
|
||||
### `google_slides_insert_text`
|
||||
|
||||
Insert text into a shape or table cell in a Google Slides presentation. Use this to add text to text boxes, shapes, or table cells.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `presentationId` | string | Yes | The ID of the presentation |
|
||||
| `objectId` | string | Yes | The object ID of the shape or table cell to insert text into. For table cells, use the cell object ID. |
|
||||
| `text` | string | Yes | The text to insert |
|
||||
| `insertionIndex` | number | No | The zero-based index at which to insert the text. If not specified, text is inserted at the beginning \(index 0\). |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `inserted` | boolean | Whether the text was successfully inserted |
|
||||
| `objectId` | string | The object ID where text was inserted |
|
||||
| `text` | string | The text that was inserted |
|
||||
| `metadata` | object | Operation metadata including presentation ID and URL |
|
||||
| ↳ `presentationId` | string | The presentation ID |
|
||||
| ↳ `url` | string | URL to the presentation |
|
||||
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ Search for similar content in a knowledge base using vector similarity
|
||||
| `properties` | string | No | No description |
|
||||
| `tagName` | string | No | No description |
|
||||
| `tagValue` | string | No | No description |
|
||||
| `tagFilters` | string | No | No description |
|
||||
|
||||
#### Output
|
||||
|
||||
@@ -108,19 +109,8 @@ Create a new document in a knowledge base
|
||||
| `knowledgeBaseId` | string | Yes | ID of the knowledge base containing the document |
|
||||
| `name` | string | Yes | Name of the document |
|
||||
| `content` | string | Yes | Content of the document |
|
||||
| `tag1` | string | No | Tag 1 value for the document |
|
||||
| `tag2` | string | No | Tag 2 value for the document |
|
||||
| `tag3` | string | No | Tag 3 value for the document |
|
||||
| `tag4` | string | No | Tag 4 value for the document |
|
||||
| `tag5` | string | No | Tag 5 value for the document |
|
||||
| `tag6` | string | No | Tag 6 value for the document |
|
||||
| `tag7` | string | No | Tag 7 value for the document |
|
||||
| `documentTagsData` | array | No | Structured tag data with names, types, and values |
|
||||
| `items` | object | No | No description |
|
||||
| `properties` | string | No | No description |
|
||||
| `tagName` | string | No | No description |
|
||||
| `tagValue` | string | No | No description |
|
||||
| `tagType` | string | No | No description |
|
||||
| `documentTags` | object | No | Document tags |
|
||||
| `documentTags` | string | No | No description |
|
||||
|
||||
#### Output
|
||||
|
||||
|
||||
@@ -45,8 +45,7 @@ Read data from a specific sheet in a Microsoft Excel spreadsheet
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `spreadsheetId` | string | Yes | The ID of the spreadsheet to read from |
|
||||
| `sheetName` | string | Yes | The name of the sheet/tab to read from |
|
||||
| `cellRange` | string | No | The cell range to read \(e.g., "A1:D10"\). If not specified, reads the entire used range. |
|
||||
| `range` | string | No | The range of cells to read from. Accepts "SheetName!A1:B2" for explicit ranges or just "SheetName" to read the used range of that sheet. If omitted, reads the used range of the first sheet. |
|
||||
|
||||
#### Output
|
||||
|
||||
@@ -68,9 +67,8 @@ Write data to a specific sheet in a Microsoft Excel spreadsheet
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `spreadsheetId` | string | Yes | The ID of the spreadsheet to write to |
|
||||
| `sheetName` | string | Yes | The name of the sheet/tab to write to |
|
||||
| `cellRange` | string | No | The cell range to write to \(e.g., "A1:D10", "A1"\). Defaults to "A1" if not specified. |
|
||||
| `values` | array | Yes | The data to write as a 2D array \(e.g. \[\["Name", "Age"\], \["Alice", 30\], \["Bob", 25\]\]\) or array of objects. |
|
||||
| `range` | string | No | The range of cells to write to |
|
||||
| `values` | array | Yes | The data to write to the spreadsheet |
|
||||
| `valueInputOption` | string | No | The format of the data to write |
|
||||
| `includeValuesInResponse` | boolean | No | Whether to include the written values in the response |
|
||||
|
||||
|
||||
@@ -84,9 +84,10 @@ Send messages to Slack channels or direct messages. Supports Slack mrkdwn format
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `authMethod` | string | No | Authentication method: oauth or bot_token |
|
||||
| `destinationType` | string | No | Destination type: channel or dm |
|
||||
| `botToken` | string | No | Bot token for Custom Bot |
|
||||
| `channel` | string | No | Target Slack channel \(e.g., #general\) |
|
||||
| `userId` | string | No | Target Slack user ID for direct messages \(e.g., U1234567890\) |
|
||||
| `dmUserId` | string | No | Target Slack user for direct messages |
|
||||
| `text` | string | Yes | Message text to send \(supports Slack mrkdwn formatting\) |
|
||||
| `thread_ts` | string | No | Thread timestamp to reply to \(creates thread reply\) |
|
||||
| `files` | file[] | No | Files to attach to the message |
|
||||
@@ -132,9 +133,10 @@ Read the latest messages from Slack channels. Retrieve conversation history with
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `authMethod` | string | No | Authentication method: oauth or bot_token |
|
||||
| `destinationType` | string | No | Destination type: channel or dm |
|
||||
| `botToken` | string | No | Bot token for Custom Bot |
|
||||
| `channel` | string | No | Slack channel to read messages from \(e.g., #general\) |
|
||||
| `userId` | string | No | User ID for DM conversation \(e.g., U1234567890\) |
|
||||
| `dmUserId` | string | No | Target Slack user for DM conversation |
|
||||
| `limit` | number | No | Number of messages to retrieve \(default: 10, max: 15\) |
|
||||
| `oldest` | string | No | Start of time range \(timestamp\) |
|
||||
| `latest` | string | No | End of time range \(timestamp\) |
|
||||
|
||||
@@ -2,7 +2,7 @@ import { randomUUID } from 'crypto'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { SUPPORTED_FIELD_TYPES } from '@/lib/knowledge/constants'
|
||||
import { createTagDefinition, getTagDefinitions } from '@/lib/knowledge/tags/service'
|
||||
import { checkKnowledgeBaseAccess } from '@/app/api/knowledge/utils'
|
||||
@@ -19,19 +19,32 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
try {
|
||||
logger.info(`[${requestId}] Getting tag definitions for knowledge base ${knowledgeBaseId}`)
|
||||
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
const auth = await checkHybridAuth(req, { requireWorkflowId: false })
|
||||
if (!auth.success) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const accessCheck = await checkKnowledgeBaseAccess(knowledgeBaseId, session.user.id)
|
||||
if (!accessCheck.hasAccess) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
// Only allow session and internal JWT auth (not API key)
|
||||
if (auth.authType === 'api_key') {
|
||||
return NextResponse.json(
|
||||
{ error: 'API key auth not supported for this endpoint' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
// For session auth, verify KB access. Internal JWT is trusted.
|
||||
if (auth.authType === 'session' && auth.userId) {
|
||||
const accessCheck = await checkKnowledgeBaseAccess(knowledgeBaseId, auth.userId)
|
||||
if (!accessCheck.hasAccess) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
}
|
||||
|
||||
const tagDefinitions = await getTagDefinitions(knowledgeBaseId)
|
||||
|
||||
logger.info(`[${requestId}] Retrieved ${tagDefinitions.length} tag definitions`)
|
||||
logger.info(
|
||||
`[${requestId}] Retrieved ${tagDefinitions.length} tag definitions (${auth.authType})`
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
@@ -51,14 +64,25 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
try {
|
||||
logger.info(`[${requestId}] Creating tag definition for knowledge base ${knowledgeBaseId}`)
|
||||
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
const auth = await checkHybridAuth(req, { requireWorkflowId: false })
|
||||
if (!auth.success) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const accessCheck = await checkKnowledgeBaseAccess(knowledgeBaseId, session.user.id)
|
||||
if (!accessCheck.hasAccess) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
// Only allow session and internal JWT auth (not API key)
|
||||
if (auth.authType === 'api_key') {
|
||||
return NextResponse.json(
|
||||
{ error: 'API key auth not supported for this endpoint' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
// For session auth, verify KB access. Internal JWT is trusted.
|
||||
if (auth.authType === 'session' && auth.userId) {
|
||||
const accessCheck = await checkKnowledgeBaseAccess(knowledgeBaseId, auth.userId)
|
||||
if (!accessCheck.hasAccess) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { type HTMLAttributes, type ReactNode } from 'react'
|
||||
import React, { type HTMLAttributes, memo, type ReactNode, useMemo } from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { Tooltip } from '@/components/emcn'
|
||||
@@ -23,24 +23,16 @@ export function LinkWithPreview({ href, children }: { href: string; children: Re
|
||||
)
|
||||
}
|
||||
|
||||
export default function MarkdownRenderer({
|
||||
content,
|
||||
customLinkComponent,
|
||||
}: {
|
||||
content: string
|
||||
customLinkComponent?: typeof LinkWithPreview
|
||||
}) {
|
||||
const LinkComponent = customLinkComponent || LinkWithPreview
|
||||
const REMARK_PLUGINS = [remarkGfm]
|
||||
|
||||
const customComponents = {
|
||||
// Paragraph
|
||||
function createCustomComponents(LinkComponent: typeof LinkWithPreview) {
|
||||
return {
|
||||
p: ({ children }: React.HTMLAttributes<HTMLParagraphElement>) => (
|
||||
<p className='mb-1 font-sans text-base text-gray-800 leading-relaxed last:mb-0 dark:text-gray-200'>
|
||||
{children}
|
||||
</p>
|
||||
),
|
||||
|
||||
// Headings
|
||||
h1: ({ children }: React.HTMLAttributes<HTMLHeadingElement>) => (
|
||||
<h1 className='mt-10 mb-5 font-sans font-semibold text-2xl text-gray-900 dark:text-gray-100'>
|
||||
{children}
|
||||
@@ -62,7 +54,6 @@ export default function MarkdownRenderer({
|
||||
</h4>
|
||||
),
|
||||
|
||||
// Lists
|
||||
ul: ({ children }: React.HTMLAttributes<HTMLUListElement>) => (
|
||||
<ul
|
||||
className='mt-1 mb-1 space-y-1 pl-6 font-sans text-gray-800 dark:text-gray-200'
|
||||
@@ -89,7 +80,6 @@ export default function MarkdownRenderer({
|
||||
</li>
|
||||
),
|
||||
|
||||
// Code blocks
|
||||
pre: ({ children }: HTMLAttributes<HTMLPreElement>) => {
|
||||
let codeProps: HTMLAttributes<HTMLElement> = {}
|
||||
let codeContent: ReactNode = children
|
||||
@@ -120,7 +110,6 @@ export default function MarkdownRenderer({
|
||||
)
|
||||
},
|
||||
|
||||
// Inline code
|
||||
code: ({
|
||||
inline,
|
||||
className,
|
||||
@@ -144,24 +133,20 @@ export default function MarkdownRenderer({
|
||||
)
|
||||
},
|
||||
|
||||
// Blockquotes
|
||||
blockquote: ({ children }: React.HTMLAttributes<HTMLQuoteElement>) => (
|
||||
<blockquote className='my-4 border-gray-300 border-l-4 py-1 pl-4 font-sans text-gray-700 italic dark:border-gray-600 dark:text-gray-300'>
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
|
||||
// Horizontal rule
|
||||
hr: () => <hr className='my-8 border-gray-500/[.07] border-t dark:border-gray-400/[.07]' />,
|
||||
|
||||
// Links
|
||||
a: ({ href, children, ...props }: React.AnchorHTMLAttributes<HTMLAnchorElement>) => (
|
||||
<LinkComponent href={href || '#'} {...props}>
|
||||
{children}
|
||||
</LinkComponent>
|
||||
),
|
||||
|
||||
// Tables
|
||||
table: ({ children }: React.TableHTMLAttributes<HTMLTableElement>) => (
|
||||
<div className='my-4 w-full overflow-x-auto'>
|
||||
<table className='min-w-full table-auto border border-gray-300 font-sans text-sm dark:border-gray-700'>
|
||||
@@ -193,7 +178,6 @@ export default function MarkdownRenderer({
|
||||
</td>
|
||||
),
|
||||
|
||||
// Images
|
||||
img: ({ src, alt, ...props }: React.ImgHTMLAttributes<HTMLImageElement>) => (
|
||||
<img
|
||||
src={src}
|
||||
@@ -203,15 +187,33 @@ export default function MarkdownRenderer({
|
||||
/>
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_COMPONENTS = createCustomComponents(LinkWithPreview)
|
||||
|
||||
const MarkdownRenderer = memo(function MarkdownRenderer({
|
||||
content,
|
||||
customLinkComponent,
|
||||
}: {
|
||||
content: string
|
||||
customLinkComponent?: typeof LinkWithPreview
|
||||
}) {
|
||||
const components = useMemo(() => {
|
||||
if (!customLinkComponent) {
|
||||
return DEFAULT_COMPONENTS
|
||||
}
|
||||
return createCustomComponents(customLinkComponent)
|
||||
}, [customLinkComponent])
|
||||
|
||||
// Pre-process content to fix common issues
|
||||
const processedContent = content.trim()
|
||||
|
||||
return (
|
||||
<div className='space-y-4 break-words font-sans text-[#0D0D0D] text-base leading-relaxed dark:text-gray-100'>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={customComponents}>
|
||||
<ReactMarkdown remarkPlugins={REMARK_PLUGINS} components={components}>
|
||||
{processedContent}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
export default MarkdownRenderer
|
||||
|
||||
@@ -7,7 +7,7 @@ import { generateBrandedMetadata, generateStructuredData } from '@/lib/branding/
|
||||
import { PostHogProvider } from '@/app/_shell/providers/posthog-provider'
|
||||
import '@/app/_styles/globals.css'
|
||||
import { OneDollarStats } from '@/components/analytics/onedollarstats'
|
||||
import { isReactGrabEnabled } from '@/lib/core/config/feature-flags'
|
||||
import { isReactGrabEnabled, isReactScanEnabled } from '@/lib/core/config/feature-flags'
|
||||
import { HydrationErrorHandler } from '@/app/_shell/hydration-error-handler'
|
||||
import { QueryProvider } from '@/app/_shell/providers/query-provider'
|
||||
import { SessionProvider } from '@/app/_shell/providers/session-provider'
|
||||
@@ -35,6 +35,13 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
return (
|
||||
<html lang='en' suppressHydrationWarning>
|
||||
<head>
|
||||
{isReactScanEnabled && (
|
||||
<Script
|
||||
src='https://unpkg.com/react-scan/dist/auto.global.js'
|
||||
crossOrigin='anonymous'
|
||||
strategy='beforeInteractive'
|
||||
/>
|
||||
)}
|
||||
{isReactGrabEnabled && (
|
||||
<Script
|
||||
src='https://unpkg.com/react-grab/dist/index.global.js'
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import { Suspense, useEffect, useState } from 'react'
|
||||
import { CheckCircle, Heart, Info, Loader2, XCircle } from 'lucide-react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui'
|
||||
import { useBrandConfig } from '@/lib/branding/branding'
|
||||
import { inter } from '@/app/_styles/fonts/inter/inter'
|
||||
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
|
||||
import { BrandedButton } from '@/app/(auth)/components/branded-button'
|
||||
import { SupportFooter } from '@/app/(auth)/components/support-footer'
|
||||
import { InviteLayout } from '@/app/invite/components'
|
||||
|
||||
interface UnsubscribeData {
|
||||
success: boolean
|
||||
@@ -27,7 +30,6 @@ function UnsubscribeContent() {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [processing, setProcessing] = useState(false)
|
||||
const [unsubscribed, setUnsubscribed] = useState(false)
|
||||
const brand = useBrandConfig()
|
||||
|
||||
const email = searchParams.get('email')
|
||||
const token = searchParams.get('token')
|
||||
@@ -109,7 +111,7 @@ function UnsubscribeContent() {
|
||||
} else {
|
||||
setError(result.error || 'Failed to unsubscribe')
|
||||
}
|
||||
} catch (error) {
|
||||
} catch {
|
||||
setError('Failed to process unsubscribe request')
|
||||
} finally {
|
||||
setProcessing(false)
|
||||
@@ -118,272 +120,171 @@ function UnsubscribeContent() {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className='before:-z-50 relative flex min-h-screen items-center justify-center before:pointer-events-none before:fixed before:inset-0 before:bg-white'>
|
||||
<Card className='w-full max-w-md border shadow-sm'>
|
||||
<CardContent className='flex items-center justify-center p-8'>
|
||||
<Loader2 className='h-8 w-8 animate-spin text-muted-foreground' />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<InviteLayout>
|
||||
<div className='space-y-1 text-center'>
|
||||
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
|
||||
Loading
|
||||
</h1>
|
||||
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
|
||||
Validating your unsubscribe link...
|
||||
</p>
|
||||
</div>
|
||||
<div className={`${inter.className} mt-8 flex w-full items-center justify-center py-8`}>
|
||||
<Loader2 className='h-8 w-8 animate-spin text-muted-foreground' />
|
||||
</div>
|
||||
<SupportFooter position='absolute' />
|
||||
</InviteLayout>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className='before:-z-50 relative flex min-h-screen items-center justify-center p-4 before:pointer-events-none before:fixed before:inset-0 before:bg-white'>
|
||||
<Card className='w-full max-w-md border shadow-sm'>
|
||||
<CardHeader className='text-center'>
|
||||
<XCircle className='mx-auto mb-2 h-12 w-12 text-red-500' />
|
||||
<CardTitle className='text-foreground'>Invalid Unsubscribe Link</CardTitle>
|
||||
<CardDescription className='text-muted-foreground'>
|
||||
This unsubscribe link is invalid or has expired
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
<div className='rounded-lg border bg-red-50 p-4'>
|
||||
<p className='text-red-800 text-sm'>
|
||||
<strong>Error:</strong> {error}
|
||||
</p>
|
||||
</div>
|
||||
<InviteLayout>
|
||||
<div className='space-y-1 text-center'>
|
||||
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
|
||||
Invalid Unsubscribe Link
|
||||
</h1>
|
||||
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
|
||||
{error}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='space-y-3'>
|
||||
<p className='text-muted-foreground text-sm'>This could happen if:</p>
|
||||
<ul className='ml-4 list-inside list-disc space-y-1 text-muted-foreground text-sm'>
|
||||
<li>The link is missing required parameters</li>
|
||||
<li>The link has expired or been used already</li>
|
||||
<li>The link was copied incorrectly</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className={`${inter.className} mt-8 w-full max-w-[410px] space-y-3`}>
|
||||
<BrandedButton onClick={() => window.history.back()}>Go Back</BrandedButton>
|
||||
</div>
|
||||
|
||||
<div className='mt-6 flex flex-col gap-3'>
|
||||
<Button
|
||||
onClick={() =>
|
||||
window.open(
|
||||
`mailto:${brand.supportEmail}?subject=Unsubscribe%20Help&body=Hi%2C%20I%20need%20help%20unsubscribing%20from%20emails.%20My%20unsubscribe%20link%20is%20not%20working.`,
|
||||
'_blank'
|
||||
)
|
||||
}
|
||||
className='w-full bg-[var(--brand-primary-hex)] font-medium text-white shadow-sm transition-colors duration-200 hover:bg-[var(--brand-primary-hover-hex)]'
|
||||
>
|
||||
Contact Support
|
||||
</Button>
|
||||
<Button onClick={() => window.history.back()} variant='outline' className='w-full'>
|
||||
Go Back
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className='mt-4 text-center'>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Need immediate help? Email us at{' '}
|
||||
<a
|
||||
href={`mailto:${brand.supportEmail}`}
|
||||
className='text-muted-foreground hover:underline'
|
||||
>
|
||||
{brand.supportEmail}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<SupportFooter position='absolute' />
|
||||
</InviteLayout>
|
||||
)
|
||||
}
|
||||
|
||||
if (data?.isTransactional) {
|
||||
return (
|
||||
<div className='before:-z-50 relative flex min-h-screen items-center justify-center p-4 before:pointer-events-none before:fixed before:inset-0 before:bg-white'>
|
||||
<Card className='w-full max-w-md border shadow-sm'>
|
||||
<CardHeader className='text-center'>
|
||||
<Info className='mx-auto mb-2 h-12 w-12 text-blue-500' />
|
||||
<CardTitle className='text-foreground'>Important Account Emails</CardTitle>
|
||||
<CardDescription className='text-muted-foreground'>
|
||||
This email contains important information about your account
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
<div className='rounded-lg border bg-blue-50 p-4'>
|
||||
<p className='text-blue-800 text-sm'>
|
||||
<strong>Transactional emails</strong> like password resets, account confirmations,
|
||||
and security alerts cannot be unsubscribed from as they contain essential
|
||||
information for your account security and functionality.
|
||||
</p>
|
||||
</div>
|
||||
<InviteLayout>
|
||||
<div className='space-y-1 text-center'>
|
||||
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
|
||||
Important Account Emails
|
||||
</h1>
|
||||
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
|
||||
Transactional emails like password resets, account confirmations, and security alerts
|
||||
cannot be unsubscribed from as they contain essential information for your account.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='space-y-3'>
|
||||
<p className='text-foreground text-sm'>
|
||||
If you no longer wish to receive these emails, you can:
|
||||
</p>
|
||||
<ul className='ml-4 list-inside list-disc space-y-1 text-muted-foreground text-sm'>
|
||||
<li>Close your account entirely</li>
|
||||
<li>Contact our support team for assistance</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className={`${inter.className} mt-8 w-full max-w-[410px] space-y-3`}>
|
||||
<BrandedButton onClick={() => window.close()}>Close</BrandedButton>
|
||||
</div>
|
||||
|
||||
<div className='mt-6 flex flex-col gap-3'>
|
||||
<Button
|
||||
onClick={() =>
|
||||
window.open(
|
||||
`mailto:${brand.supportEmail}?subject=Account%20Help&body=Hi%2C%20I%20need%20help%20with%20my%20account%20emails.`,
|
||||
'_blank'
|
||||
)
|
||||
}
|
||||
className='w-full bg-blue-600 text-white hover:bg-blue-700'
|
||||
>
|
||||
Contact Support
|
||||
</Button>
|
||||
<Button onClick={() => window.close()} variant='outline' className='w-full'>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<SupportFooter position='absolute' />
|
||||
</InviteLayout>
|
||||
)
|
||||
}
|
||||
|
||||
if (unsubscribed) {
|
||||
return (
|
||||
<div className='before:-z-50 relative flex min-h-screen items-center justify-center before:pointer-events-none before:fixed before:inset-0 before:bg-white'>
|
||||
<Card className='w-full max-w-md border shadow-sm'>
|
||||
<CardHeader className='text-center'>
|
||||
<CheckCircle className='mx-auto mb-2 h-12 w-12 text-green-500' />
|
||||
<CardTitle className='text-foreground'>Successfully Unsubscribed</CardTitle>
|
||||
<CardDescription className='text-muted-foreground'>
|
||||
You have been unsubscribed from our emails. You will stop receiving emails within 48
|
||||
hours.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='text-center'>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
If you change your mind, you can always update your email preferences in your account
|
||||
settings or contact us at{' '}
|
||||
<a
|
||||
href={`mailto:${brand.supportEmail}`}
|
||||
className='text-muted-foreground hover:underline'
|
||||
>
|
||||
{brand.supportEmail}
|
||||
</a>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<InviteLayout>
|
||||
<div className='space-y-1 text-center'>
|
||||
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
|
||||
Successfully Unsubscribed
|
||||
</h1>
|
||||
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
|
||||
You have been unsubscribed from our emails. You will stop receiving emails within 48
|
||||
hours.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={`${inter.className} mt-8 w-full max-w-[410px] space-y-3`}>
|
||||
<BrandedButton onClick={() => window.close()}>Close</BrandedButton>
|
||||
</div>
|
||||
|
||||
<SupportFooter position='absolute' />
|
||||
</InviteLayout>
|
||||
)
|
||||
}
|
||||
|
||||
const isAlreadyUnsubscribedFromAll = data?.currentPreferences.unsubscribeAll
|
||||
|
||||
return (
|
||||
<div className='before:-z-50 relative flex min-h-screen items-center justify-center p-4 before:pointer-events-none before:fixed before:inset-0 before:bg-white'>
|
||||
<Card className='w-full max-w-md border shadow-sm'>
|
||||
<CardHeader className='text-center'>
|
||||
<Heart className='mx-auto mb-2 h-12 w-12 text-red-500' />
|
||||
<CardTitle className='text-foreground'>We're sorry to see you go!</CardTitle>
|
||||
<CardDescription className='text-muted-foreground'>
|
||||
We understand email preferences are personal. Choose which emails you'd like to
|
||||
stop receiving from Sim.
|
||||
</CardDescription>
|
||||
<div className='mt-2 rounded-lg border bg-muted/50 p-3'>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Email: <span className='font-medium text-foreground'>{data?.email}</span>
|
||||
</p>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
<div className='space-y-3'>
|
||||
<Button
|
||||
onClick={() => handleUnsubscribe('all')}
|
||||
disabled={processing || data?.currentPreferences.unsubscribeAll}
|
||||
variant='destructive'
|
||||
className='w-full'
|
||||
>
|
||||
{data?.currentPreferences.unsubscribeAll ? (
|
||||
<CheckCircle className='mr-2 h-4 w-4' />
|
||||
) : null}
|
||||
{processing
|
||||
? 'Unsubscribing...'
|
||||
: data?.currentPreferences.unsubscribeAll
|
||||
? 'Unsubscribed from All Emails'
|
||||
: 'Unsubscribe from All Marketing Emails'}
|
||||
</Button>
|
||||
<InviteLayout>
|
||||
<div className='space-y-1 text-center'>
|
||||
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
|
||||
Email Preferences
|
||||
</h1>
|
||||
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
|
||||
Choose which emails you'd like to stop receiving.
|
||||
</p>
|
||||
<p className={`${inter.className} mt-2 font-[380] text-[14px] text-muted-foreground`}>
|
||||
{data?.email}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='text-center text-muted-foreground text-sm'>
|
||||
or choose specific types:
|
||||
</div>
|
||||
<div className={`${inter.className} mt-8 w-full max-w-[410px] space-y-3`}>
|
||||
<BrandedButton
|
||||
onClick={() => handleUnsubscribe('all')}
|
||||
disabled={processing || isAlreadyUnsubscribedFromAll}
|
||||
loading={processing}
|
||||
loadingText='Unsubscribing'
|
||||
>
|
||||
{isAlreadyUnsubscribedFromAll
|
||||
? 'Unsubscribed from All Emails'
|
||||
: 'Unsubscribe from All Marketing Emails'}
|
||||
</BrandedButton>
|
||||
|
||||
<Button
|
||||
onClick={() => handleUnsubscribe('marketing')}
|
||||
disabled={
|
||||
processing ||
|
||||
data?.currentPreferences.unsubscribeAll ||
|
||||
data?.currentPreferences.unsubscribeMarketing
|
||||
}
|
||||
variant='outline'
|
||||
className='w-full'
|
||||
>
|
||||
{data?.currentPreferences.unsubscribeMarketing ? (
|
||||
<CheckCircle className='mr-2 h-4 w-4' />
|
||||
) : null}
|
||||
{data?.currentPreferences.unsubscribeMarketing
|
||||
? 'Unsubscribed from Marketing'
|
||||
: 'Unsubscribe from Marketing Emails'}
|
||||
</Button>
|
||||
<div className='py-2 text-center'>
|
||||
<span className={`${inter.className} font-[380] text-[14px] text-muted-foreground`}>
|
||||
or choose specific types
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => handleUnsubscribe('updates')}
|
||||
disabled={
|
||||
processing ||
|
||||
data?.currentPreferences.unsubscribeAll ||
|
||||
data?.currentPreferences.unsubscribeUpdates
|
||||
}
|
||||
variant='outline'
|
||||
className='w-full'
|
||||
>
|
||||
{data?.currentPreferences.unsubscribeUpdates ? (
|
||||
<CheckCircle className='mr-2 h-4 w-4' />
|
||||
) : null}
|
||||
{data?.currentPreferences.unsubscribeUpdates
|
||||
? 'Unsubscribed from Updates'
|
||||
: 'Unsubscribe from Product Updates'}
|
||||
</Button>
|
||||
<BrandedButton
|
||||
onClick={() => handleUnsubscribe('marketing')}
|
||||
disabled={
|
||||
processing ||
|
||||
isAlreadyUnsubscribedFromAll ||
|
||||
data?.currentPreferences.unsubscribeMarketing
|
||||
}
|
||||
>
|
||||
{data?.currentPreferences.unsubscribeMarketing
|
||||
? 'Unsubscribed from Marketing'
|
||||
: 'Unsubscribe from Marketing Emails'}
|
||||
</BrandedButton>
|
||||
|
||||
<Button
|
||||
onClick={() => handleUnsubscribe('notifications')}
|
||||
disabled={
|
||||
processing ||
|
||||
data?.currentPreferences.unsubscribeAll ||
|
||||
data?.currentPreferences.unsubscribeNotifications
|
||||
}
|
||||
variant='outline'
|
||||
className='w-full'
|
||||
>
|
||||
{data?.currentPreferences.unsubscribeNotifications ? (
|
||||
<CheckCircle className='mr-2 h-4 w-4' />
|
||||
) : null}
|
||||
{data?.currentPreferences.unsubscribeNotifications
|
||||
? 'Unsubscribed from Notifications'
|
||||
: 'Unsubscribe from Notifications'}
|
||||
</Button>
|
||||
</div>
|
||||
<BrandedButton
|
||||
onClick={() => handleUnsubscribe('updates')}
|
||||
disabled={
|
||||
processing ||
|
||||
isAlreadyUnsubscribedFromAll ||
|
||||
data?.currentPreferences.unsubscribeUpdates
|
||||
}
|
||||
>
|
||||
{data?.currentPreferences.unsubscribeUpdates
|
||||
? 'Unsubscribed from Updates'
|
||||
: 'Unsubscribe from Product Updates'}
|
||||
</BrandedButton>
|
||||
|
||||
<div className='mt-6 space-y-3'>
|
||||
<div className='rounded-lg border bg-muted/50 p-3'>
|
||||
<p className='text-center text-muted-foreground text-xs'>
|
||||
<strong>Note:</strong> You'll continue receiving important account emails like
|
||||
password resets and security alerts.
|
||||
</p>
|
||||
</div>
|
||||
<BrandedButton
|
||||
onClick={() => handleUnsubscribe('notifications')}
|
||||
disabled={
|
||||
processing ||
|
||||
isAlreadyUnsubscribedFromAll ||
|
||||
data?.currentPreferences.unsubscribeNotifications
|
||||
}
|
||||
>
|
||||
{data?.currentPreferences.unsubscribeNotifications
|
||||
? 'Unsubscribed from Notifications'
|
||||
: 'Unsubscribe from Notifications'}
|
||||
</BrandedButton>
|
||||
</div>
|
||||
|
||||
<p className='text-center text-muted-foreground text-xs'>
|
||||
Questions? Contact us at{' '}
|
||||
<a
|
||||
href={`mailto:${brand.supportEmail}`}
|
||||
className='text-muted-foreground hover:underline'
|
||||
>
|
||||
{brand.supportEmail}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<div className={`${inter.className} mt-6 max-w-[410px] text-center`}>
|
||||
<p className='font-[380] text-[13px] text-muted-foreground'>
|
||||
You'll continue receiving important account emails like password resets and security
|
||||
alerts.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SupportFooter position='absolute' />
|
||||
</InviteLayout>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -391,13 +292,20 @@ export default function Unsubscribe() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className='before:-z-50 relative flex min-h-screen items-center justify-center before:pointer-events-none before:fixed before:inset-0 before:bg-white'>
|
||||
<Card className='w-full max-w-md border shadow-sm'>
|
||||
<CardContent className='flex items-center justify-center p-8'>
|
||||
<Loader2 className='h-8 w-8 animate-spin text-muted-foreground' />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<InviteLayout>
|
||||
<div className='space-y-1 text-center'>
|
||||
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
|
||||
Loading
|
||||
</h1>
|
||||
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
|
||||
Validating your unsubscribe link...
|
||||
</p>
|
||||
</div>
|
||||
<div className={`${inter.className} mt-8 flex w-full items-center justify-center py-8`}>
|
||||
<Loader2 className='h-8 w-8 animate-spin text-muted-foreground' />
|
||||
</div>
|
||||
<SupportFooter position='absolute' />
|
||||
</InviteLayout>
|
||||
}
|
||||
>
|
||||
<UnsubscribeContent />
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Button,
|
||||
Label,
|
||||
@@ -14,7 +13,7 @@ import {
|
||||
Textarea,
|
||||
} from '@/components/emcn'
|
||||
import type { DocumentData } from '@/lib/knowledge/types'
|
||||
import { knowledgeKeys } from '@/hooks/queries/knowledge'
|
||||
import { useCreateChunk } from '@/hooks/queries/knowledge'
|
||||
|
||||
const logger = createLogger('CreateChunkModal')
|
||||
|
||||
@@ -31,16 +30,15 @@ export function CreateChunkModal({
|
||||
document,
|
||||
knowledgeBaseId,
|
||||
}: CreateChunkModalProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const { mutate: createChunk, isPending: isCreating, error: mutationError } = useCreateChunk()
|
||||
const [content, setContent] = useState('')
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [showUnsavedChangesAlert, setShowUnsavedChangesAlert] = useState(false)
|
||||
const isProcessingRef = useRef(false)
|
||||
|
||||
const error = mutationError?.message ?? null
|
||||
const hasUnsavedChanges = content.trim().length > 0
|
||||
|
||||
const handleCreateChunk = async () => {
|
||||
const handleCreateChunk = () => {
|
||||
if (!document || content.trim().length === 0 || isProcessingRef.current) {
|
||||
if (isProcessingRef.current) {
|
||||
logger.warn('Chunk creation already in progress, ignoring duplicate request')
|
||||
@@ -48,56 +46,30 @@ export function CreateChunkModal({
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
isProcessingRef.current = true
|
||||
setIsCreating(true)
|
||||
setError(null)
|
||||
isProcessingRef.current = true
|
||||
|
||||
const response = await fetch(
|
||||
`/api/knowledge/${knowledgeBaseId}/documents/${document.id}/chunks`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
content: content.trim(),
|
||||
enabled: true,
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const result = await response.json()
|
||||
throw new Error(result.error || 'Failed to create chunk')
|
||||
createChunk(
|
||||
{
|
||||
knowledgeBaseId,
|
||||
documentId: document.id,
|
||||
content: content.trim(),
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
isProcessingRef.current = false
|
||||
onClose()
|
||||
},
|
||||
onError: () => {
|
||||
isProcessingRef.current = false
|
||||
},
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (result.success && result.data) {
|
||||
logger.info('Chunk created successfully:', result.data.id)
|
||||
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: knowledgeKeys.detail(knowledgeBaseId),
|
||||
})
|
||||
|
||||
onClose()
|
||||
} else {
|
||||
throw new Error(result.error || 'Failed to create chunk')
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Error creating chunk:', err)
|
||||
setError(err instanceof Error ? err.message : 'An error occurred')
|
||||
} finally {
|
||||
isProcessingRef.current = false
|
||||
setIsCreating(false)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const onClose = () => {
|
||||
onOpenChange(false)
|
||||
setContent('')
|
||||
setError(null)
|
||||
setShowUnsavedChangesAlert(false)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader } from '@/components/emcn'
|
||||
import type { ChunkData } from '@/lib/knowledge/types'
|
||||
import { knowledgeKeys } from '@/hooks/queries/knowledge'
|
||||
|
||||
const logger = createLogger('DeleteChunkModal')
|
||||
import { useDeleteChunk } from '@/hooks/queries/knowledge'
|
||||
|
||||
interface DeleteChunkModalProps {
|
||||
chunk: ChunkData | null
|
||||
@@ -24,44 +19,12 @@ export function DeleteChunkModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
}: DeleteChunkModalProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const { mutate: deleteChunk, isPending: isDeleting } = useDeleteChunk()
|
||||
|
||||
const handleDeleteChunk = async () => {
|
||||
const handleDeleteChunk = () => {
|
||||
if (!chunk || isDeleting) return
|
||||
|
||||
try {
|
||||
setIsDeleting(true)
|
||||
|
||||
const response = await fetch(
|
||||
`/api/knowledge/${knowledgeBaseId}/documents/${documentId}/chunks/${chunk.id}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete chunk')
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (result.success) {
|
||||
logger.info('Chunk deleted successfully:', chunk.id)
|
||||
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: knowledgeKeys.detail(knowledgeBaseId),
|
||||
})
|
||||
|
||||
onClose()
|
||||
} else {
|
||||
throw new Error(result.error || 'Failed to delete chunk')
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Error deleting chunk:', err)
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
}
|
||||
deleteChunk({ knowledgeBaseId, documentId, chunkId: chunk.id }, { onSuccess: onClose })
|
||||
}
|
||||
|
||||
if (!chunk) return null
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
} from '@/hooks/kb/use-knowledge-base-tag-definitions'
|
||||
import { useNextAvailableSlot } from '@/hooks/kb/use-next-available-slot'
|
||||
import { type TagDefinitionInput, useTagDefinitions } from '@/hooks/kb/use-tag-definitions'
|
||||
import { useUpdateDocumentTags } from '@/hooks/queries/knowledge'
|
||||
|
||||
const logger = createLogger('DocumentTagsModal')
|
||||
|
||||
@@ -58,8 +59,6 @@ function formatValueForDisplay(value: string, fieldType: string): string {
|
||||
try {
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return value
|
||||
// For UTC dates, display the UTC date to prevent timezone shifts
|
||||
// e.g., 2002-05-16T00:00:00.000Z should show as "May 16, 2002" not "May 15, 2002"
|
||||
if (typeof value === 'string' && (value.endsWith('Z') || /[+-]\d{2}:\d{2}$/.test(value))) {
|
||||
return new Date(
|
||||
date.getUTCFullYear(),
|
||||
@@ -96,6 +95,7 @@ export function DocumentTagsModal({
|
||||
const documentTagHook = useTagDefinitions(knowledgeBaseId, documentId)
|
||||
const kbTagHook = useKnowledgeBaseTagDefinitions(knowledgeBaseId)
|
||||
const { getNextAvailableSlot: getServerNextSlot } = useNextAvailableSlot(knowledgeBaseId)
|
||||
const { mutateAsync: updateDocumentTags } = useUpdateDocumentTags()
|
||||
|
||||
const { saveTagDefinitions, tagDefinitions, fetchTagDefinitions } = documentTagHook
|
||||
const { tagDefinitions: kbTagDefinitions, fetchTagDefinitions: refreshTagDefinitions } = kbTagHook
|
||||
@@ -118,7 +118,6 @@ export function DocumentTagsModal({
|
||||
const definition = definitions.find((def) => def.tagSlot === slot)
|
||||
|
||||
if (rawValue !== null && rawValue !== undefined && definition) {
|
||||
// Convert value to string for storage
|
||||
const stringValue = String(rawValue).trim()
|
||||
if (stringValue) {
|
||||
tags.push({
|
||||
@@ -142,41 +141,34 @@ export function DocumentTagsModal({
|
||||
async (tagsToSave: DocumentTag[]) => {
|
||||
if (!documentData) return
|
||||
|
||||
try {
|
||||
const tagData: Record<string, string> = {}
|
||||
const tagData: Record<string, string> = {}
|
||||
|
||||
// Only include tags that have values (omit empty ones)
|
||||
// Use empty string for slots that should be cleared
|
||||
ALL_TAG_SLOTS.forEach((slot) => {
|
||||
const tag = tagsToSave.find((t) => t.slot === slot)
|
||||
if (tag?.value.trim()) {
|
||||
tagData[slot] = tag.value.trim()
|
||||
} else {
|
||||
// Use empty string to clear a tag (API schema expects string, not null)
|
||||
tagData[slot] = ''
|
||||
}
|
||||
})
|
||||
|
||||
const response = await fetch(`/api/knowledge/${knowledgeBaseId}/documents/${documentId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(tagData),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to update document tags')
|
||||
ALL_TAG_SLOTS.forEach((slot) => {
|
||||
const tag = tagsToSave.find((t) => t.slot === slot)
|
||||
if (tag?.value.trim()) {
|
||||
tagData[slot] = tag.value.trim()
|
||||
} else {
|
||||
tagData[slot] = ''
|
||||
}
|
||||
})
|
||||
|
||||
onDocumentUpdate?.(tagData as Record<string, string>)
|
||||
await fetchTagDefinitions()
|
||||
} catch (error) {
|
||||
logger.error('Error updating document tags:', error)
|
||||
throw error
|
||||
}
|
||||
await updateDocumentTags({
|
||||
knowledgeBaseId,
|
||||
documentId,
|
||||
tags: tagData,
|
||||
})
|
||||
|
||||
onDocumentUpdate?.(tagData)
|
||||
await fetchTagDefinitions()
|
||||
},
|
||||
[documentData, knowledgeBaseId, documentId, fetchTagDefinitions, onDocumentUpdate]
|
||||
[
|
||||
documentData,
|
||||
knowledgeBaseId,
|
||||
documentId,
|
||||
updateDocumentTags,
|
||||
fetchTagDefinitions,
|
||||
onDocumentUpdate,
|
||||
]
|
||||
)
|
||||
|
||||
const handleRemoveTag = async (index: number) => {
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { ChevronDown, ChevronUp } from 'lucide-react'
|
||||
import {
|
||||
Button,
|
||||
@@ -19,7 +18,7 @@ import {
|
||||
import type { ChunkData, DocumentData } from '@/lib/knowledge/types'
|
||||
import { getAccurateTokenCount, getTokenStrings } from '@/lib/tokenization/estimators'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import { knowledgeKeys } from '@/hooks/queries/knowledge'
|
||||
import { useUpdateChunk } from '@/hooks/queries/knowledge'
|
||||
|
||||
const logger = createLogger('EditChunkModal')
|
||||
|
||||
@@ -50,17 +49,17 @@ export function EditChunkModal({
|
||||
onNavigateToPage,
|
||||
maxChunkSize,
|
||||
}: EditChunkModalProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const userPermissions = useUserPermissionsContext()
|
||||
const { mutate: updateChunk, isPending: isSaving, error: mutationError } = useUpdateChunk()
|
||||
const [editedContent, setEditedContent] = useState(chunk?.content || '')
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [isNavigating, setIsNavigating] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [showUnsavedChangesAlert, setShowUnsavedChangesAlert] = useState(false)
|
||||
const [pendingNavigation, setPendingNavigation] = useState<(() => void) | null>(null)
|
||||
const [tokenizerOn, setTokenizerOn] = useState(false)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
const error = mutationError?.message ?? null
|
||||
|
||||
const hasUnsavedChanges = editedContent !== (chunk?.content || '')
|
||||
|
||||
const tokenStrings = useMemo(() => {
|
||||
@@ -102,44 +101,15 @@ export function EditChunkModal({
|
||||
const canNavigatePrev = currentChunkIndex > 0 || currentPage > 1
|
||||
const canNavigateNext = currentChunkIndex < allChunks.length - 1 || currentPage < totalPages
|
||||
|
||||
const handleSaveContent = async () => {
|
||||
const handleSaveContent = () => {
|
||||
if (!chunk || !document) return
|
||||
|
||||
try {
|
||||
setIsSaving(true)
|
||||
setError(null)
|
||||
|
||||
const response = await fetch(
|
||||
`/api/knowledge/${knowledgeBaseId}/documents/${document.id}/chunks/${chunk.id}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
content: editedContent,
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const result = await response.json()
|
||||
throw new Error(result.error || 'Failed to update chunk')
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (result.success) {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: knowledgeKeys.detail(knowledgeBaseId),
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Error updating chunk:', err)
|
||||
setError(err instanceof Error ? err.message : 'An error occurred')
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
updateChunk({
|
||||
knowledgeBaseId,
|
||||
documentId: document.id,
|
||||
chunkId: chunk.id,
|
||||
content: editedContent,
|
||||
})
|
||||
}
|
||||
|
||||
const navigateToChunk = async (direction: 'prev' | 'next') => {
|
||||
@@ -165,7 +135,6 @@ export function EditChunkModal({
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(`Error navigating ${direction}:`, err)
|
||||
setError(`Failed to navigate to ${direction === 'prev' ? 'previous' : 'next'} chunk`)
|
||||
} finally {
|
||||
setIsNavigating(false)
|
||||
}
|
||||
|
||||
@@ -48,7 +48,13 @@ import { ActionBar } from '@/app/workspace/[workspaceId]/knowledge/[id]/componen
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
|
||||
import { useDocument, useDocumentChunks, useKnowledgeBase } from '@/hooks/kb/use-knowledge'
|
||||
import { knowledgeKeys, useDocumentChunkSearchQuery } from '@/hooks/queries/knowledge'
|
||||
import {
|
||||
knowledgeKeys,
|
||||
useBulkChunkOperation,
|
||||
useDeleteDocument,
|
||||
useDocumentChunkSearchQuery,
|
||||
useUpdateChunk,
|
||||
} from '@/hooks/queries/knowledge'
|
||||
|
||||
const logger = createLogger('Document')
|
||||
|
||||
@@ -403,11 +409,13 @@ export function Document({
|
||||
const [isCreateChunkModalOpen, setIsCreateChunkModalOpen] = useState(false)
|
||||
const [chunkToDelete, setChunkToDelete] = useState<ChunkData | null>(null)
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
|
||||
const [isBulkOperating, setIsBulkOperating] = useState(false)
|
||||
const [showDeleteDocumentDialog, setShowDeleteDocumentDialog] = useState(false)
|
||||
const [isDeletingDocument, setIsDeletingDocument] = useState(false)
|
||||
const [contextMenuChunk, setContextMenuChunk] = useState<ChunkData | null>(null)
|
||||
|
||||
const { mutate: updateChunkMutation } = useUpdateChunk()
|
||||
const { mutate: deleteDocumentMutation, isPending: isDeletingDocument } = useDeleteDocument()
|
||||
const { mutate: bulkChunkMutation, isPending: isBulkOperating } = useBulkChunkOperation()
|
||||
|
||||
const {
|
||||
isOpen: isContextMenuOpen,
|
||||
position: contextMenuPosition,
|
||||
@@ -440,36 +448,23 @@ export function Document({
|
||||
setSelectedChunk(null)
|
||||
}
|
||||
|
||||
const handleToggleEnabled = async (chunkId: string) => {
|
||||
const handleToggleEnabled = (chunkId: string) => {
|
||||
const chunk = displayChunks.find((c) => c.id === chunkId)
|
||||
if (!chunk) return
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/knowledge/${knowledgeBaseId}/documents/${documentId}/chunks/${chunkId}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
enabled: !chunk.enabled,
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to update chunk')
|
||||
updateChunkMutation(
|
||||
{
|
||||
knowledgeBaseId,
|
||||
documentId,
|
||||
chunkId,
|
||||
enabled: !chunk.enabled,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
updateChunk(chunkId, { enabled: !chunk.enabled })
|
||||
},
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (result.success) {
|
||||
updateChunk(chunkId, { enabled: !chunk.enabled })
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Error updating chunk:', err)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const handleDeleteChunk = (chunkId: string) => {
|
||||
@@ -515,107 +510,69 @@ export function Document({
|
||||
/**
|
||||
* Handles deleting the document
|
||||
*/
|
||||
const handleDeleteDocument = async () => {
|
||||
const handleDeleteDocument = () => {
|
||||
if (!documentData) return
|
||||
|
||||
try {
|
||||
setIsDeletingDocument(true)
|
||||
|
||||
const response = await fetch(`/api/knowledge/${knowledgeBaseId}/documents/${documentId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete document')
|
||||
deleteDocumentMutation(
|
||||
{ knowledgeBaseId, documentId },
|
||||
{
|
||||
onSuccess: () => {
|
||||
router.push(`/workspace/${workspaceId}/knowledge/${knowledgeBaseId}`)
|
||||
},
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (result.success) {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: knowledgeKeys.detail(knowledgeBaseId),
|
||||
})
|
||||
|
||||
router.push(`/workspace/${workspaceId}/knowledge/${knowledgeBaseId}`)
|
||||
} else {
|
||||
throw new Error(result.error || 'Failed to delete document')
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Error deleting document:', err)
|
||||
setIsDeletingDocument(false)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const performBulkChunkOperation = async (
|
||||
const performBulkChunkOperation = (
|
||||
operation: 'enable' | 'disable' | 'delete',
|
||||
chunks: ChunkData[]
|
||||
) => {
|
||||
if (chunks.length === 0) return
|
||||
|
||||
try {
|
||||
setIsBulkOperating(true)
|
||||
|
||||
const response = await fetch(
|
||||
`/api/knowledge/${knowledgeBaseId}/documents/${documentId}/chunks`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
operation,
|
||||
chunkIds: chunks.map((chunk) => chunk.id),
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to ${operation} chunks`)
|
||||
bulkChunkMutation(
|
||||
{
|
||||
knowledgeBaseId,
|
||||
documentId,
|
||||
operation,
|
||||
chunkIds: chunks.map((chunk) => chunk.id),
|
||||
},
|
||||
{
|
||||
onSuccess: (result) => {
|
||||
if (operation === 'delete') {
|
||||
refreshChunks()
|
||||
} else {
|
||||
result.results.forEach((opResult) => {
|
||||
if (opResult.operation === operation) {
|
||||
opResult.chunkIds.forEach((chunkId: string) => {
|
||||
updateChunk(chunkId, { enabled: operation === 'enable' })
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
logger.info(`Successfully ${operation}d ${result.successCount} chunks`)
|
||||
setSelectedChunks(new Set())
|
||||
},
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (result.success) {
|
||||
if (operation === 'delete') {
|
||||
await refreshChunks()
|
||||
} else {
|
||||
result.data.results.forEach((opResult: any) => {
|
||||
if (opResult.operation === operation) {
|
||||
opResult.chunkIds.forEach((chunkId: string) => {
|
||||
updateChunk(chunkId, { enabled: operation === 'enable' })
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
logger.info(`Successfully ${operation}d ${result.data.successCount} chunks`)
|
||||
}
|
||||
|
||||
setSelectedChunks(new Set())
|
||||
} catch (err) {
|
||||
logger.error(`Error ${operation}ing chunks:`, err)
|
||||
} finally {
|
||||
setIsBulkOperating(false)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const handleBulkEnable = async () => {
|
||||
const handleBulkEnable = () => {
|
||||
const chunksToEnable = displayChunks.filter(
|
||||
(chunk) => selectedChunks.has(chunk.id) && !chunk.enabled
|
||||
)
|
||||
await performBulkChunkOperation('enable', chunksToEnable)
|
||||
performBulkChunkOperation('enable', chunksToEnable)
|
||||
}
|
||||
|
||||
const handleBulkDisable = async () => {
|
||||
const handleBulkDisable = () => {
|
||||
const chunksToDisable = displayChunks.filter(
|
||||
(chunk) => selectedChunks.has(chunk.id) && chunk.enabled
|
||||
)
|
||||
await performBulkChunkOperation('disable', chunksToDisable)
|
||||
performBulkChunkOperation('disable', chunksToDisable)
|
||||
}
|
||||
|
||||
const handleBulkDelete = async () => {
|
||||
const handleBulkDelete = () => {
|
||||
const chunksToDelete = displayChunks.filter((chunk) => selectedChunks.has(chunk.id))
|
||||
await performBulkChunkOperation('delete', chunksToDelete)
|
||||
performBulkChunkOperation('delete', chunksToDelete)
|
||||
}
|
||||
|
||||
const selectedChunksList = displayChunks.filter((chunk) => selectedChunks.has(chunk.id))
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { format } from 'date-fns'
|
||||
import {
|
||||
AlertCircle,
|
||||
@@ -62,7 +61,12 @@ import {
|
||||
type TagDefinition,
|
||||
useKnowledgeBaseTagDefinitions,
|
||||
} from '@/hooks/kb/use-knowledge-base-tag-definitions'
|
||||
import { knowledgeKeys } from '@/hooks/queries/knowledge'
|
||||
import {
|
||||
useBulkDocumentOperation,
|
||||
useDeleteDocument,
|
||||
useDeleteKnowledgeBase,
|
||||
useUpdateDocument,
|
||||
} from '@/hooks/queries/knowledge'
|
||||
|
||||
const logger = createLogger('KnowledgeBase')
|
||||
|
||||
@@ -407,12 +411,17 @@ export function KnowledgeBase({
|
||||
id,
|
||||
knowledgeBaseName: passedKnowledgeBaseName,
|
||||
}: KnowledgeBaseProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
const { removeKnowledgeBase } = useKnowledgeBasesList(workspaceId, { enabled: false })
|
||||
const userPermissions = useUserPermissionsContext()
|
||||
|
||||
const { mutate: updateDocumentMutation } = useUpdateDocument()
|
||||
const { mutate: deleteDocumentMutation } = useDeleteDocument()
|
||||
const { mutate: deleteKnowledgeBaseMutation, isPending: isDeleting } =
|
||||
useDeleteKnowledgeBase(workspaceId)
|
||||
const { mutate: bulkDocumentMutation, isPending: isBulkOperating } = useBulkDocumentOperation()
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [showTagsModal, setShowTagsModal] = useState(false)
|
||||
|
||||
@@ -427,8 +436,6 @@ export function KnowledgeBase({
|
||||
const [selectedDocuments, setSelectedDocuments] = useState<Set<string>>(new Set())
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||
const [showAddDocumentsModal, setShowAddDocumentsModal] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const [isBulkOperating, setIsBulkOperating] = useState(false)
|
||||
const [showDeleteDocumentModal, setShowDeleteDocumentModal] = useState(false)
|
||||
const [documentToDelete, setDocumentToDelete] = useState<string | null>(null)
|
||||
const [showBulkDeleteModal, setShowBulkDeleteModal] = useState(false)
|
||||
@@ -550,7 +557,7 @@ export function KnowledgeBase({
|
||||
/**
|
||||
* Checks for documents with stale processing states and marks them as failed
|
||||
*/
|
||||
const checkForDeadProcesses = async () => {
|
||||
const checkForDeadProcesses = () => {
|
||||
const now = new Date()
|
||||
const DEAD_PROCESS_THRESHOLD_MS = 600 * 1000 // 10 minutes
|
||||
|
||||
@@ -567,116 +574,79 @@ export function KnowledgeBase({
|
||||
|
||||
logger.warn(`Found ${staleDocuments.length} documents with dead processes`)
|
||||
|
||||
const markFailedPromises = staleDocuments.map(async (doc) => {
|
||||
try {
|
||||
const response = await fetch(`/api/knowledge/${id}/documents/${doc.id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
staleDocuments.forEach((doc) => {
|
||||
updateDocumentMutation(
|
||||
{
|
||||
knowledgeBaseId: id,
|
||||
documentId: doc.id,
|
||||
updates: { markFailedDueToTimeout: true },
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
logger.info(`Successfully marked dead process as failed for document: ${doc.filename}`)
|
||||
},
|
||||
body: JSON.stringify({
|
||||
markFailedDueToTimeout: true,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ error: 'Unknown error' }))
|
||||
logger.error(`Failed to mark document ${doc.id} as failed: ${errorData.error}`)
|
||||
return
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
if (result.success) {
|
||||
logger.info(`Successfully marked dead process as failed for document: ${doc.filename}`)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Error marking document ${doc.id} as failed:`, error)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
await Promise.allSettled(markFailedPromises)
|
||||
}
|
||||
|
||||
const handleToggleEnabled = async (docId: string) => {
|
||||
const handleToggleEnabled = (docId: string) => {
|
||||
const document = documents.find((doc) => doc.id === docId)
|
||||
if (!document) return
|
||||
|
||||
const newEnabled = !document.enabled
|
||||
|
||||
// Optimistic update
|
||||
updateDocument(docId, { enabled: newEnabled })
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/knowledge/${id}/documents/${docId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
updateDocumentMutation(
|
||||
{
|
||||
knowledgeBaseId: id,
|
||||
documentId: docId,
|
||||
updates: { enabled: newEnabled },
|
||||
},
|
||||
{
|
||||
onError: () => {
|
||||
// Rollback on error
|
||||
updateDocument(docId, { enabled: !newEnabled })
|
||||
},
|
||||
body: JSON.stringify({
|
||||
enabled: newEnabled,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to update document')
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (!result.success) {
|
||||
updateDocument(docId, { enabled: !newEnabled })
|
||||
}
|
||||
} catch (err) {
|
||||
updateDocument(docId, { enabled: !newEnabled })
|
||||
logger.error('Error updating document:', err)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles retrying a failed document processing
|
||||
*/
|
||||
const handleRetryDocument = async (docId: string) => {
|
||||
try {
|
||||
updateDocument(docId, {
|
||||
processingStatus: 'pending',
|
||||
processingError: null,
|
||||
processingStartedAt: null,
|
||||
processingCompletedAt: null,
|
||||
})
|
||||
const handleRetryDocument = (docId: string) => {
|
||||
// Optimistic update
|
||||
updateDocument(docId, {
|
||||
processingStatus: 'pending',
|
||||
processingError: null,
|
||||
processingStartedAt: null,
|
||||
processingCompletedAt: null,
|
||||
})
|
||||
|
||||
const response = await fetch(`/api/knowledge/${id}/documents/${docId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
updateDocumentMutation(
|
||||
{
|
||||
knowledgeBaseId: id,
|
||||
documentId: docId,
|
||||
updates: { retryProcessing: true },
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
refreshDocuments()
|
||||
logger.info(`Document retry initiated successfully for: ${docId}`)
|
||||
},
|
||||
onError: (err) => {
|
||||
logger.error('Error retrying document:', err)
|
||||
updateDocument(docId, {
|
||||
processingStatus: 'failed',
|
||||
processingError:
|
||||
err instanceof Error ? err.message : 'Failed to retry document processing',
|
||||
})
|
||||
},
|
||||
body: JSON.stringify({
|
||||
retryProcessing: true,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to retry document processing')
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to retry document processing')
|
||||
}
|
||||
|
||||
await refreshDocuments()
|
||||
|
||||
logger.info(`Document retry initiated successfully for: ${docId}`)
|
||||
} catch (err) {
|
||||
logger.error('Error retrying document:', err)
|
||||
const currentDoc = documents.find((doc) => doc.id === docId)
|
||||
if (currentDoc) {
|
||||
updateDocument(docId, {
|
||||
processingStatus: 'failed',
|
||||
processingError:
|
||||
err instanceof Error ? err.message : 'Failed to retry document processing',
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -694,43 +664,32 @@ export function KnowledgeBase({
|
||||
const currentDoc = documents.find((doc) => doc.id === documentId)
|
||||
const previousName = currentDoc?.filename
|
||||
|
||||
// Optimistic update
|
||||
updateDocument(documentId, { filename: newName })
|
||||
queryClient.setQueryData<DocumentData>(knowledgeKeys.document(id, documentId), (previous) =>
|
||||
previous ? { ...previous, filename: newName } : previous
|
||||
)
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/knowledge/${id}/documents/${documentId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
updateDocumentMutation(
|
||||
{
|
||||
knowledgeBaseId: id,
|
||||
documentId,
|
||||
updates: { filename: newName },
|
||||
},
|
||||
body: JSON.stringify({ filename: newName }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const result = await response.json()
|
||||
throw new Error(result.error || 'Failed to rename document')
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to rename document')
|
||||
}
|
||||
|
||||
logger.info(`Document renamed: ${documentId}`)
|
||||
} catch (err) {
|
||||
if (previousName !== undefined) {
|
||||
updateDocument(documentId, { filename: previousName })
|
||||
queryClient.setQueryData<DocumentData>(
|
||||
knowledgeKeys.document(id, documentId),
|
||||
(previous) => (previous ? { ...previous, filename: previousName } : previous)
|
||||
)
|
||||
}
|
||||
logger.error('Error renaming document:', err)
|
||||
throw err
|
||||
}
|
||||
{
|
||||
onSuccess: () => {
|
||||
logger.info(`Document renamed: ${documentId}`)
|
||||
resolve()
|
||||
},
|
||||
onError: (err) => {
|
||||
// Rollback on error
|
||||
if (previousName !== undefined) {
|
||||
updateDocument(documentId, { filename: previousName })
|
||||
}
|
||||
logger.error('Error renaming document:', err)
|
||||
reject(err)
|
||||
},
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -744,35 +703,26 @@ export function KnowledgeBase({
|
||||
/**
|
||||
* Confirms and executes the deletion of a single document
|
||||
*/
|
||||
const confirmDeleteDocument = async () => {
|
||||
const confirmDeleteDocument = () => {
|
||||
if (!documentToDelete) return
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/knowledge/${id}/documents/${documentToDelete}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete document')
|
||||
deleteDocumentMutation(
|
||||
{ knowledgeBaseId: id, documentId: documentToDelete },
|
||||
{
|
||||
onSuccess: () => {
|
||||
refreshDocuments()
|
||||
setSelectedDocuments((prev) => {
|
||||
const newSet = new Set(prev)
|
||||
newSet.delete(documentToDelete)
|
||||
return newSet
|
||||
})
|
||||
},
|
||||
onSettled: () => {
|
||||
setShowDeleteDocumentModal(false)
|
||||
setDocumentToDelete(null)
|
||||
},
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (result.success) {
|
||||
refreshDocuments()
|
||||
|
||||
setSelectedDocuments((prev) => {
|
||||
const newSet = new Set(prev)
|
||||
newSet.delete(documentToDelete)
|
||||
return newSet
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Error deleting document:', err)
|
||||
} finally {
|
||||
setShowDeleteDocumentModal(false)
|
||||
setDocumentToDelete(null)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -818,32 +768,18 @@ export function KnowledgeBase({
|
||||
/**
|
||||
* Handles deleting the entire knowledge base
|
||||
*/
|
||||
const handleDeleteKnowledgeBase = async () => {
|
||||
const handleDeleteKnowledgeBase = () => {
|
||||
if (!knowledgeBase) return
|
||||
|
||||
try {
|
||||
setIsDeleting(true)
|
||||
|
||||
const response = await fetch(`/api/knowledge/${id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete knowledge base')
|
||||
deleteKnowledgeBaseMutation(
|
||||
{ knowledgeBaseId: id },
|
||||
{
|
||||
onSuccess: () => {
|
||||
removeKnowledgeBase(id)
|
||||
router.push(`/workspace/${workspaceId}/knowledge`)
|
||||
},
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (result.success) {
|
||||
removeKnowledgeBase(id)
|
||||
router.push(`/workspace/${workspaceId}/knowledge`)
|
||||
} else {
|
||||
throw new Error(result.error || 'Failed to delete knowledge base')
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Error deleting knowledge base:', err)
|
||||
setIsDeleting(false)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -856,93 +792,57 @@ export function KnowledgeBase({
|
||||
/**
|
||||
* Handles bulk enabling of selected documents
|
||||
*/
|
||||
const handleBulkEnable = async () => {
|
||||
const handleBulkEnable = () => {
|
||||
const documentsToEnable = documents.filter(
|
||||
(doc) => selectedDocuments.has(doc.id) && !doc.enabled
|
||||
)
|
||||
|
||||
if (documentsToEnable.length === 0) return
|
||||
|
||||
try {
|
||||
setIsBulkOperating(true)
|
||||
|
||||
const response = await fetch(`/api/knowledge/${id}/documents`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
bulkDocumentMutation(
|
||||
{
|
||||
knowledgeBaseId: id,
|
||||
operation: 'enable',
|
||||
documentIds: documentsToEnable.map((doc) => doc.id),
|
||||
},
|
||||
{
|
||||
onSuccess: (result) => {
|
||||
result.updatedDocuments?.forEach((updatedDoc) => {
|
||||
updateDocument(updatedDoc.id, { enabled: updatedDoc.enabled })
|
||||
})
|
||||
logger.info(`Successfully enabled ${result.successCount} documents`)
|
||||
setSelectedDocuments(new Set())
|
||||
},
|
||||
body: JSON.stringify({
|
||||
operation: 'enable',
|
||||
documentIds: documentsToEnable.map((doc) => doc.id),
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to enable documents')
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (result.success) {
|
||||
result.data.updatedDocuments.forEach((updatedDoc: { id: string; enabled: boolean }) => {
|
||||
updateDocument(updatedDoc.id, { enabled: updatedDoc.enabled })
|
||||
})
|
||||
|
||||
logger.info(`Successfully enabled ${result.data.successCount} documents`)
|
||||
}
|
||||
|
||||
setSelectedDocuments(new Set())
|
||||
} catch (err) {
|
||||
logger.error('Error enabling documents:', err)
|
||||
} finally {
|
||||
setIsBulkOperating(false)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles bulk disabling of selected documents
|
||||
*/
|
||||
const handleBulkDisable = async () => {
|
||||
const handleBulkDisable = () => {
|
||||
const documentsToDisable = documents.filter(
|
||||
(doc) => selectedDocuments.has(doc.id) && doc.enabled
|
||||
)
|
||||
|
||||
if (documentsToDisable.length === 0) return
|
||||
|
||||
try {
|
||||
setIsBulkOperating(true)
|
||||
|
||||
const response = await fetch(`/api/knowledge/${id}/documents`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
bulkDocumentMutation(
|
||||
{
|
||||
knowledgeBaseId: id,
|
||||
operation: 'disable',
|
||||
documentIds: documentsToDisable.map((doc) => doc.id),
|
||||
},
|
||||
{
|
||||
onSuccess: (result) => {
|
||||
result.updatedDocuments?.forEach((updatedDoc) => {
|
||||
updateDocument(updatedDoc.id, { enabled: updatedDoc.enabled })
|
||||
})
|
||||
logger.info(`Successfully disabled ${result.successCount} documents`)
|
||||
setSelectedDocuments(new Set())
|
||||
},
|
||||
body: JSON.stringify({
|
||||
operation: 'disable',
|
||||
documentIds: documentsToDisable.map((doc) => doc.id),
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to disable documents')
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (result.success) {
|
||||
result.data.updatedDocuments.forEach((updatedDoc: { id: string; enabled: boolean }) => {
|
||||
updateDocument(updatedDoc.id, { enabled: updatedDoc.enabled })
|
||||
})
|
||||
|
||||
logger.info(`Successfully disabled ${result.data.successCount} documents`)
|
||||
}
|
||||
|
||||
setSelectedDocuments(new Set())
|
||||
} catch (err) {
|
||||
logger.error('Error disabling documents:', err)
|
||||
} finally {
|
||||
setIsBulkOperating(false)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -956,44 +856,28 @@ export function KnowledgeBase({
|
||||
/**
|
||||
* Confirms and executes the bulk deletion of selected documents
|
||||
*/
|
||||
const confirmBulkDelete = async () => {
|
||||
const confirmBulkDelete = () => {
|
||||
const documentsToDelete = documents.filter((doc) => selectedDocuments.has(doc.id))
|
||||
|
||||
if (documentsToDelete.length === 0) return
|
||||
|
||||
try {
|
||||
setIsBulkOperating(true)
|
||||
|
||||
const response = await fetch(`/api/knowledge/${id}/documents`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
bulkDocumentMutation(
|
||||
{
|
||||
knowledgeBaseId: id,
|
||||
operation: 'delete',
|
||||
documentIds: documentsToDelete.map((doc) => doc.id),
|
||||
},
|
||||
{
|
||||
onSuccess: (result) => {
|
||||
logger.info(`Successfully deleted ${result.successCount} documents`)
|
||||
refreshDocuments()
|
||||
setSelectedDocuments(new Set())
|
||||
},
|
||||
onSettled: () => {
|
||||
setShowBulkDeleteModal(false)
|
||||
},
|
||||
body: JSON.stringify({
|
||||
operation: 'delete',
|
||||
documentIds: documentsToDelete.map((doc) => doc.id),
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete documents')
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (result.success) {
|
||||
logger.info(`Successfully deleted ${result.data.successCount} documents`)
|
||||
}
|
||||
|
||||
await refreshDocuments()
|
||||
|
||||
setSelectedDocuments(new Set())
|
||||
} catch (err) {
|
||||
logger.error('Error deleting documents:', err)
|
||||
} finally {
|
||||
setIsBulkOperating(false)
|
||||
setShowBulkDeleteModal(false)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const selectedDocumentsList = documents.filter((doc) => selectedDocuments.has(doc.id))
|
||||
|
||||
@@ -22,10 +22,10 @@ import {
|
||||
type TagDefinition,
|
||||
useKnowledgeBaseTagDefinitions,
|
||||
} from '@/hooks/kb/use-knowledge-base-tag-definitions'
|
||||
import { useCreateTagDefinition, useDeleteTagDefinition } from '@/hooks/queries/knowledge'
|
||||
|
||||
const logger = createLogger('BaseTagsModal')
|
||||
|
||||
/** Field type display labels */
|
||||
const FIELD_TYPE_LABELS: Record<string, string> = {
|
||||
text: 'Text',
|
||||
number: 'Number',
|
||||
@@ -45,7 +45,6 @@ interface DocumentListProps {
|
||||
totalCount: number
|
||||
}
|
||||
|
||||
/** Displays a list of documents affected by tag operations */
|
||||
function DocumentList({ documents, totalCount }: DocumentListProps) {
|
||||
const displayLimit = 5
|
||||
const hasMore = totalCount > displayLimit
|
||||
@@ -95,13 +94,14 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
|
||||
const { tagDefinitions: kbTagDefinitions, fetchTagDefinitions: refreshTagDefinitions } =
|
||||
useKnowledgeBaseTagDefinitions(knowledgeBaseId)
|
||||
|
||||
const createTagMutation = useCreateTagDefinition()
|
||||
const deleteTagMutation = useDeleteTagDefinition()
|
||||
|
||||
const [deleteTagDialogOpen, setDeleteTagDialogOpen] = useState(false)
|
||||
const [selectedTag, setSelectedTag] = useState<TagDefinition | null>(null)
|
||||
const [viewDocumentsDialogOpen, setViewDocumentsDialogOpen] = useState(false)
|
||||
const [isDeletingTag, setIsDeletingTag] = useState(false)
|
||||
const [tagUsageData, setTagUsageData] = useState<TagUsageData[]>([])
|
||||
const [isCreatingTag, setIsCreatingTag] = useState(false)
|
||||
const [isSavingTag, setIsSavingTag] = useState(false)
|
||||
const [createTagForm, setCreateTagForm] = useState({
|
||||
displayName: '',
|
||||
fieldType: 'text',
|
||||
@@ -177,13 +177,12 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
|
||||
}
|
||||
|
||||
const tagNameConflict =
|
||||
isCreatingTag && !isSavingTag && hasTagNameConflict(createTagForm.displayName)
|
||||
isCreatingTag && !createTagMutation.isPending && hasTagNameConflict(createTagForm.displayName)
|
||||
|
||||
const canSaveTag = () => {
|
||||
return createTagForm.displayName.trim() && !hasTagNameConflict(createTagForm.displayName)
|
||||
}
|
||||
|
||||
/** Get slot usage counts per field type */
|
||||
const getSlotUsageByFieldType = (fieldType: string): { used: number; max: number } => {
|
||||
const config = TAG_SLOT_CONFIG[fieldType as keyof typeof TAG_SLOT_CONFIG]
|
||||
if (!config) return { used: 0, max: 0 }
|
||||
@@ -191,13 +190,11 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
|
||||
return { used, max: config.maxSlots }
|
||||
}
|
||||
|
||||
/** Check if a field type has available slots */
|
||||
const hasAvailableSlots = (fieldType: string): boolean => {
|
||||
const { used, max } = getSlotUsageByFieldType(fieldType)
|
||||
return used < max
|
||||
}
|
||||
|
||||
/** Field type options for Combobox */
|
||||
const fieldTypeOptions: ComboboxOption[] = useMemo(() => {
|
||||
return SUPPORTED_FIELD_TYPES.filter((type) => hasAvailableSlots(type)).map((type) => {
|
||||
const { used, max } = getSlotUsageByFieldType(type)
|
||||
@@ -211,43 +208,17 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
|
||||
const saveTagDefinition = async () => {
|
||||
if (!canSaveTag()) return
|
||||
|
||||
setIsSavingTag(true)
|
||||
try {
|
||||
// Check if selected field type has available slots
|
||||
if (!hasAvailableSlots(createTagForm.fieldType)) {
|
||||
throw new Error(`No available slots for ${createTagForm.fieldType} type`)
|
||||
}
|
||||
|
||||
// Get the next available slot from the API
|
||||
const slotResponse = await fetch(
|
||||
`/api/knowledge/${knowledgeBaseId}/next-available-slot?fieldType=${createTagForm.fieldType}`
|
||||
)
|
||||
if (!slotResponse.ok) {
|
||||
throw new Error('Failed to get available slot')
|
||||
}
|
||||
const slotResult = await slotResponse.json()
|
||||
if (!slotResult.success || !slotResult.data?.nextAvailableSlot) {
|
||||
throw new Error('No available tag slots for this field type')
|
||||
}
|
||||
|
||||
const newTagDefinition = {
|
||||
tagSlot: slotResult.data.nextAvailableSlot,
|
||||
await createTagMutation.mutateAsync({
|
||||
knowledgeBaseId,
|
||||
displayName: createTagForm.displayName.trim(),
|
||||
fieldType: createTagForm.fieldType,
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/knowledge/${knowledgeBaseId}/tag-definitions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(newTagDefinition),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to create tag definition')
|
||||
}
|
||||
|
||||
await Promise.all([refreshTagDefinitions(), fetchTagUsage()])
|
||||
|
||||
setCreateTagForm({
|
||||
@@ -257,27 +228,17 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
|
||||
setIsCreatingTag(false)
|
||||
} catch (error) {
|
||||
logger.error('Error creating tag definition:', error)
|
||||
} finally {
|
||||
setIsSavingTag(false)
|
||||
}
|
||||
}
|
||||
|
||||
const confirmDeleteTag = async () => {
|
||||
if (!selectedTag) return
|
||||
|
||||
setIsDeletingTag(true)
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/knowledge/${knowledgeBaseId}/tag-definitions/${selectedTag.id}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
throw new Error(`Failed to delete tag definition: ${response.status} ${errorText}`)
|
||||
}
|
||||
await deleteTagMutation.mutateAsync({
|
||||
knowledgeBaseId,
|
||||
tagDefinitionId: selectedTag.id,
|
||||
})
|
||||
|
||||
await Promise.all([refreshTagDefinitions(), fetchTagUsage()])
|
||||
|
||||
@@ -285,8 +246,6 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
|
||||
setSelectedTag(null)
|
||||
} catch (error) {
|
||||
logger.error('Error deleting tag definition:', error)
|
||||
} finally {
|
||||
setIsDeletingTag(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -433,11 +392,11 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
|
||||
className='flex-1'
|
||||
disabled={
|
||||
!canSaveTag() ||
|
||||
isSavingTag ||
|
||||
createTagMutation.isPending ||
|
||||
!hasAvailableSlots(createTagForm.fieldType)
|
||||
}
|
||||
>
|
||||
{isSavingTag ? 'Creating...' : 'Create Tag'}
|
||||
{createTagMutation.isPending ? 'Creating...' : 'Create Tag'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -481,13 +440,17 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
|
||||
<ModalFooter>
|
||||
<Button
|
||||
variant='default'
|
||||
disabled={isDeletingTag}
|
||||
disabled={deleteTagMutation.isPending}
|
||||
onClick={() => setDeleteTagDialogOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant='destructive' onClick={confirmDeleteTag} disabled={isDeletingTag}>
|
||||
{isDeletingTag ? <>Deleting...</> : 'Delete Tag'}
|
||||
<Button
|
||||
variant='destructive'
|
||||
onClick={confirmDeleteTag}
|
||||
disabled={deleteTagMutation.isPending}
|
||||
>
|
||||
{deleteTagMutation.isPending ? 'Deleting...' : 'Delete Tag'}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { Loader2, RotateCcw, X } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { useForm } from 'react-hook-form'
|
||||
@@ -23,7 +22,7 @@ import { cn } from '@/lib/core/utils/cn'
|
||||
import { formatFileSize, validateKnowledgeBaseFile } from '@/lib/uploads/utils/file-utils'
|
||||
import { ACCEPT_ATTRIBUTE } from '@/lib/uploads/utils/validation'
|
||||
import { useKnowledgeUpload } from '@/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload'
|
||||
import { knowledgeKeys } from '@/hooks/queries/knowledge'
|
||||
import { useCreateKnowledgeBase, useDeleteKnowledgeBase } from '@/hooks/queries/knowledge'
|
||||
|
||||
const logger = createLogger('CreateBaseModal')
|
||||
|
||||
@@ -82,10 +81,11 @@ interface SubmitStatus {
|
||||
export function CreateBaseModal({ open, onOpenChange }: CreateBaseModalProps) {
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const createKnowledgeBaseMutation = useCreateKnowledgeBase(workspaceId)
|
||||
const deleteKnowledgeBaseMutation = useDeleteKnowledgeBase(workspaceId)
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [submitStatus, setSubmitStatus] = useState<SubmitStatus | null>(null)
|
||||
const [files, setFiles] = useState<FileWithPreview[]>([])
|
||||
const [fileError, setFileError] = useState<string | null>(null)
|
||||
@@ -245,12 +245,14 @@ export function CreateBaseModal({ open, onOpenChange }: CreateBaseModalProps) {
|
||||
})
|
||||
}
|
||||
|
||||
const isSubmitting =
|
||||
createKnowledgeBaseMutation.isPending || deleteKnowledgeBaseMutation.isPending || isUploading
|
||||
|
||||
const onSubmit = async (data: FormValues) => {
|
||||
setIsSubmitting(true)
|
||||
setSubmitStatus(null)
|
||||
|
||||
try {
|
||||
const knowledgeBasePayload = {
|
||||
const newKnowledgeBase = await createKnowledgeBaseMutation.mutateAsync({
|
||||
name: data.name,
|
||||
description: data.description || undefined,
|
||||
workspaceId: workspaceId,
|
||||
@@ -259,29 +261,8 @@ export function CreateBaseModal({ open, onOpenChange }: CreateBaseModalProps) {
|
||||
minSize: data.minChunkSize,
|
||||
overlap: data.overlapSize,
|
||||
},
|
||||
}
|
||||
|
||||
const response = await fetch('/api/knowledge', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(knowledgeBasePayload),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.error || 'Failed to create knowledge base')
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to create knowledge base')
|
||||
}
|
||||
|
||||
const newKnowledgeBase = result.data
|
||||
|
||||
if (files.length > 0) {
|
||||
try {
|
||||
const uploadedFiles = await uploadFiles(files, newKnowledgeBase.id, {
|
||||
@@ -293,15 +274,11 @@ export function CreateBaseModal({ open, onOpenChange }: CreateBaseModalProps) {
|
||||
|
||||
logger.info(`Successfully uploaded ${uploadedFiles.length} files`)
|
||||
logger.info(`Started processing ${uploadedFiles.length} documents in the background`)
|
||||
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: knowledgeKeys.list(workspaceId),
|
||||
})
|
||||
} catch (uploadError) {
|
||||
logger.error('File upload failed, deleting knowledge base:', uploadError)
|
||||
try {
|
||||
await fetch(`/api/knowledge/${newKnowledgeBase.id}`, {
|
||||
method: 'DELETE',
|
||||
await deleteKnowledgeBaseMutation.mutateAsync({
|
||||
knowledgeBaseId: newKnowledgeBase.id,
|
||||
})
|
||||
logger.info(`Deleted orphaned knowledge base: ${newKnowledgeBase.id}`)
|
||||
} catch (deleteError) {
|
||||
@@ -309,10 +286,6 @@ export function CreateBaseModal({ open, onOpenChange }: CreateBaseModalProps) {
|
||||
}
|
||||
throw uploadError
|
||||
}
|
||||
} else {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: knowledgeKeys.list(workspaceId),
|
||||
})
|
||||
}
|
||||
|
||||
files.forEach((file) => URL.revokeObjectURL(file.preview))
|
||||
@@ -325,8 +298,6 @@ export function CreateBaseModal({ open, onOpenChange }: CreateBaseModalProps) {
|
||||
type: 'error',
|
||||
message: error instanceof Error ? error.message : 'An unknown error occurred',
|
||||
})
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { AlertTriangle, ChevronDown, LibraryBig, MoreHorizontal } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
@@ -15,7 +14,7 @@ import {
|
||||
} from '@/components/emcn'
|
||||
import { Trash } from '@/components/emcn/icons/trash'
|
||||
import { filterButtonClass } from '@/app/workspace/[workspaceId]/knowledge/components/constants'
|
||||
import { knowledgeKeys } from '@/hooks/queries/knowledge'
|
||||
import { useUpdateKnowledgeBase } from '@/hooks/queries/knowledge'
|
||||
|
||||
const logger = createLogger('KnowledgeHeader')
|
||||
|
||||
@@ -54,14 +53,13 @@ interface Workspace {
|
||||
}
|
||||
|
||||
export function KnowledgeHeader({ breadcrumbs, options }: KnowledgeHeaderProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const [isActionsPopoverOpen, setIsActionsPopoverOpen] = useState(false)
|
||||
const [isWorkspacePopoverOpen, setIsWorkspacePopoverOpen] = useState(false)
|
||||
const [workspaces, setWorkspaces] = useState<Workspace[]>([])
|
||||
const [isLoadingWorkspaces, setIsLoadingWorkspaces] = useState(false)
|
||||
const [isUpdatingWorkspace, setIsUpdatingWorkspace] = useState(false)
|
||||
|
||||
// Fetch available workspaces
|
||||
const updateKnowledgeBase = useUpdateKnowledgeBase()
|
||||
|
||||
useEffect(() => {
|
||||
if (!options?.knowledgeBaseId) return
|
||||
|
||||
@@ -76,7 +74,6 @@ export function KnowledgeHeader({ breadcrumbs, options }: KnowledgeHeaderProps)
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
// Filter workspaces where user has write/admin permissions
|
||||
const availableWorkspaces = data.workspaces
|
||||
.filter((ws: any) => ws.permissions === 'write' || ws.permissions === 'admin')
|
||||
.map((ws: any) => ({
|
||||
@@ -97,47 +94,27 @@ export function KnowledgeHeader({ breadcrumbs, options }: KnowledgeHeaderProps)
|
||||
}, [options?.knowledgeBaseId])
|
||||
|
||||
const handleWorkspaceChange = async (workspaceId: string | null) => {
|
||||
if (isUpdatingWorkspace || !options?.knowledgeBaseId) return
|
||||
if (updateKnowledgeBase.isPending || !options?.knowledgeBaseId) return
|
||||
|
||||
try {
|
||||
setIsUpdatingWorkspace(true)
|
||||
setIsWorkspacePopoverOpen(false)
|
||||
setIsWorkspacePopoverOpen(false)
|
||||
|
||||
const response = await fetch(`/api/knowledge/${options.knowledgeBaseId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
updateKnowledgeBase.mutate(
|
||||
{
|
||||
knowledgeBaseId: options.knowledgeBaseId,
|
||||
updates: { workspaceId },
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
logger.info(
|
||||
`Knowledge base workspace updated: ${options.knowledgeBaseId} -> ${workspaceId}`
|
||||
)
|
||||
options.onWorkspaceChange?.(workspaceId)
|
||||
},
|
||||
onError: (err) => {
|
||||
logger.error('Error updating workspace:', err)
|
||||
},
|
||||
body: JSON.stringify({
|
||||
workspaceId,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const result = await response.json()
|
||||
throw new Error(result.error || 'Failed to update workspace')
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (result.success) {
|
||||
logger.info(
|
||||
`Knowledge base workspace updated: ${options.knowledgeBaseId} -> ${workspaceId}`
|
||||
)
|
||||
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: knowledgeKeys.detail(options.knowledgeBaseId),
|
||||
})
|
||||
|
||||
await options.onWorkspaceChange?.(workspaceId)
|
||||
} else {
|
||||
throw new Error(result.error || 'Failed to update workspace')
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Error updating workspace:', err)
|
||||
} finally {
|
||||
setIsUpdatingWorkspace(false)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const currentWorkspace = workspaces.find((ws) => ws.id === options?.currentWorkspaceId)
|
||||
@@ -147,7 +124,6 @@ export function KnowledgeHeader({ breadcrumbs, options }: KnowledgeHeaderProps)
|
||||
<div className={HEADER_STYLES.container}>
|
||||
<div className={HEADER_STYLES.breadcrumbs}>
|
||||
{breadcrumbs.map((breadcrumb, index) => {
|
||||
// Use unique identifier when available, fallback to content-based key
|
||||
const key = breadcrumb.id || `${breadcrumb.label}-${breadcrumb.href || index}`
|
||||
|
||||
return (
|
||||
@@ -189,13 +165,13 @@ export function KnowledgeHeader({ breadcrumbs, options }: KnowledgeHeaderProps)
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
disabled={isLoadingWorkspaces || isUpdatingWorkspace}
|
||||
disabled={isLoadingWorkspaces || updateKnowledgeBase.isPending}
|
||||
className={filterButtonClass}
|
||||
>
|
||||
<span className='truncate'>
|
||||
{isLoadingWorkspaces
|
||||
? 'Loading...'
|
||||
: isUpdatingWorkspace
|
||||
: updateKnowledgeBase.isPending
|
||||
? 'Updating...'
|
||||
: currentWorkspace?.name || 'No workspace'}
|
||||
</span>
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
|
||||
import { useKnowledgeBasesList } from '@/hooks/kb/use-knowledge'
|
||||
import { useDeleteKnowledgeBase, useUpdateKnowledgeBase } from '@/hooks/queries/knowledge'
|
||||
import { useDebounce } from '@/hooks/use-debounce'
|
||||
|
||||
const logger = createLogger('Knowledge')
|
||||
@@ -51,10 +52,12 @@ export function Knowledge() {
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
|
||||
const { knowledgeBases, isLoading, error, removeKnowledgeBase, updateKnowledgeBase } =
|
||||
useKnowledgeBasesList(workspaceId)
|
||||
const { knowledgeBases, isLoading, error } = useKnowledgeBasesList(workspaceId)
|
||||
const userPermissions = useUserPermissionsContext()
|
||||
|
||||
const { mutateAsync: updateKnowledgeBaseMutation } = useUpdateKnowledgeBase(workspaceId)
|
||||
const { mutateAsync: deleteKnowledgeBaseMutation } = useDeleteKnowledgeBase(workspaceId)
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const debouncedSearchQuery = useDebounce(searchQuery, 300)
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false)
|
||||
@@ -112,29 +115,13 @@ export function Knowledge() {
|
||||
*/
|
||||
const handleUpdateKnowledgeBase = useCallback(
|
||||
async (id: string, name: string, description: string) => {
|
||||
const response = await fetch(`/api/knowledge/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ name, description }),
|
||||
await updateKnowledgeBaseMutation({
|
||||
knowledgeBaseId: id,
|
||||
updates: { name, description },
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const result = await response.json()
|
||||
throw new Error(result.error || 'Failed to update knowledge base')
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (result.success) {
|
||||
logger.info(`Knowledge base updated: ${id}`)
|
||||
updateKnowledgeBase(id, { name, description })
|
||||
} else {
|
||||
throw new Error(result.error || 'Failed to update knowledge base')
|
||||
}
|
||||
logger.info(`Knowledge base updated: ${id}`)
|
||||
},
|
||||
[updateKnowledgeBase]
|
||||
[updateKnowledgeBaseMutation]
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -142,25 +129,10 @@ export function Knowledge() {
|
||||
*/
|
||||
const handleDeleteKnowledgeBase = useCallback(
|
||||
async (id: string) => {
|
||||
const response = await fetch(`/api/knowledge/${id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const result = await response.json()
|
||||
throw new Error(result.error || 'Failed to delete knowledge base')
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (result.success) {
|
||||
logger.info(`Knowledge base deleted: ${id}`)
|
||||
removeKnowledgeBase(id)
|
||||
} else {
|
||||
throw new Error(result.error || 'Failed to delete knowledge base')
|
||||
}
|
||||
await deleteKnowledgeBaseMutation({ knowledgeBaseId: id })
|
||||
logger.info(`Knowledge base deleted: ${id}`)
|
||||
},
|
||||
[removeKnowledgeBase]
|
||||
[deleteKnowledgeBaseMutation]
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,13 +4,13 @@ import type React from 'react'
|
||||
import { createContext, useContext, useEffect, useMemo, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
||||
import { useUserPermissions, type WorkspaceUserPermissions } from '@/hooks/use-user-permissions'
|
||||
import {
|
||||
useWorkspacePermissions,
|
||||
type WorkspacePermissions,
|
||||
} from '@/hooks/use-workspace-permissions'
|
||||
import { useNotificationStore } from '@/stores/notifications'
|
||||
import { useOperationQueueStore } from '@/stores/operation-queue/store'
|
||||
|
||||
const logger = createLogger('WorkspacePermissionsProvider')
|
||||
|
||||
@@ -64,8 +64,8 @@ export function WorkspacePermissionsProvider({ children }: WorkspacePermissionsP
|
||||
// Track whether we've already surfaced an offline notification to avoid duplicates
|
||||
const [hasShownOfflineNotification, setHasShownOfflineNotification] = useState(false)
|
||||
|
||||
// Get operation error state from collaborative workflow
|
||||
const { hasOperationError } = useCollaborativeWorkflow()
|
||||
// Get operation error state directly from the store (avoid full useCollaborativeWorkflow subscription)
|
||||
const hasOperationError = useOperationQueueStore((state) => state.hasOperationError)
|
||||
|
||||
const addNotification = useNotificationStore((state) => state.addNotification)
|
||||
|
||||
|
||||
@@ -48,17 +48,17 @@ export const ActionBar = memo(
|
||||
collaborativeBatchToggleBlockEnabled,
|
||||
collaborativeBatchToggleBlockHandles,
|
||||
} = useCollaborativeWorkflow()
|
||||
const { activeWorkflowId } = useWorkflowRegistry()
|
||||
const blocks = useWorkflowStore((state) => state.blocks)
|
||||
const subBlockStore = useSubBlockStore()
|
||||
const { activeWorkflowId, setPendingSelection } = useWorkflowRegistry()
|
||||
|
||||
const handleDuplicateBlock = useCallback(() => {
|
||||
const blocks = useWorkflowStore.getState().blocks
|
||||
const sourceBlock = blocks[blockId]
|
||||
if (!sourceBlock) return
|
||||
|
||||
const newId = crypto.randomUUID()
|
||||
const newName = getUniqueBlockName(sourceBlock.name, blocks)
|
||||
const subBlockValues = subBlockStore.workflowValues[activeWorkflowId || '']?.[blockId] || {}
|
||||
const subBlockValues =
|
||||
useSubBlockStore.getState().workflowValues[activeWorkflowId || '']?.[blockId] || {}
|
||||
|
||||
const { block, subBlockValues: filteredValues } = prepareDuplicateBlockState({
|
||||
sourceBlock,
|
||||
@@ -68,18 +68,10 @@ export const ActionBar = memo(
|
||||
subBlockValues,
|
||||
})
|
||||
|
||||
setPendingSelection([newId])
|
||||
collaborativeBatchAddBlocks([block], [], {}, {}, { [newId]: filteredValues })
|
||||
}, [
|
||||
blockId,
|
||||
blocks,
|
||||
activeWorkflowId,
|
||||
subBlockStore.workflowValues,
|
||||
collaborativeBatchAddBlocks,
|
||||
])
|
||||
}, [blockId, activeWorkflowId, collaborativeBatchAddBlocks, setPendingSelection])
|
||||
|
||||
/**
|
||||
* Optimized single store subscription for all block data
|
||||
*/
|
||||
const { isEnabled, horizontalHandles, parentId, parentType } = useWorkflowStore(
|
||||
useCallback(
|
||||
(state) => {
|
||||
|
||||
@@ -3,13 +3,11 @@ import ReactMarkdown from 'react-markdown'
|
||||
import type { NodeProps } from 'reactflow'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { BLOCK_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import { ActionBar } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/action-bar/action-bar'
|
||||
import { useBlockVisual } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
|
||||
import {
|
||||
BLOCK_DIMENSIONS,
|
||||
useBlockDimensions,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-dimensions'
|
||||
import { useBlockDimensions } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-dimensions'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import type { WorkflowBlockProps } from '../workflow-block/types'
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { Code, Tooltip } from '@/components/emcn'
|
||||
|
||||
const REMARK_PLUGINS = [remarkGfm]
|
||||
|
||||
/**
|
||||
* Recursively extracts text content from React elements
|
||||
* @param element - React node to extract text from
|
||||
@@ -149,14 +151,12 @@ interface CopilotMarkdownRendererProps {
|
||||
* Tighter spacing compared to traditional prose for better chat UX
|
||||
*/
|
||||
const markdownComponents = {
|
||||
// Paragraphs - tight spacing, no margin on last
|
||||
p: ({ children }: React.HTMLAttributes<HTMLParagraphElement>) => (
|
||||
<p className='mb-1.5 font-base font-season text-[var(--text-primary)] text-sm leading-[1.4] last:mb-0 dark:font-[470]'>
|
||||
{children}
|
||||
</p>
|
||||
),
|
||||
|
||||
// Headings - minimal margins for chat context
|
||||
h1: ({ children }: React.HTMLAttributes<HTMLHeadingElement>) => (
|
||||
<h1 className='mt-2 mb-1 font-season font-semibold text-[var(--text-primary)] text-base first:mt-0'>
|
||||
{children}
|
||||
@@ -178,7 +178,6 @@ const markdownComponents = {
|
||||
</h4>
|
||||
),
|
||||
|
||||
// Lists - compact spacing
|
||||
ul: ({ children }: React.HTMLAttributes<HTMLUListElement>) => (
|
||||
<ul
|
||||
className='my-1 space-y-0.5 pl-5 font-base font-season text-[var(--text-primary)] dark:font-[470]'
|
||||
@@ -204,7 +203,6 @@ const markdownComponents = {
|
||||
</li>
|
||||
),
|
||||
|
||||
// Code blocks - handled by CodeBlock component
|
||||
pre: ({ children }: React.HTMLAttributes<HTMLPreElement>) => {
|
||||
let codeContent: React.ReactNode = children
|
||||
let language = 'code'
|
||||
@@ -243,7 +241,6 @@ const markdownComponents = {
|
||||
return <CodeBlock code={actualCodeText} language={language} />
|
||||
},
|
||||
|
||||
// Inline code
|
||||
code: ({
|
||||
className,
|
||||
children,
|
||||
@@ -257,7 +254,6 @@ const markdownComponents = {
|
||||
</code>
|
||||
),
|
||||
|
||||
// Text formatting
|
||||
strong: ({ children }: React.HTMLAttributes<HTMLElement>) => (
|
||||
<strong className='font-semibold text-[var(--text-primary)]'>{children}</strong>
|
||||
),
|
||||
@@ -271,22 +267,18 @@ const markdownComponents = {
|
||||
<i className='text-[var(--text-primary)] italic'>{children}</i>
|
||||
),
|
||||
|
||||
// Blockquote - compact
|
||||
blockquote: ({ children }: React.HTMLAttributes<HTMLQuoteElement>) => (
|
||||
<blockquote className='my-1.5 border-[var(--border-1)] border-l-2 py-0.5 pl-3 font-season text-[var(--text-secondary)] text-sm italic'>
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
|
||||
// Horizontal rule
|
||||
hr: () => <hr className='my-3 border-[var(--divider)] border-t' />,
|
||||
|
||||
// Links
|
||||
a: ({ href, children }: React.AnchorHTMLAttributes<HTMLAnchorElement>) => (
|
||||
<LinkWithPreview href={href || '#'}>{children}</LinkWithPreview>
|
||||
),
|
||||
|
||||
// Tables - compact
|
||||
table: ({ children }: React.TableHTMLAttributes<HTMLTableElement>) => (
|
||||
<div className='my-2 max-w-full overflow-x-auto'>
|
||||
<table className='min-w-full table-auto border border-[var(--border-1)] font-season text-xs'>
|
||||
@@ -314,7 +306,6 @@ const markdownComponents = {
|
||||
</td>
|
||||
),
|
||||
|
||||
// Images
|
||||
img: ({ src, alt, ...props }: React.ImgHTMLAttributes<HTMLImageElement>) => (
|
||||
<img src={src} alt={alt || 'Image'} className='my-2 h-auto max-w-full rounded-md' {...props} />
|
||||
),
|
||||
@@ -330,7 +321,7 @@ const markdownComponents = {
|
||||
function CopilotMarkdownRenderer({ content }: CopilotMarkdownRendererProps) {
|
||||
return (
|
||||
<div className='max-w-full break-words font-base font-season text-[var(--text-primary)] text-sm leading-[1.4] dark:font-[470] [&_*]:max-w-full [&_a]:break-all [&_code:not(pre_code)]:break-words [&_li]:break-words [&_p]:break-words'>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
|
||||
<ReactMarkdown remarkPlugins={REMARK_PLUGINS} components={markdownComponents}>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,2 @@
|
||||
export { useCheckpointManagement } from './use-checkpoint-management'
|
||||
export { useMessageEditing } from './use-message-editing'
|
||||
export { useMessageFeedback } from './use-message-feedback'
|
||||
export { useSuccessTimers } from './use-success-timers'
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import type { CopilotMessage } from '@/stores/panel'
|
||||
import { useCopilotStore } from '@/stores/panel'
|
||||
|
||||
const logger = createLogger('useMessageFeedback')
|
||||
|
||||
const WORKFLOW_TOOL_NAMES = ['edit_workflow']
|
||||
|
||||
interface UseMessageFeedbackProps {
|
||||
setShowUpvoteSuccess: (show: boolean) => void
|
||||
setShowDownvoteSuccess: (show: boolean) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook to handle message feedback (upvote/downvote)
|
||||
*
|
||||
* @param message - The copilot message
|
||||
* @param messages - Array of all messages in the chat
|
||||
* @param props - Success state setters from useSuccessTimers
|
||||
* @returns Feedback management utilities
|
||||
*/
|
||||
export function useMessageFeedback(
|
||||
message: CopilotMessage,
|
||||
messages: CopilotMessage[],
|
||||
props: UseMessageFeedbackProps
|
||||
) {
|
||||
const { setShowUpvoteSuccess, setShowDownvoteSuccess } = props
|
||||
const { currentChat } = useCopilotStore()
|
||||
|
||||
/**
|
||||
* Gets the full assistant response content from message
|
||||
*/
|
||||
const getFullAssistantContent = useCallback((message: CopilotMessage) => {
|
||||
if (message.content?.trim()) {
|
||||
return message.content
|
||||
}
|
||||
|
||||
if (message.contentBlocks && message.contentBlocks.length > 0) {
|
||||
return message.contentBlocks
|
||||
.filter((block) => block.type === 'text')
|
||||
.map((block) => block.content)
|
||||
.join('')
|
||||
}
|
||||
|
||||
return message.content || ''
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Finds the last user query before this assistant message
|
||||
*/
|
||||
const getLastUserQuery = useCallback(() => {
|
||||
const messageIndex = messages.findIndex((msg) => msg.id === message.id)
|
||||
if (messageIndex === -1) return null
|
||||
|
||||
for (let i = messageIndex - 1; i >= 0; i--) {
|
||||
if (messages[i].role === 'user') {
|
||||
return messages[i].content
|
||||
}
|
||||
}
|
||||
return null
|
||||
}, [messages, message.id])
|
||||
|
||||
/**
|
||||
* Submits feedback to the API
|
||||
*/
|
||||
const submitFeedback = useCallback(
|
||||
async (isPositive: boolean) => {
|
||||
if (!currentChat?.id) {
|
||||
logger.error('No current chat ID available for feedback submission')
|
||||
return
|
||||
}
|
||||
|
||||
const userQuery = getLastUserQuery()
|
||||
if (!userQuery) {
|
||||
logger.error('No user query found for feedback submission')
|
||||
return
|
||||
}
|
||||
|
||||
const agentResponse = getFullAssistantContent(message)
|
||||
if (!agentResponse.trim()) {
|
||||
logger.error('No agent response content available for feedback submission')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const requestBody = {
|
||||
chatId: currentChat.id,
|
||||
userQuery,
|
||||
agentResponse,
|
||||
isPositiveFeedback: isPositive,
|
||||
}
|
||||
|
||||
const response = await fetch('/api/copilot/feedback', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to submit feedback: ${response.statusText}`)
|
||||
}
|
||||
|
||||
await response.json()
|
||||
} catch (error) {
|
||||
logger.error('Error submitting feedback:', error)
|
||||
}
|
||||
},
|
||||
[currentChat, getLastUserQuery, getFullAssistantContent, message]
|
||||
)
|
||||
|
||||
/**
|
||||
* Handles upvote action
|
||||
*/
|
||||
const handleUpvote = useCallback(async () => {
|
||||
setShowDownvoteSuccess(false)
|
||||
setShowUpvoteSuccess(true)
|
||||
await submitFeedback(true)
|
||||
}, [submitFeedback])
|
||||
|
||||
/**
|
||||
* Handles downvote action
|
||||
*/
|
||||
const handleDownvote = useCallback(async () => {
|
||||
setShowUpvoteSuccess(false)
|
||||
setShowDownvoteSuccess(true)
|
||||
await submitFeedback(false)
|
||||
}, [submitFeedback])
|
||||
|
||||
return {
|
||||
handleUpvote,
|
||||
handleDownvote,
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
/**
|
||||
* Duration to show success indicators (in milliseconds)
|
||||
*/
|
||||
const SUCCESS_DISPLAY_DURATION = 2000
|
||||
|
||||
/**
|
||||
* Custom hook to manage auto-hiding success states
|
||||
* Automatically hides success indicators after a set duration
|
||||
*
|
||||
* @returns Success state management utilities
|
||||
*/
|
||||
export function useSuccessTimers() {
|
||||
const [showCopySuccess, setShowCopySuccess] = useState(false)
|
||||
const [showUpvoteSuccess, setShowUpvoteSuccess] = useState(false)
|
||||
const [showDownvoteSuccess, setShowDownvoteSuccess] = useState(false)
|
||||
|
||||
/**
|
||||
* Auto-hide copy success indicator after duration
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (showCopySuccess) {
|
||||
const timer = setTimeout(() => {
|
||||
setShowCopySuccess(false)
|
||||
}, SUCCESS_DISPLAY_DURATION)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [showCopySuccess])
|
||||
|
||||
/**
|
||||
* Auto-hide upvote success indicator after duration
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (showUpvoteSuccess) {
|
||||
const timer = setTimeout(() => {
|
||||
setShowUpvoteSuccess(false)
|
||||
}, SUCCESS_DISPLAY_DURATION)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [showUpvoteSuccess])
|
||||
|
||||
/**
|
||||
* Auto-hide downvote success indicator after duration
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (showDownvoteSuccess) {
|
||||
const timer = setTimeout(() => {
|
||||
setShowDownvoteSuccess(false)
|
||||
}, SUCCESS_DISPLAY_DURATION)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [showDownvoteSuccess])
|
||||
|
||||
/**
|
||||
* Handles copy to clipboard action
|
||||
* @param content - Content to copy to clipboard
|
||||
*/
|
||||
const handleCopy = useCallback((content: string) => {
|
||||
navigator.clipboard.writeText(content)
|
||||
setShowCopySuccess(true)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
// State
|
||||
showCopySuccess,
|
||||
showUpvoteSuccess,
|
||||
showDownvoteSuccess,
|
||||
|
||||
// Operations
|
||||
handleCopy,
|
||||
setShowUpvoteSuccess,
|
||||
setShowDownvoteSuccess,
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -151,7 +151,7 @@ export function useMentionData(props: UseMentionDataProps): MentionDataReturn {
|
||||
useShallow(useCallback((state) => Object.keys(state.blocks), []))
|
||||
)
|
||||
|
||||
const registryWorkflows = useWorkflowRegistry((state) => state.workflows)
|
||||
const registryWorkflows = useWorkflowRegistry(useShallow((state) => state.workflows))
|
||||
const hydrationPhase = useWorkflowRegistry((state) => state.hydration.phase)
|
||||
const isLoadingWorkflows =
|
||||
hydrationPhase === 'idle' ||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback } from 'react'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import type { useMentionMenu } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-menu'
|
||||
import type { ChatContext } from '@/stores/panel'
|
||||
|
||||
@@ -39,11 +39,11 @@ export function useMentionTokens({
|
||||
setSelectedContexts,
|
||||
}: UseMentionTokensProps) {
|
||||
/**
|
||||
* Computes all mention ranges in the message (both @mentions and /commands)
|
||||
*
|
||||
* @returns Array of mention ranges sorted by start position
|
||||
* Memoized mention ranges - computed once when message or selectedContexts change.
|
||||
* This prevents expensive O(n×m) string searches from running on every keystroke
|
||||
* when other callbacks access the ranges.
|
||||
*/
|
||||
const computeMentionRanges = useCallback((): MentionRange[] => {
|
||||
const memoizedMentionRanges = useMemo((): MentionRange[] => {
|
||||
const ranges: MentionRange[] = []
|
||||
if (!message || selectedContexts.length === 0) return ranges
|
||||
|
||||
@@ -93,35 +93,45 @@ export function useMentionTokens({
|
||||
|
||||
/**
|
||||
* Finds a mention range containing the given position
|
||||
*/
|
||||
const computeMentionRanges = useCallback(
|
||||
(): MentionRange[] => memoizedMentionRanges,
|
||||
[memoizedMentionRanges]
|
||||
)
|
||||
|
||||
/**
|
||||
* Finds a mention range containing the given position.
|
||||
* Uses memoized ranges directly for better performance.
|
||||
*
|
||||
* @param pos - Position to check
|
||||
* @returns Mention range if found, undefined otherwise
|
||||
*/
|
||||
const findRangeContaining = useCallback(
|
||||
(pos: number): MentionRange | undefined => {
|
||||
const ranges = computeMentionRanges()
|
||||
return ranges.find((r) => pos > r.start && pos < r.end)
|
||||
return memoizedMentionRanges.find((r) => pos > r.start && pos < r.end)
|
||||
},
|
||||
[computeMentionRanges]
|
||||
[memoizedMentionRanges]
|
||||
)
|
||||
|
||||
/**
|
||||
* Removes contexts for mention tokens that overlap with a text selection
|
||||
* Removes contexts for mention tokens that overlap with a text selection.
|
||||
* Uses memoized ranges directly for better performance.
|
||||
*
|
||||
* @param selStart - Selection start position
|
||||
* @param selEnd - Selection end position
|
||||
*/
|
||||
const removeContextsInSelection = useCallback(
|
||||
(selStart: number, selEnd: number) => {
|
||||
const ranges = computeMentionRanges()
|
||||
const overlappingRanges = ranges.filter((r) => !(selEnd <= r.start || selStart >= r.end))
|
||||
const overlappingRanges = memoizedMentionRanges.filter(
|
||||
(r) => !(selEnd <= r.start || selStart >= r.end)
|
||||
)
|
||||
|
||||
if (overlappingRanges.length > 0) {
|
||||
const labelsToRemove = new Set(overlappingRanges.map((r) => r.label))
|
||||
setSelectedContexts((prev) => prev.filter((c) => !c.label || !labelsToRemove.has(c.label)))
|
||||
}
|
||||
},
|
||||
[computeMentionRanges, setSelectedContexts]
|
||||
[memoizedMentionRanges, setSelectedContexts]
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -655,6 +655,13 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
[insertTriggerAndOpenMenu]
|
||||
)
|
||||
|
||||
const handleModelSelect = useCallback(
|
||||
(model: string) => {
|
||||
setSelectedModel(model as any)
|
||||
},
|
||||
[setSelectedModel]
|
||||
)
|
||||
|
||||
const canSubmit = message.trim().length > 0 && !disabled && !isLoading
|
||||
const showAbortButton = isLoading && onAbort
|
||||
|
||||
@@ -863,7 +870,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
<ModelSelector
|
||||
selectedModel={selectedModel}
|
||||
isNearTop={isNearTop}
|
||||
onModelSelect={(model: string) => setSelectedModel(model as any)}
|
||||
onModelSelect={handleModelSelect}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -11,8 +11,6 @@ import {
|
||||
ButtonGroupItem,
|
||||
Checkbox,
|
||||
Code,
|
||||
Combobox,
|
||||
type ComboboxOption,
|
||||
Input,
|
||||
Label,
|
||||
TagInput,
|
||||
@@ -271,14 +269,6 @@ export function A2aDeploy({
|
||||
onNeedsRepublishChange?.(!!needsRepublish)
|
||||
}, [needsRepublish, onNeedsRepublishChange])
|
||||
|
||||
const authSchemeOptions: ComboboxOption[] = useMemo(
|
||||
() => [
|
||||
{ label: 'API Key', value: 'apiKey' },
|
||||
{ label: 'None (Public)', value: 'none' },
|
||||
],
|
||||
[]
|
||||
)
|
||||
|
||||
const canSave = name.trim().length > 0 && description.trim().length > 0
|
||||
useEffect(() => {
|
||||
onCanSaveChange?.(canSave)
|
||||
@@ -758,17 +748,18 @@ console.log(data);`
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Authentication */}
|
||||
{/* Access */}
|
||||
<div>
|
||||
<Label className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
|
||||
Authentication
|
||||
Access
|
||||
</Label>
|
||||
<Combobox
|
||||
options={authSchemeOptions}
|
||||
<ButtonGroup
|
||||
value={authScheme}
|
||||
onChange={(v) => setAuthScheme(v as AuthScheme)}
|
||||
placeholder='Select authentication...'
|
||||
/>
|
||||
onValueChange={(value) => setAuthScheme(value as AuthScheme)}
|
||||
>
|
||||
<ButtonGroupItem value='apiKey'>API Key</ButtonGroupItem>
|
||||
<ButtonGroupItem value='none'>Public</ButtonGroupItem>
|
||||
</ButtonGroup>
|
||||
<p className='mt-[6.5px] text-[11px] text-[var(--text-secondary)]'>
|
||||
{authScheme === 'none'
|
||||
? 'Anyone can call this agent without authentication'
|
||||
|
||||
@@ -409,7 +409,11 @@ export function ChatDeploy({
|
||||
<ModalHeader>Delete Chat</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className='text-[12px] text-[var(--text-secondary)]'>
|
||||
Are you sure you want to delete this chat?{' '}
|
||||
Are you sure you want to delete{' '}
|
||||
<span className='font-medium text-[var(--text-primary)]'>
|
||||
{existingChat?.title || 'this chat'}
|
||||
</span>
|
||||
?{' '}
|
||||
<span className='text-[var(--text-error)]'>
|
||||
This will remove the chat at "{getEmailDomain()}/chat/{existingChat?.identifier}"
|
||||
and make it unavailable to all users.
|
||||
@@ -424,7 +428,7 @@ export function ChatDeploy({
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant='default' onClick={handleDelete} disabled={isDeleting}>
|
||||
<Button variant='destructive' onClick={handleDelete} disabled={isDeleting}>
|
||||
{isDeleting ? 'Deleting...' : 'Delete'}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
|
||||
@@ -375,8 +375,11 @@ export function TemplateDeploy({
|
||||
<ModalHeader>Delete Template</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className='text-[12px] text-[var(--text-secondary)]'>
|
||||
Are you sure you want to delete this template?{' '}
|
||||
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
|
||||
Are you sure you want to delete{' '}
|
||||
<span className='font-medium text-[var(--text-primary)]'>
|
||||
{existingTemplate?.name || formData.name || 'this template'}
|
||||
</span>
|
||||
? <span className='text-[var(--text-error)]'>This action cannot be undone.</span>
|
||||
</p>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
|
||||
@@ -846,7 +846,11 @@ export function DeployModal({
|
||||
<ModalHeader>Delete A2A Agent</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className='text-[12px] text-[var(--text-secondary)]'>
|
||||
Are you sure you want to delete this agent?{' '}
|
||||
Are you sure you want to delete{' '}
|
||||
<span className='font-medium text-[var(--text-primary)]'>
|
||||
{existingA2aAgent?.name || 'this agent'}
|
||||
</span>
|
||||
?{' '}
|
||||
<span className='text-[var(--text-error)]'>
|
||||
This will permanently remove the agent configuration.
|
||||
</span>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ReactElement } from 'react'
|
||||
import { useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
|
||||
import { memo, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
|
||||
import { Check, Copy, Wand2 } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import 'prismjs/components/prism-python'
|
||||
@@ -170,7 +170,7 @@ interface CodeProps {
|
||||
hideInternalWand?: boolean
|
||||
}
|
||||
|
||||
export function Code({
|
||||
export const Code = memo(function Code({
|
||||
blockId,
|
||||
subBlockId,
|
||||
placeholder = 'Write JavaScript...',
|
||||
@@ -206,6 +206,8 @@ export function Code({
|
||||
const handleGeneratedContentRef = useRef<(generatedCode: string) => void>(() => {})
|
||||
const handleStreamChunkRef = useRef<(chunk: string) => void>(() => {})
|
||||
const hasEditedSinceFocusRef = useRef(false)
|
||||
const codeRef = useRef(code)
|
||||
codeRef.current = code
|
||||
|
||||
const accessiblePrefixes = useAccessibleReferencePrefixes(blockId)
|
||||
const emitTagSelection = useTagSelection(blockId, subBlockId)
|
||||
@@ -307,25 +309,18 @@ export function Code({
|
||||
? getDefaultValueString()
|
||||
: storeValue
|
||||
|
||||
const lastValidationStatus = useRef<boolean>(true)
|
||||
|
||||
useEffect(() => {
|
||||
if (!onValidationChange) return
|
||||
|
||||
const nextStatus = shouldValidateJson ? isValidJson : true
|
||||
if (lastValidationStatus.current === nextStatus) {
|
||||
return
|
||||
}
|
||||
const isValid = !shouldValidateJson || isValidJson
|
||||
|
||||
lastValidationStatus.current = nextStatus
|
||||
|
||||
if (!shouldValidateJson) {
|
||||
onValidationChange(nextStatus)
|
||||
if (isValid) {
|
||||
onValidationChange(true)
|
||||
return
|
||||
}
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
onValidationChange(nextStatus)
|
||||
onValidationChange(false)
|
||||
}, 150)
|
||||
|
||||
return () => clearTimeout(timeoutId)
|
||||
@@ -337,7 +332,7 @@ export function Code({
|
||||
}
|
||||
|
||||
handleStreamChunkRef.current = (chunk: string) => {
|
||||
setCode((prev) => prev + chunk)
|
||||
setCode((prev: string) => prev + chunk)
|
||||
}
|
||||
|
||||
handleGeneratedContentRef.current = (generatedCode: string) => {
|
||||
@@ -434,12 +429,12 @@ export function Code({
|
||||
`
|
||||
document.body.appendChild(tempContainer)
|
||||
|
||||
lines.forEach((line) => {
|
||||
lines.forEach((line: string) => {
|
||||
const lineDiv = document.createElement('div')
|
||||
|
||||
if (line.includes('<') && line.includes('>')) {
|
||||
const parts = line.split(/(<[^>]+>)/g)
|
||||
parts.forEach((part) => {
|
||||
parts.forEach((part: string) => {
|
||||
const span = document.createElement('span')
|
||||
span.textContent = part
|
||||
lineDiv.appendChild(span)
|
||||
@@ -472,7 +467,6 @@ export function Code({
|
||||
}
|
||||
}, [code])
|
||||
|
||||
// Event Handlers
|
||||
/**
|
||||
* Handles drag-and-drop events for inserting reference tags into the code editor.
|
||||
* @param e - The drag event
|
||||
@@ -500,7 +494,6 @@ export function Code({
|
||||
textarea.selectionStart = newCursorPosition
|
||||
textarea.selectionEnd = newCursorPosition
|
||||
|
||||
// Show tag dropdown after cursor is positioned
|
||||
setShowTags(true)
|
||||
if (data.connectionData?.sourceBlockId) {
|
||||
setActiveSourceBlockId(data.connectionData.sourceBlockId)
|
||||
@@ -559,44 +552,45 @@ export function Code({
|
||||
}
|
||||
}
|
||||
|
||||
// Helper Functions
|
||||
/**
|
||||
* Determines whether a `<...>` segment should be highlighted as a reference.
|
||||
* @param part - The code segment to check
|
||||
* @returns True if the segment should be highlighted as a reference
|
||||
*/
|
||||
const shouldHighlightReference = (part: string): boolean => {
|
||||
if (!part.startsWith('<') || !part.endsWith('>')) {
|
||||
return false
|
||||
}
|
||||
const shouldHighlightReference = useCallback(
|
||||
(part: string): boolean => {
|
||||
if (!part.startsWith('<') || !part.endsWith('>')) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!isLikelyReferenceSegment(part)) {
|
||||
return false
|
||||
}
|
||||
if (!isLikelyReferenceSegment(part)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const split = splitReferenceSegment(part)
|
||||
if (!split) {
|
||||
return false
|
||||
}
|
||||
const split = splitReferenceSegment(part)
|
||||
if (!split) {
|
||||
return false
|
||||
}
|
||||
|
||||
const reference = split.reference
|
||||
const reference = split.reference
|
||||
|
||||
if (!accessiblePrefixes) {
|
||||
return true
|
||||
}
|
||||
if (!accessiblePrefixes) {
|
||||
return true
|
||||
}
|
||||
|
||||
const inner = reference.slice(1, -1)
|
||||
const [prefix] = inner.split('.')
|
||||
const normalizedPrefix = normalizeName(prefix)
|
||||
const inner = reference.slice(1, -1)
|
||||
const [prefix] = inner.split('.')
|
||||
const normalizedPrefix = normalizeName(prefix)
|
||||
|
||||
if (SYSTEM_REFERENCE_PREFIXES.has(normalizedPrefix)) {
|
||||
return true
|
||||
}
|
||||
if (SYSTEM_REFERENCE_PREFIXES.has(normalizedPrefix)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return accessiblePrefixes.has(normalizedPrefix)
|
||||
}
|
||||
return accessiblePrefixes.has(normalizedPrefix)
|
||||
},
|
||||
[accessiblePrefixes]
|
||||
)
|
||||
|
||||
// Expose wand control handlers to parent via ref
|
||||
useImperativeHandle(
|
||||
wandControlRef,
|
||||
() => ({
|
||||
@@ -609,6 +603,62 @@ export function Code({
|
||||
[generateCodeStream, isPromptVisible, isAiStreaming]
|
||||
)
|
||||
|
||||
const highlightCode = useMemo(
|
||||
() => createHighlightFunction(effectiveLanguage, shouldHighlightReference),
|
||||
[effectiveLanguage, shouldHighlightReference]
|
||||
)
|
||||
|
||||
const handleValueChange = useCallback(
|
||||
(newCode: string) => {
|
||||
if (!isAiStreaming && !isPreview && !disabled && !readOnly) {
|
||||
hasEditedSinceFocusRef.current = true
|
||||
setCode(newCode)
|
||||
setStoreValue(newCode)
|
||||
|
||||
const textarea = editorRef.current?.querySelector('textarea')
|
||||
if (textarea) {
|
||||
const pos = textarea.selectionStart
|
||||
setCursorPosition(pos)
|
||||
|
||||
const tagTrigger = checkTagTrigger(newCode, pos)
|
||||
setShowTags(tagTrigger.show)
|
||||
if (!tagTrigger.show) {
|
||||
setActiveSourceBlockId(null)
|
||||
}
|
||||
|
||||
const envVarTrigger = checkEnvVarTrigger(newCode, pos)
|
||||
setShowEnvVars(envVarTrigger.show)
|
||||
setSearchTerm(envVarTrigger.show ? envVarTrigger.searchTerm : '')
|
||||
}
|
||||
}
|
||||
},
|
||||
[isAiStreaming, isPreview, disabled, readOnly, setStoreValue]
|
||||
)
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLDivElement | HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Escape') {
|
||||
setShowTags(false)
|
||||
setShowEnvVars(false)
|
||||
}
|
||||
if (isAiStreaming) {
|
||||
e.preventDefault()
|
||||
}
|
||||
if (e.key === 'z' && (e.metaKey || e.ctrlKey) && !hasEditedSinceFocusRef.current) {
|
||||
e.preventDefault()
|
||||
}
|
||||
},
|
||||
[isAiStreaming]
|
||||
)
|
||||
|
||||
const handleEditorFocus = useCallback(() => {
|
||||
hasEditedSinceFocusRef.current = false
|
||||
if (!isPreview && !disabled && !readOnly && codeRef.current.trim() === '') {
|
||||
setShowTags(true)
|
||||
setCursorPosition(0)
|
||||
}
|
||||
}, [isPreview, disabled, readOnly])
|
||||
|
||||
/**
|
||||
* Renders the line numbers, aligned with wrapped visual lines and highlighting the active line.
|
||||
* @returns Array of React elements representing the line numbers
|
||||
@@ -617,7 +667,7 @@ export function Code({
|
||||
const numbers: ReactElement[] = []
|
||||
let lineNumber = 1
|
||||
|
||||
visualLineHeights.forEach((height) => {
|
||||
visualLineHeights.forEach((height: number) => {
|
||||
const isActive = lineNumber === activeLineNumber
|
||||
numbers.push(
|
||||
<div
|
||||
@@ -724,50 +774,10 @@ export function Code({
|
||||
|
||||
<Editor
|
||||
value={code}
|
||||
onValueChange={(newCode) => {
|
||||
if (!isAiStreaming && !isPreview && !disabled && !readOnly) {
|
||||
hasEditedSinceFocusRef.current = true
|
||||
setCode(newCode)
|
||||
setStoreValue(newCode)
|
||||
|
||||
const textarea = editorRef.current?.querySelector('textarea')
|
||||
if (textarea) {
|
||||
const pos = textarea.selectionStart
|
||||
setCursorPosition(pos)
|
||||
|
||||
const tagTrigger = checkTagTrigger(newCode, pos)
|
||||
setShowTags(tagTrigger.show)
|
||||
if (!tagTrigger.show) {
|
||||
setActiveSourceBlockId(null)
|
||||
}
|
||||
|
||||
const envVarTrigger = checkEnvVarTrigger(newCode, pos)
|
||||
setShowEnvVars(envVarTrigger.show)
|
||||
setSearchTerm(envVarTrigger.show ? envVarTrigger.searchTerm : '')
|
||||
}
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
setShowTags(false)
|
||||
setShowEnvVars(false)
|
||||
}
|
||||
if (isAiStreaming) {
|
||||
e.preventDefault()
|
||||
}
|
||||
if (e.key === 'z' && (e.metaKey || e.ctrlKey) && !hasEditedSinceFocusRef.current) {
|
||||
e.preventDefault()
|
||||
}
|
||||
}}
|
||||
onFocus={() => {
|
||||
hasEditedSinceFocusRef.current = false
|
||||
// Show tag dropdown on focus when code is empty
|
||||
if (!isPreview && !disabled && !readOnly && code.trim() === '') {
|
||||
setShowTags(true)
|
||||
setCursorPosition(0)
|
||||
}
|
||||
}}
|
||||
highlight={createHighlightFunction(effectiveLanguage, shouldHighlightReference)}
|
||||
onValueChange={handleValueChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={handleEditorFocus}
|
||||
highlight={highlightCode}
|
||||
{...getCodeEditorProps({ isStreaming: isAiStreaming, isPreview, disabled })}
|
||||
/>
|
||||
|
||||
@@ -810,4 +820,4 @@ export function Code({
|
||||
</CodeEditor.Container>
|
||||
</>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { isEqual } from 'lodash'
|
||||
import { useReactFlow } from 'reactflow'
|
||||
import { Combobox, type ComboboxOption } from '@/components/emcn/components'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
@@ -71,7 +72,7 @@ interface ComboBoxProps {
|
||||
dependsOn?: SubBlockConfig['dependsOn']
|
||||
}
|
||||
|
||||
export function ComboBox({
|
||||
export const ComboBox = memo(function ComboBox({
|
||||
options,
|
||||
defaultValue,
|
||||
blockId,
|
||||
@@ -112,7 +113,8 @@ export function ComboBox({
|
||||
)
|
||||
},
|
||||
[dependsOnFields, activeWorkflowId, blockId, canonicalIndex, canonicalModeOverrides]
|
||||
)
|
||||
),
|
||||
isEqual
|
||||
)
|
||||
|
||||
// State management
|
||||
@@ -281,34 +283,17 @@ export function ComboBox({
|
||||
setStoreInitialized(true)
|
||||
}, [])
|
||||
|
||||
// Check if current value is valid (exists in allowed options)
|
||||
const isValueValid = useMemo(() => {
|
||||
if (value === null || value === undefined) return false
|
||||
return evaluatedOptions.some((opt) => getOptionValue(opt) === value)
|
||||
}, [value, evaluatedOptions, getOptionValue])
|
||||
|
||||
// Set default value once store is initialized and permissions are loaded
|
||||
// Also reset if current value becomes invalid (e.g., provider was blocked)
|
||||
useEffect(() => {
|
||||
if (isPermissionLoading) return
|
||||
if (!storeInitialized) return
|
||||
if (defaultOptionValue === undefined) return
|
||||
|
||||
const needsDefault = value === null || value === undefined
|
||||
const needsReset = subBlockId === 'model' && value && !isValueValid
|
||||
|
||||
if (needsDefault || needsReset) {
|
||||
// Only set default when no value exists (initial block add)
|
||||
if (value === null || value === undefined) {
|
||||
setStoreValue(defaultOptionValue)
|
||||
}
|
||||
}, [
|
||||
storeInitialized,
|
||||
value,
|
||||
defaultOptionValue,
|
||||
setStoreValue,
|
||||
isPermissionLoading,
|
||||
subBlockId,
|
||||
isValueValid,
|
||||
])
|
||||
}, [storeInitialized, value, defaultOptionValue, setStoreValue, isPermissionLoading])
|
||||
|
||||
// Clear fetched options and hydrated option when dependencies change
|
||||
useEffect(() => {
|
||||
@@ -437,6 +422,18 @@ export function ComboBox({
|
||||
[reactFlowInstance]
|
||||
)
|
||||
|
||||
/**
|
||||
* Handles combobox open state changes to trigger option fetching
|
||||
*/
|
||||
const handleOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
if (open) {
|
||||
void fetchOptionsIfNeeded()
|
||||
}
|
||||
},
|
||||
[fetchOptionsIfNeeded]
|
||||
)
|
||||
|
||||
/**
|
||||
* Gets the icon for the currently selected option
|
||||
*/
|
||||
@@ -466,6 +463,75 @@ export function ComboBox({
|
||||
)
|
||||
}, [inputValue, accessiblePrefixes, selectedOption, selectedOptionIcon])
|
||||
|
||||
const ctrlOnChangeRef = useRef<
|
||||
((e: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) => void) | null
|
||||
>(null)
|
||||
const onDropRef = useRef<
|
||||
((e: React.DragEvent<HTMLTextAreaElement | HTMLInputElement>) => void) | null
|
||||
>(null)
|
||||
const onDragOverRef = useRef<
|
||||
((e: React.DragEvent<HTMLTextAreaElement | HTMLInputElement>) => void) | null
|
||||
>(null)
|
||||
const inputRefFromController = useRef<HTMLInputElement | null>(null)
|
||||
|
||||
const comboboxOnChange = useCallback(
|
||||
(newValue: string) => {
|
||||
const matchedComboboxOption = comboboxOptions.find((option) => option.value === newValue)
|
||||
if (matchedComboboxOption) {
|
||||
setInputValue(matchedComboboxOption.label)
|
||||
} else {
|
||||
setInputValue(newValue)
|
||||
}
|
||||
|
||||
// Use controller's handler so env vars, tags, and DnD still work
|
||||
const syntheticEvent = {
|
||||
target: { value: newValue, selectionStart: newValue.length },
|
||||
} as React.ChangeEvent<HTMLInputElement>
|
||||
ctrlOnChangeRef.current?.(syntheticEvent)
|
||||
},
|
||||
[comboboxOptions, setInputValue]
|
||||
)
|
||||
|
||||
const comboboxInputProps = useMemo(
|
||||
() => ({
|
||||
onDrop: ((e: React.DragEvent<HTMLInputElement>) => {
|
||||
onDropRef.current?.(e)
|
||||
}) as (e: React.DragEvent<HTMLInputElement>) => void,
|
||||
onDragOver: ((e: React.DragEvent<HTMLInputElement>) => {
|
||||
onDragOverRef.current?.(e)
|
||||
}) as (e: React.DragEvent<HTMLInputElement>) => void,
|
||||
onWheel: handleWheel,
|
||||
autoComplete: 'off' as const,
|
||||
}),
|
||||
[handleWheel]
|
||||
)
|
||||
|
||||
// Stable onChange for SubBlockInputController
|
||||
const controllerOnChange = useCallback(
|
||||
(newValue: string) => {
|
||||
if (isPreview) {
|
||||
return
|
||||
}
|
||||
|
||||
const matchedOption = evaluatedOptions.find((option) => {
|
||||
if (typeof option === 'string') {
|
||||
return option === newValue
|
||||
}
|
||||
return option.id === newValue
|
||||
})
|
||||
|
||||
// If a matching option is found, store its ID; otherwise store the raw value
|
||||
// (allows expressions like <block.output> to be entered directly)
|
||||
const nextValue = matchedOption
|
||||
? typeof matchedOption === 'string'
|
||||
? matchedOption
|
||||
: matchedOption.id
|
||||
: newValue
|
||||
setStoreValue(nextValue)
|
||||
},
|
||||
[isPreview, evaluatedOptions, setStoreValue]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className='relative w-full'>
|
||||
<SubBlockInputController
|
||||
@@ -473,76 +539,43 @@ export function ComboBox({
|
||||
subBlockId={subBlockId}
|
||||
config={config}
|
||||
value={propValue}
|
||||
onChange={(newValue) => {
|
||||
if (isPreview) {
|
||||
return
|
||||
}
|
||||
|
||||
const matchedOption = evaluatedOptions.find((option) => {
|
||||
if (typeof option === 'string') {
|
||||
return option === newValue
|
||||
}
|
||||
return option.id === newValue
|
||||
})
|
||||
|
||||
// If a matching option is found, store its ID; otherwise store the raw value
|
||||
// (allows expressions like <block.output> to be entered directly)
|
||||
const nextValue = matchedOption
|
||||
? typeof matchedOption === 'string'
|
||||
? matchedOption
|
||||
: matchedOption.id
|
||||
: newValue
|
||||
setStoreValue(nextValue)
|
||||
}}
|
||||
onChange={controllerOnChange}
|
||||
isPreview={isPreview}
|
||||
disabled={disabled}
|
||||
previewValue={previewValue}
|
||||
>
|
||||
{({ ref, onChange: ctrlOnChange, onDrop, onDragOver }) => (
|
||||
<Combobox
|
||||
options={comboboxOptions}
|
||||
value={inputValue}
|
||||
selectedValue={value ?? ''}
|
||||
onChange={(newValue) => {
|
||||
const matchedComboboxOption = comboboxOptions.find(
|
||||
(option) => option.value === newValue
|
||||
)
|
||||
if (matchedComboboxOption) {
|
||||
setInputValue(matchedComboboxOption.label)
|
||||
} else {
|
||||
setInputValue(newValue)
|
||||
}
|
||||
{({ ref, onChange: ctrlOnChange, onDrop, onDragOver }) => {
|
||||
// Update refs with latest handlers from render prop
|
||||
ctrlOnChangeRef.current = ctrlOnChange
|
||||
onDropRef.current = onDrop
|
||||
onDragOverRef.current = onDragOver
|
||||
// Store the input ref for passing to Combobox
|
||||
if (ref.current) {
|
||||
inputRefFromController.current = ref.current as HTMLInputElement
|
||||
}
|
||||
|
||||
// Use controller's handler so env vars, tags, and DnD still work
|
||||
const syntheticEvent = {
|
||||
target: { value: newValue, selectionStart: newValue.length },
|
||||
} as React.ChangeEvent<HTMLInputElement>
|
||||
ctrlOnChange(syntheticEvent)
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
editable
|
||||
overlayContent={overlayContent}
|
||||
inputRef={ref as React.RefObject<HTMLInputElement>}
|
||||
filterOptions
|
||||
searchable={config.searchable}
|
||||
className={cn('allow-scroll overflow-x-auto', selectedOptionIcon && 'pl-[28px]')}
|
||||
inputProps={{
|
||||
onDrop: onDrop as (e: React.DragEvent<HTMLInputElement>) => void,
|
||||
onDragOver: onDragOver as (e: React.DragEvent<HTMLInputElement>) => void,
|
||||
onWheel: handleWheel,
|
||||
autoComplete: 'off',
|
||||
}}
|
||||
isLoading={isLoadingOptions}
|
||||
error={fetchError}
|
||||
onOpenChange={(open) => {
|
||||
if (open) {
|
||||
void fetchOptionsIfNeeded()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
return (
|
||||
<Combobox
|
||||
options={comboboxOptions}
|
||||
value={inputValue}
|
||||
selectedValue={value ?? ''}
|
||||
onChange={comboboxOnChange}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
editable
|
||||
overlayContent={overlayContent}
|
||||
inputRef={ref as React.RefObject<HTMLInputElement>}
|
||||
filterOptions
|
||||
searchable={config.searchable}
|
||||
className={cn('allow-scroll overflow-x-auto', selectedOptionIcon && 'pl-[28px]')}
|
||||
inputProps={comboboxInputProps}
|
||||
isLoading={isLoadingOptions}
|
||||
error={fetchError}
|
||||
onOpenChange={handleOpenChange}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
</SubBlockInputController>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ReactElement } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { ChevronDown, ChevronUp, Plus } from 'lucide-react'
|
||||
import { ChevronDown, ChevronsUpDown, ChevronUp, Plus } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import Editor from 'react-simple-code-editor'
|
||||
import { useUpdateNodeInternals } from 'reactflow'
|
||||
@@ -39,6 +39,16 @@ import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
|
||||
const logger = createLogger('ConditionInput')
|
||||
|
||||
/**
|
||||
* Default height for router textareas in pixels
|
||||
*/
|
||||
const ROUTER_DEFAULT_HEIGHT_PX = 100
|
||||
|
||||
/**
|
||||
* Minimum height for router textareas in pixels
|
||||
*/
|
||||
const ROUTER_MIN_HEIGHT_PX = 80
|
||||
|
||||
/**
|
||||
* Represents a single conditional block (if/else if/else).
|
||||
*/
|
||||
@@ -743,6 +753,61 @@ export function ConditionInput({
|
||||
}
|
||||
}, [conditionalBlocks, isRouterMode])
|
||||
|
||||
// State for tracking individual router textarea heights
|
||||
const [routerHeights, setRouterHeights] = useState<{ [key: string]: number }>({})
|
||||
const isResizing = useRef(false)
|
||||
|
||||
/**
|
||||
* Gets the height for a specific router block, returning default if not set.
|
||||
*
|
||||
* @param blockId - ID of the router block
|
||||
* @returns Height in pixels
|
||||
*/
|
||||
const getRouterHeight = (blockId: string): number => {
|
||||
return routerHeights[blockId] ?? ROUTER_DEFAULT_HEIGHT_PX
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles mouse-based resize for router textareas.
|
||||
*
|
||||
* @param e - Mouse event from the resize handle
|
||||
* @param blockId - ID of the block being resized
|
||||
*/
|
||||
const startRouterResize = (e: React.MouseEvent, blockId: string) => {
|
||||
if (isPreview || disabled) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
isResizing.current = true
|
||||
|
||||
const startY = e.clientY
|
||||
const startHeight = getRouterHeight(blockId)
|
||||
|
||||
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||||
if (!isResizing.current) return
|
||||
|
||||
const deltaY = moveEvent.clientY - startY
|
||||
const newHeight = Math.max(ROUTER_MIN_HEIGHT_PX, startHeight + deltaY)
|
||||
|
||||
// Update the textarea height directly for smooth resizing
|
||||
const textarea = inputRefs.current.get(blockId)
|
||||
if (textarea) {
|
||||
textarea.style.height = `${newHeight}px`
|
||||
}
|
||||
|
||||
// Update state to keep track
|
||||
setRouterHeights((prev) => ({ ...prev, [blockId]: newHeight }))
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
isResizing.current = false
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
document.removeEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
document.addEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
|
||||
// Show loading or empty state if not ready or no blocks
|
||||
if (!isReady || conditionalBlocks.length === 0) {
|
||||
return (
|
||||
@@ -907,10 +972,24 @@ export function ConditionInput({
|
||||
}}
|
||||
placeholder='Describe when this route should be taken...'
|
||||
disabled={disabled || isPreview}
|
||||
className='min-h-[60px] resize-none rounded-none border-0 px-3 py-2 text-sm placeholder:text-muted-foreground/50 focus-visible:ring-0 focus-visible:ring-offset-0'
|
||||
rows={2}
|
||||
className='min-h-[100px] resize-none rounded-none border-0 px-3 py-2 text-sm placeholder:text-muted-foreground/50 focus-visible:ring-0 focus-visible:ring-offset-0'
|
||||
rows={4}
|
||||
style={{ height: `${getRouterHeight(block.id)}px` }}
|
||||
/>
|
||||
|
||||
{/* Custom resize handle */}
|
||||
{!isPreview && !disabled && (
|
||||
<div
|
||||
className='absolute right-1 bottom-1 flex h-4 w-4 cursor-ns-resize items-center justify-center rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-5)] dark:bg-[var(--surface-5)]'
|
||||
onMouseDown={(e) => startRouterResize(e, block.id)}
|
||||
onDragStart={(e) => {
|
||||
e.preventDefault()
|
||||
}}
|
||||
>
|
||||
<ChevronsUpDown className='h-3 w-3 text-[var(--text-muted)]' />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{block.showEnvVars && (
|
||||
<EnvVarDropdown
|
||||
visible={block.showEnvVars}
|
||||
|
||||
@@ -41,6 +41,7 @@ const SCOPE_DESCRIPTIONS: Record<string, string> = {
|
||||
'https://www.googleapis.com/auth/calendar': 'View and manage calendar',
|
||||
'https://www.googleapis.com/auth/userinfo.email': 'View email address',
|
||||
'https://www.googleapis.com/auth/userinfo.profile': 'View basic profile info',
|
||||
'https://www.googleapis.com/auth/forms.body': 'View and manage Google Forms',
|
||||
'https://www.googleapis.com/auth/forms.responses.readonly': 'View responses to Google Forms',
|
||||
'https://www.googleapis.com/auth/ediscovery': 'Access Google Vault for eDiscovery',
|
||||
'https://www.googleapis.com/auth/devstorage.read_only': 'Read files from Google Cloud Storage',
|
||||
|
||||
@@ -15,6 +15,7 @@ interface DocumentSelectorProps {
|
||||
onDocumentSelect?: (documentId: string) => void
|
||||
isPreview?: boolean
|
||||
previewValue?: string | null
|
||||
previewContextValues?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export function DocumentSelector({
|
||||
@@ -24,9 +25,15 @@ export function DocumentSelector({
|
||||
onDocumentSelect,
|
||||
isPreview = false,
|
||||
previewValue,
|
||||
previewContextValues,
|
||||
}: DocumentSelectorProps) {
|
||||
const { finalDisabled } = useDependsOnGate(blockId, subBlock, { disabled, isPreview })
|
||||
const [knowledgeBaseIdValue] = useSubBlockValue(blockId, 'knowledgeBaseId')
|
||||
const { finalDisabled } = useDependsOnGate(blockId, subBlock, {
|
||||
disabled,
|
||||
isPreview,
|
||||
previewContextValues,
|
||||
})
|
||||
const [knowledgeBaseIdFromStore] = useSubBlockValue(blockId, 'knowledgeBaseId')
|
||||
const knowledgeBaseIdValue = previewContextValues?.knowledgeBaseId ?? knowledgeBaseIdFromStore
|
||||
const normalizedKnowledgeBaseId =
|
||||
typeof knowledgeBaseIdValue === 'string' && knowledgeBaseIdValue.trim().length > 0
|
||||
? knowledgeBaseIdValue
|
||||
|
||||
@@ -37,6 +37,7 @@ interface DocumentTagEntryProps {
|
||||
disabled?: boolean
|
||||
isPreview?: boolean
|
||||
previewValue?: any
|
||||
previewContextValues?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -56,6 +57,7 @@ export function DocumentTagEntry({
|
||||
disabled = false,
|
||||
isPreview = false,
|
||||
previewValue,
|
||||
previewContextValues,
|
||||
}: DocumentTagEntryProps) {
|
||||
const [storeValue, setStoreValue] = useSubBlockValue<string>(blockId, subBlock.id)
|
||||
const accessiblePrefixes = useAccessibleReferencePrefixes(blockId)
|
||||
@@ -74,8 +76,12 @@ export function DocumentTagEntry({
|
||||
disabled,
|
||||
})
|
||||
|
||||
const [knowledgeBaseIdValue] = useSubBlockValue(blockId, 'knowledgeBaseId')
|
||||
const knowledgeBaseId = knowledgeBaseIdValue || null
|
||||
const [knowledgeBaseIdFromStore] = useSubBlockValue(blockId, 'knowledgeBaseId')
|
||||
const knowledgeBaseIdValue = previewContextValues?.knowledgeBaseId ?? knowledgeBaseIdFromStore
|
||||
const knowledgeBaseId =
|
||||
typeof knowledgeBaseIdValue === 'string' && knowledgeBaseIdValue.trim().length > 0
|
||||
? knowledgeBaseIdValue
|
||||
: null
|
||||
|
||||
const { tagDefinitions, isLoading } = useKnowledgeBaseTagDefinitions(knowledgeBaseId)
|
||||
const emitTagSelection = useTagSelection(blockId, subBlock.id)
|
||||
@@ -131,11 +137,16 @@ export function DocumentTagEntry({
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a tag by ID (prevents removing the last tag)
|
||||
* Removes a tag by ID, or resets it if it's the last one
|
||||
*/
|
||||
const removeTag = (id: string) => {
|
||||
if (isReadOnly || tags.length === 1) return
|
||||
updateTags(tags.filter((t) => t.id !== id))
|
||||
if (isReadOnly) return
|
||||
if (tags.length === 1) {
|
||||
// Reset the last tag instead of removing it
|
||||
updateTags([createDefaultTag()])
|
||||
} else {
|
||||
updateTags(tags.filter((t) => t.id !== id))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -222,6 +233,7 @@ export function DocumentTagEntry({
|
||||
|
||||
/**
|
||||
* Renders the tag header with name, badge, and action buttons
|
||||
* Shows tag name only when collapsed (as summary), generic label when expanded
|
||||
*/
|
||||
const renderTagHeader = (tag: DocumentTag, index: number) => (
|
||||
<div
|
||||
@@ -230,9 +242,11 @@ export function DocumentTagEntry({
|
||||
>
|
||||
<div className='flex min-w-0 flex-1 items-center gap-[8px]'>
|
||||
<span className='block truncate font-medium text-[14px] text-[var(--text-tertiary)]'>
|
||||
{tag.tagName || `Tag ${index + 1}`}
|
||||
{tag.collapsed ? tag.tagName || `Tag ${index + 1}` : `Tag ${index + 1}`}
|
||||
</span>
|
||||
{tag.tagName && <Badge size='sm'>{FIELD_TYPE_LABELS[tag.fieldType] || 'Text'}</Badge>}
|
||||
{tag.collapsed && tag.tagName && (
|
||||
<Badge size='sm'>{FIELD_TYPE_LABELS[tag.fieldType] || 'Text'}</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className='flex items-center gap-[8px] pl-[8px]' onClick={(e) => e.stopPropagation()}>
|
||||
<Button
|
||||
@@ -247,7 +261,7 @@ export function DocumentTagEntry({
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={() => removeTag(tag.id)}
|
||||
disabled={isReadOnly || tags.length === 1}
|
||||
disabled={isReadOnly}
|
||||
className='h-auto p-0 text-[var(--text-error)] hover:text-[var(--text-error)]'
|
||||
>
|
||||
<Trash className='h-[14px] w-[14px]' />
|
||||
@@ -341,7 +355,7 @@ export function DocumentTagEntry({
|
||||
|
||||
const tagOptions: ComboboxOption[] = selectableTags.map((t) => ({
|
||||
value: t.displayName,
|
||||
label: `${t.displayName} (${FIELD_TYPE_LABELS[t.fieldType] || 'Text'})`,
|
||||
label: t.displayName,
|
||||
}))
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { isEqual } from 'lodash'
|
||||
import { Badge } from '@/components/emcn'
|
||||
import { Combobox, type ComboboxOption } from '@/components/emcn/components'
|
||||
import { buildCanonicalIndex, resolveDependencyValue } from '@/lib/workflows/subblocks/visibility'
|
||||
@@ -68,7 +69,7 @@ interface DropdownProps {
|
||||
* - Special handling for dataMode subblock to convert between JSON and structured formats
|
||||
* - Integrates with the workflow state management system
|
||||
*/
|
||||
export function Dropdown({
|
||||
export const Dropdown = memo(function Dropdown({
|
||||
options,
|
||||
defaultValue,
|
||||
blockId,
|
||||
@@ -110,7 +111,8 @@ export function Dropdown({
|
||||
)
|
||||
},
|
||||
[dependsOnFields, activeWorkflowId, blockId, canonicalIndex, canonicalModeOverrides]
|
||||
)
|
||||
),
|
||||
isEqual
|
||||
)
|
||||
|
||||
const [storeInitialized, setStoreInitialized] = useState(false)
|
||||
@@ -161,6 +163,18 @@ export function Dropdown({
|
||||
}
|
||||
}, [fetchOptions, blockId, subBlockId, isPreview, disabled])
|
||||
|
||||
/**
|
||||
* Handles combobox open state changes to trigger option fetching
|
||||
*/
|
||||
const handleOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
if (open) {
|
||||
void fetchOptionsIfNeeded()
|
||||
}
|
||||
},
|
||||
[fetchOptionsIfNeeded]
|
||||
)
|
||||
|
||||
const evaluatedOptions = useMemo(() => {
|
||||
return typeof options === 'function' ? options() : options
|
||||
}, [options])
|
||||
@@ -471,11 +485,7 @@ export function Dropdown({
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
editable={false}
|
||||
onOpenChange={(open) => {
|
||||
if (open) {
|
||||
void fetchOptionsIfNeeded()
|
||||
}
|
||||
}}
|
||||
onOpenChange={handleOpenChange}
|
||||
overlayContent={multiSelectOverlay}
|
||||
multiSelect={multiSelect}
|
||||
isLoading={isLoadingOptions}
|
||||
@@ -484,4 +494,4 @@ export function Dropdown({
|
||||
searchPlaceholder='Search...'
|
||||
/>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -40,6 +40,7 @@ interface KnowledgeTagFiltersProps {
|
||||
disabled?: boolean
|
||||
isPreview?: boolean
|
||||
previewValue?: string | null
|
||||
previewContextValues?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -60,14 +61,19 @@ export function KnowledgeTagFilters({
|
||||
disabled = false,
|
||||
isPreview = false,
|
||||
previewValue,
|
||||
previewContextValues,
|
||||
}: KnowledgeTagFiltersProps) {
|
||||
const [storeValue, setStoreValue] = useSubBlockValue<string | null>(blockId, subBlock.id)
|
||||
const emitTagSelection = useTagSelection(blockId, subBlock.id)
|
||||
const valueInputRefs = useRef<Record<string, HTMLInputElement>>({})
|
||||
const overlayRefs = useRef<Record<string, HTMLDivElement>>({})
|
||||
|
||||
const [knowledgeBaseIdValue] = useSubBlockValue(blockId, 'knowledgeBaseId')
|
||||
const knowledgeBaseId = knowledgeBaseIdValue || null
|
||||
const [knowledgeBaseIdFromStore] = useSubBlockValue(blockId, 'knowledgeBaseId')
|
||||
const knowledgeBaseIdValue = previewContextValues?.knowledgeBaseId ?? knowledgeBaseIdFromStore
|
||||
const knowledgeBaseId =
|
||||
typeof knowledgeBaseIdValue === 'string' && knowledgeBaseIdValue.trim().length > 0
|
||||
? knowledgeBaseIdValue
|
||||
: null
|
||||
|
||||
const { tagDefinitions, isLoading } = useKnowledgeBaseTagDefinitions(knowledgeBaseId)
|
||||
const accessiblePrefixes = useAccessibleReferencePrefixes(blockId)
|
||||
@@ -123,11 +129,16 @@ export function KnowledgeTagFilters({
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a filter by ID (prevents removing the last filter)
|
||||
* Removes a filter by ID, or resets it if it's the last one
|
||||
*/
|
||||
const removeFilter = (id: string) => {
|
||||
if (isReadOnly || filters.length === 1) return
|
||||
updateFilters(filters.filter((f) => f.id !== id))
|
||||
if (isReadOnly) return
|
||||
if (filters.length === 1) {
|
||||
// Reset the last filter instead of removing it
|
||||
updateFilters([createDefaultFilter()])
|
||||
} else {
|
||||
updateFilters(filters.filter((f) => f.id !== id))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -215,6 +226,7 @@ export function KnowledgeTagFilters({
|
||||
|
||||
/**
|
||||
* Renders the filter header with name, badge, and action buttons
|
||||
* Shows tag name only when collapsed (as summary), generic label when expanded
|
||||
*/
|
||||
const renderFilterHeader = (filter: TagFilter, index: number) => (
|
||||
<div
|
||||
@@ -223,9 +235,11 @@ export function KnowledgeTagFilters({
|
||||
>
|
||||
<div className='flex min-w-0 flex-1 items-center gap-[8px]'>
|
||||
<span className='block truncate font-medium text-[14px] text-[var(--text-tertiary)]'>
|
||||
{filter.tagName || `Filter ${index + 1}`}
|
||||
{filter.collapsed ? filter.tagName || `Filter ${index + 1}` : `Filter ${index + 1}`}
|
||||
</span>
|
||||
{filter.tagName && <Badge size='sm'>{FIELD_TYPE_LABELS[filter.fieldType] || 'Text'}</Badge>}
|
||||
{filter.collapsed && filter.tagName && (
|
||||
<Badge size='sm'>{FIELD_TYPE_LABELS[filter.fieldType] || 'Text'}</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className='flex items-center gap-[8px] pl-[8px]' onClick={(e) => e.stopPropagation()}>
|
||||
<Button variant='ghost' onClick={addFilter} disabled={isReadOnly} className='h-auto p-0'>
|
||||
@@ -235,7 +249,7 @@ export function KnowledgeTagFilters({
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={() => removeFilter(filter.id)}
|
||||
disabled={isReadOnly || filters.length === 1}
|
||||
disabled={isReadOnly}
|
||||
className='h-auto p-0 text-[var(--text-error)] hover:text-[var(--text-error)]'
|
||||
>
|
||||
<Trash className='h-[14px] w-[14px]' />
|
||||
@@ -324,7 +338,7 @@ export function KnowledgeTagFilters({
|
||||
const renderFilterContent = (filter: TagFilter) => {
|
||||
const tagOptions: ComboboxOption[] = tagDefinitions.map((tag) => ({
|
||||
value: tag.displayName,
|
||||
label: `${tag.displayName} (${FIELD_TYPE_LABELS[tag.fieldType] || 'Text'})`,
|
||||
label: tag.displayName,
|
||||
}))
|
||||
|
||||
const operators = getOperatorsForFieldType(filter.fieldType)
|
||||
|
||||
@@ -234,48 +234,45 @@ export function LongInput({
|
||||
}, [value])
|
||||
|
||||
// Handle resize functionality
|
||||
const startResize = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
isResizing.current = true
|
||||
const startResize = (e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
isResizing.current = true
|
||||
|
||||
const startY = e.clientY
|
||||
const startHeight = height
|
||||
const startY = e.clientY
|
||||
const startHeight = height
|
||||
|
||||
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||||
if (!isResizing.current) return
|
||||
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||||
if (!isResizing.current) return
|
||||
|
||||
const deltaY = moveEvent.clientY - startY
|
||||
const newHeight = Math.max(MIN_HEIGHT_PX, startHeight + deltaY)
|
||||
const deltaY = moveEvent.clientY - startY
|
||||
const newHeight = Math.max(MIN_HEIGHT_PX, startHeight + deltaY)
|
||||
|
||||
if (textareaRef.current && overlayRef.current) {
|
||||
textareaRef.current.style.height = `${newHeight}px`
|
||||
overlayRef.current.style.height = `${newHeight}px`
|
||||
}
|
||||
if (containerRef.current) {
|
||||
containerRef.current.style.height = `${newHeight}px`
|
||||
}
|
||||
// Keep React state in sync so parent layouts (e.g., Editor) update during drag
|
||||
setHeight(newHeight)
|
||||
if (textareaRef.current && overlayRef.current) {
|
||||
textareaRef.current.style.height = `${newHeight}px`
|
||||
overlayRef.current.style.height = `${newHeight}px`
|
||||
}
|
||||
if (containerRef.current) {
|
||||
containerRef.current.style.height = `${newHeight}px`
|
||||
}
|
||||
// Keep React state in sync so parent layouts (e.g., Editor) update during drag
|
||||
setHeight(newHeight)
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
if (textareaRef.current) {
|
||||
const finalHeight = Number.parseInt(textareaRef.current.style.height, 10) || height
|
||||
setHeight(finalHeight)
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
if (textareaRef.current) {
|
||||
const finalHeight = Number.parseInt(textareaRef.current.style.height, 10) || height
|
||||
setHeight(finalHeight)
|
||||
}
|
||||
isResizing.current = false
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
document.removeEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
|
||||
isResizing.current = false
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
document.removeEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
document.addEventListener('mouseup', handleMouseUp)
|
||||
},
|
||||
[height]
|
||||
)
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
document.addEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
|
||||
// Expose wand control handlers to parent via ref
|
||||
useImperativeHandle(
|
||||
|
||||
@@ -1,281 +1,17 @@
|
||||
import type { RefObject } from 'react'
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { Combobox, Input, Label, Slider, Switch, Textarea } from '@/components/emcn/components'
|
||||
import { Combobox, Label, Slider, Switch } from '@/components/emcn/components'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text'
|
||||
import {
|
||||
checkTagTrigger,
|
||||
TagDropdown,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown'
|
||||
import { LongInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/long-input/long-input'
|
||||
import { ShortInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/short-input/short-input'
|
||||
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
|
||||
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
import { useMcpTools } from '@/hooks/mcp/use-mcp-tools'
|
||||
import { formatParameterLabel } from '@/tools/params'
|
||||
|
||||
const logger = createLogger('McpDynamicArgs')
|
||||
|
||||
interface McpInputWithTagsProps {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
isPassword?: boolean
|
||||
blockId: string
|
||||
accessiblePrefixes?: Set<string>
|
||||
}
|
||||
|
||||
function McpInputWithTags({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
disabled,
|
||||
isPassword,
|
||||
blockId,
|
||||
accessiblePrefixes,
|
||||
}: McpInputWithTagsProps) {
|
||||
const [showTags, setShowTags] = useState(false)
|
||||
const [cursorPosition, setCursorPosition] = useState(0)
|
||||
const [activeSourceBlockId, setActiveSourceBlockId] = useState<string | null>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const inputNameRef = useRef(`mcp_input_${Math.random()}`)
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = e.target.value
|
||||
const newCursorPosition = e.target.selectionStart ?? 0
|
||||
|
||||
onChange(newValue)
|
||||
setCursorPosition(newCursorPosition)
|
||||
|
||||
const tagTrigger = checkTagTrigger(newValue, newCursorPosition)
|
||||
setShowTags(tagTrigger.show)
|
||||
}
|
||||
|
||||
const handleDrop = (e: React.DragEvent<HTMLInputElement>) => {
|
||||
e.preventDefault()
|
||||
|
||||
try {
|
||||
const data = JSON.parse(e.dataTransfer.getData('application/json'))
|
||||
if (data.type !== 'connectionBlock') return
|
||||
|
||||
const dropPosition = inputRef.current?.selectionStart ?? value.length ?? 0
|
||||
const currentValue = value ?? ''
|
||||
const newValue = `${currentValue.slice(0, dropPosition)}<${currentValue.slice(dropPosition)}`
|
||||
|
||||
onChange(newValue)
|
||||
setCursorPosition(dropPosition + 1)
|
||||
setShowTags(true)
|
||||
|
||||
if (data.connectionData?.sourceBlockId) {
|
||||
setActiveSourceBlockId(data.connectionData.sourceBlockId)
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.selectionStart = dropPosition + 1
|
||||
inputRef.current.selectionEnd = dropPosition + 1
|
||||
}
|
||||
}, 0)
|
||||
} catch (error) {
|
||||
logger.error('Failed to parse drop data:', { error })
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragOver = (e: React.DragEvent<HTMLInputElement>) => {
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
const handleTagSelect = (newValue: string) => {
|
||||
onChange(newValue)
|
||||
setShowTags(false)
|
||||
setActiveSourceBlockId(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='relative'>
|
||||
<div className='relative'>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
type={isPassword ? 'password' : 'text'}
|
||||
value={value || ''}
|
||||
onChange={handleChange}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
name={inputNameRef.current}
|
||||
autoComplete='off'
|
||||
autoCapitalize='off'
|
||||
spellCheck='false'
|
||||
data-form-type='other'
|
||||
data-lpignore='true'
|
||||
data-1p-ignore
|
||||
readOnly
|
||||
onFocus={(e) => {
|
||||
e.currentTarget.removeAttribute('readOnly')
|
||||
// Show tag dropdown on focus when input is empty
|
||||
if (!disabled && (value?.trim() === '' || !value)) {
|
||||
setShowTags(true)
|
||||
setCursorPosition(0)
|
||||
}
|
||||
}}
|
||||
className={cn(!isPassword && 'text-transparent caret-foreground')}
|
||||
/>
|
||||
{!isPassword && (
|
||||
<div className='pointer-events-none absolute inset-0 flex items-center overflow-hidden bg-transparent px-[8px] py-[6px] font-medium font-sans text-sm'>
|
||||
<div className='whitespace-pre'>
|
||||
{formatDisplayText(value?.toString() || '', {
|
||||
accessiblePrefixes,
|
||||
highlightAll: !accessiblePrefixes,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<TagDropdown
|
||||
visible={showTags}
|
||||
onSelect={handleTagSelect}
|
||||
blockId={blockId}
|
||||
activeSourceBlockId={activeSourceBlockId}
|
||||
inputValue={value?.toString() ?? ''}
|
||||
cursorPosition={cursorPosition}
|
||||
onClose={() => {
|
||||
setShowTags(false)
|
||||
setActiveSourceBlockId(null)
|
||||
}}
|
||||
inputRef={inputRef as RefObject<HTMLInputElement>}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface McpTextareaWithTagsProps {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
blockId: string
|
||||
accessiblePrefixes?: Set<string>
|
||||
rows?: number
|
||||
}
|
||||
|
||||
function McpTextareaWithTags({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
disabled,
|
||||
blockId,
|
||||
accessiblePrefixes,
|
||||
rows = 4,
|
||||
}: McpTextareaWithTagsProps) {
|
||||
const [showTags, setShowTags] = useState(false)
|
||||
const [cursorPosition, setCursorPosition] = useState(0)
|
||||
const [activeSourceBlockId, setActiveSourceBlockId] = useState<string | null>(null)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const textareaNameRef = useRef(`mcp_textarea_${Math.random()}`)
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const newValue = e.target.value
|
||||
const newCursorPosition = e.target.selectionStart ?? 0
|
||||
|
||||
onChange(newValue)
|
||||
setCursorPosition(newCursorPosition)
|
||||
|
||||
const tagTrigger = checkTagTrigger(newValue, newCursorPosition)
|
||||
setShowTags(tagTrigger.show)
|
||||
}
|
||||
|
||||
const handleDrop = (e: React.DragEvent<HTMLTextAreaElement>) => {
|
||||
e.preventDefault()
|
||||
|
||||
try {
|
||||
const data = JSON.parse(e.dataTransfer.getData('application/json'))
|
||||
if (data.type !== 'connectionBlock') return
|
||||
|
||||
const dropPosition = textareaRef.current?.selectionStart ?? value.length ?? 0
|
||||
const currentValue = value ?? ''
|
||||
const newValue = `${currentValue.slice(0, dropPosition)}<${currentValue.slice(dropPosition)}`
|
||||
|
||||
onChange(newValue)
|
||||
setCursorPosition(dropPosition + 1)
|
||||
setShowTags(true)
|
||||
|
||||
if (data.connectionData?.sourceBlockId) {
|
||||
setActiveSourceBlockId(data.connectionData.sourceBlockId)
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.selectionStart = dropPosition + 1
|
||||
textareaRef.current.selectionEnd = dropPosition + 1
|
||||
}
|
||||
}, 0)
|
||||
} catch (error) {
|
||||
logger.error('Failed to parse drop data:', { error })
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragOver = (e: React.DragEvent<HTMLTextAreaElement>) => {
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
const handleTagSelect = (newValue: string) => {
|
||||
onChange(newValue)
|
||||
setShowTags(false)
|
||||
setActiveSourceBlockId(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='relative'>
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={value || ''}
|
||||
onChange={handleChange}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onFocus={() => {
|
||||
// Show tag dropdown on focus when input is empty
|
||||
if (!disabled && (value?.trim() === '' || !value)) {
|
||||
setShowTags(true)
|
||||
setCursorPosition(0)
|
||||
}
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
rows={rows}
|
||||
name={textareaNameRef.current}
|
||||
autoComplete='off'
|
||||
autoCapitalize='off'
|
||||
spellCheck='false'
|
||||
data-form-type='other'
|
||||
data-lpignore='true'
|
||||
data-1p-ignore
|
||||
className={cn('min-h-[80px] resize-none text-transparent caret-foreground')}
|
||||
/>
|
||||
<div className='pointer-events-none absolute inset-0 overflow-auto whitespace-pre-wrap break-words px-[8px] py-[8px] font-medium font-sans text-sm'>
|
||||
{formatDisplayText(value || '', {
|
||||
accessiblePrefixes,
|
||||
highlightAll: !accessiblePrefixes,
|
||||
})}
|
||||
</div>
|
||||
<TagDropdown
|
||||
visible={showTags}
|
||||
onSelect={handleTagSelect}
|
||||
blockId={blockId}
|
||||
activeSourceBlockId={activeSourceBlockId}
|
||||
inputValue={value?.toString() ?? ''}
|
||||
cursorPosition={cursorPosition}
|
||||
onClose={() => {
|
||||
setShowTags(false)
|
||||
setActiveSourceBlockId(null)
|
||||
}}
|
||||
inputRef={textareaRef as RefObject<HTMLTextAreaElement>}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface McpDynamicArgsProps {
|
||||
blockId: string
|
||||
subBlockId: string
|
||||
@@ -284,6 +20,27 @@ interface McpDynamicArgsProps {
|
||||
previewValue?: any
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a minimal SubBlockConfig for MCP tool parameters
|
||||
*/
|
||||
function createParamConfig(
|
||||
paramName: string,
|
||||
paramSchema: any,
|
||||
inputType: 'long-input' | 'short-input'
|
||||
): SubBlockConfig {
|
||||
const placeholder =
|
||||
paramSchema.type === 'array'
|
||||
? `Enter JSON array, e.g. ["item1", "item2"] or comma-separated values`
|
||||
: paramSchema.description || `Enter ${formatParameterLabel(paramName).toLowerCase()}`
|
||||
|
||||
return {
|
||||
id: paramName,
|
||||
type: inputType,
|
||||
title: formatParameterLabel(paramName),
|
||||
placeholder,
|
||||
}
|
||||
}
|
||||
|
||||
export function McpDynamicArgs({
|
||||
blockId,
|
||||
subBlockId,
|
||||
@@ -297,7 +54,6 @@ export function McpDynamicArgs({
|
||||
const [selectedTool] = useSubBlockValue(blockId, 'tool')
|
||||
const [cachedSchema] = useSubBlockValue(blockId, '_toolSchema')
|
||||
const [toolArgs, setToolArgs] = useSubBlockValue(blockId, subBlockId)
|
||||
const accessiblePrefixes = useAccessibleReferencePrefixes(blockId)
|
||||
|
||||
const selectedToolConfig = mcpTools.find((tool) => tool.id === selectedTool)
|
||||
const toolSchema = cachedSchema || selectedToolConfig?.inputSchema
|
||||
@@ -308,7 +64,7 @@ export function McpDynamicArgs({
|
||||
try {
|
||||
return JSON.parse(previewValue)
|
||||
} catch (error) {
|
||||
console.warn('Failed to parse preview value as JSON:', error)
|
||||
logger.warn('Failed to parse preview value as JSON:', { error })
|
||||
return previewValue
|
||||
}
|
||||
}
|
||||
@@ -318,7 +74,7 @@ export function McpDynamicArgs({
|
||||
try {
|
||||
return JSON.parse(toolArgs)
|
||||
} catch (error) {
|
||||
console.warn('Failed to parse toolArgs as JSON:', error)
|
||||
logger.warn('Failed to parse toolArgs as JSON:', { error })
|
||||
return {}
|
||||
}
|
||||
}
|
||||
@@ -460,24 +216,23 @@ export function McpDynamicArgs({
|
||||
)
|
||||
}
|
||||
|
||||
case 'long-input':
|
||||
case 'long-input': {
|
||||
const config = createParamConfig(paramName, paramSchema, 'long-input')
|
||||
return (
|
||||
<McpTextareaWithTags
|
||||
<LongInput
|
||||
key={`${paramName}-long`}
|
||||
blockId={blockId}
|
||||
subBlockId={`_mcp_${paramName}`}
|
||||
config={config}
|
||||
placeholder={config.placeholder}
|
||||
rows={4}
|
||||
value={value || ''}
|
||||
onChange={(newValue) => updateParameter(paramName, newValue)}
|
||||
placeholder={
|
||||
paramSchema.type === 'array'
|
||||
? `Enter JSON array, e.g. ["item1", "item2"] or comma-separated values`
|
||||
: paramSchema.description ||
|
||||
`Enter ${formatParameterLabel(paramName).toLowerCase()}`
|
||||
}
|
||||
isPreview={isPreview}
|
||||
disabled={disabled}
|
||||
blockId={blockId}
|
||||
accessiblePrefixes={accessiblePrefixes}
|
||||
rows={4}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
default: {
|
||||
const isPassword =
|
||||
@@ -485,10 +240,16 @@ export function McpDynamicArgs({
|
||||
paramName.toLowerCase().includes('password') ||
|
||||
paramName.toLowerCase().includes('token')
|
||||
const isNumeric = paramSchema.type === 'number' || paramSchema.type === 'integer'
|
||||
const config = createParamConfig(paramName, paramSchema, 'short-input')
|
||||
|
||||
return (
|
||||
<McpInputWithTags
|
||||
<ShortInput
|
||||
key={`${paramName}-short`}
|
||||
blockId={blockId}
|
||||
subBlockId={`_mcp_${paramName}`}
|
||||
config={config}
|
||||
placeholder={config.placeholder}
|
||||
password={isPassword}
|
||||
value={value?.toString() || ''}
|
||||
onChange={(newValue) => {
|
||||
let processedValue: any = newValue
|
||||
@@ -506,16 +267,8 @@ export function McpDynamicArgs({
|
||||
}
|
||||
updateParameter(paramName, processedValue)
|
||||
}}
|
||||
placeholder={
|
||||
paramSchema.type === 'array'
|
||||
? `Enter JSON array, e.g. ["item1", "item2"] or comma-separated values`
|
||||
: paramSchema.description ||
|
||||
`Enter ${formatParameterLabel(paramName).toLowerCase()}`
|
||||
}
|
||||
isPreview={isPreview}
|
||||
disabled={disabled}
|
||||
isPassword={isPassword}
|
||||
blockId={blockId}
|
||||
accessiblePrefixes={accessiblePrefixes}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -578,26 +331,40 @@ export function McpDynamicArgs({
|
||||
tabIndex={-1}
|
||||
readOnly
|
||||
/>
|
||||
<div className='space-y-4'>
|
||||
<div>
|
||||
{toolSchema.properties &&
|
||||
Object.entries(toolSchema.properties).map(([paramName, paramSchema]) => {
|
||||
Object.entries(toolSchema.properties).map(([paramName, paramSchema], index, entries) => {
|
||||
const inputType = getInputType(paramSchema as any)
|
||||
const showLabel = inputType !== 'switch'
|
||||
const showDivider = index < entries.length - 1
|
||||
|
||||
return (
|
||||
<div key={paramName} className='space-y-2'>
|
||||
{showLabel && (
|
||||
<Label
|
||||
className={cn(
|
||||
'font-medium text-sm',
|
||||
toolSchema.required?.includes(paramName) &&
|
||||
'after:ml-1 after:text-red-500 after:content-["*"]'
|
||||
)}
|
||||
>
|
||||
{formatParameterLabel(paramName)}
|
||||
</Label>
|
||||
<div key={paramName} className='subblock-row'>
|
||||
<div className='subblock-content flex flex-col gap-[10px]'>
|
||||
{showLabel && (
|
||||
<Label
|
||||
className={cn(
|
||||
'font-medium text-sm',
|
||||
toolSchema.required?.includes(paramName) &&
|
||||
'after:ml-1 after:text-red-500 after:content-["*"]'
|
||||
)}
|
||||
>
|
||||
{formatParameterLabel(paramName)}
|
||||
</Label>
|
||||
)}
|
||||
{renderParameterInput(paramName, paramSchema as any)}
|
||||
</div>
|
||||
{showDivider && (
|
||||
<div className='subblock-divider px-[2px] pt-[16px] pb-[13px]'>
|
||||
<div
|
||||
className='h-[1.25px]'
|
||||
style={{
|
||||
backgroundImage:
|
||||
'repeating-linear-gradient(to right, var(--border) 0px, var(--border) 6px, transparent 6px, transparent 12px)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{renderParameterInput(paramName, paramSchema as any)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
|
||||
import { isEqual } from 'lodash'
|
||||
import { ChevronDown, ChevronsUpDown, ChevronUp, Plus } from 'lucide-react'
|
||||
import { Button, Popover, PopoverContent, PopoverItem, PopoverTrigger } from '@/components/emcn'
|
||||
import { Trash } from '@/components/emcn/icons/trash'
|
||||
@@ -225,14 +226,18 @@ export function MessagesInput({
|
||||
[wandHook]
|
||||
)
|
||||
|
||||
/**
|
||||
* Initialize local state from stored or preview value
|
||||
*/
|
||||
const localMessagesRef = useRef(localMessages)
|
||||
localMessagesRef.current = localMessages
|
||||
|
||||
useEffect(() => {
|
||||
if (isPreview && previewValue && Array.isArray(previewValue)) {
|
||||
setLocalMessages(previewValue)
|
||||
if (!isEqual(localMessagesRef.current, previewValue)) {
|
||||
setLocalMessages(previewValue)
|
||||
}
|
||||
} else if (messages && Array.isArray(messages) && messages.length > 0) {
|
||||
setLocalMessages(messages)
|
||||
if (!isEqual(localMessagesRef.current, messages)) {
|
||||
setLocalMessages(messages)
|
||||
}
|
||||
}
|
||||
}, [isPreview, previewValue, messages])
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
|
||||
import { memo, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
|
||||
import { Check, Copy, Wand2 } from 'lucide-react'
|
||||
import { useReactFlow } from 'reactflow'
|
||||
import { Input } from '@/components/emcn'
|
||||
@@ -62,7 +62,7 @@ interface ShortInputProps {
|
||||
* - Copy to clipboard functionality
|
||||
* - Integrates with ReactFlow for zoom control
|
||||
*/
|
||||
export function ShortInput({
|
||||
export const ShortInput = memo(function ShortInput({
|
||||
blockId,
|
||||
subBlockId,
|
||||
placeholder,
|
||||
@@ -445,4 +445,4 @@ export function ShortInput({
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useRef } from 'react'
|
||||
import { useCallback, useRef } from 'react'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { Trash } from '@/components/emcn/icons/trash'
|
||||
import 'prismjs/components/prism-json'
|
||||
@@ -81,6 +81,8 @@ const createDefaultField = (): Field => ({
|
||||
*/
|
||||
const validateFieldName = (name: string): string => name.replace(/[\x00-\x1F"\\]/g, '').trim()
|
||||
|
||||
const jsonHighlight = (code: string): string => highlight(code, languages.json, 'json')
|
||||
|
||||
export function FieldFormat({
|
||||
blockId,
|
||||
subBlockId,
|
||||
@@ -138,17 +140,50 @@ export function FieldFormat({
|
||||
setStoreValue(fields.filter((field) => field.id !== id))
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a specific field property
|
||||
*/
|
||||
const updateField = (id: string, field: keyof Field, value: any) => {
|
||||
if (isReadOnly) return
|
||||
const storeValueRef = useRef(storeValue)
|
||||
storeValueRef.current = storeValue
|
||||
|
||||
const updatedValue =
|
||||
field === 'name' && typeof value === 'string' ? validateFieldName(value) : value
|
||||
const isReadOnlyRef = useRef(isReadOnly)
|
||||
isReadOnlyRef.current = isReadOnly
|
||||
|
||||
setStoreValue(fields.map((f) => (f.id === id ? { ...f, [field]: updatedValue } : f)))
|
||||
}
|
||||
const setStoreValueRef = useRef(setStoreValue)
|
||||
setStoreValueRef.current = setStoreValue
|
||||
|
||||
const updateField = useCallback(
|
||||
(id: string, fieldKey: keyof Field, fieldValue: Field[keyof Field]) => {
|
||||
if (isReadOnlyRef.current) return
|
||||
|
||||
const updatedValue =
|
||||
fieldKey === 'name' && typeof fieldValue === 'string'
|
||||
? validateFieldName(fieldValue)
|
||||
: fieldValue
|
||||
|
||||
const currentStoreValue = storeValueRef.current
|
||||
const currentFields: Field[] =
|
||||
Array.isArray(currentStoreValue) && currentStoreValue.length > 0
|
||||
? currentStoreValue
|
||||
: [createDefaultField()]
|
||||
|
||||
setStoreValueRef.current(
|
||||
currentFields.map((f) => (f.id === id ? { ...f, [fieldKey]: updatedValue } : f))
|
||||
)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const editorValueChangeHandlersRef = useRef<Record<string, (newValue: string) => void>>({})
|
||||
|
||||
const getEditorValueChangeHandler = useCallback(
|
||||
(fieldId: string): ((newValue: string) => void) => {
|
||||
if (!editorValueChangeHandlersRef.current[fieldId]) {
|
||||
editorValueChangeHandlersRef.current[fieldId] = (newValue: string) => {
|
||||
updateField(fieldId, 'value', newValue)
|
||||
}
|
||||
}
|
||||
return editorValueChangeHandlersRef.current[fieldId]
|
||||
},
|
||||
[updateField]
|
||||
)
|
||||
|
||||
/**
|
||||
* Toggles the collapsed state of a field
|
||||
@@ -222,15 +257,14 @@ export function FieldFormat({
|
||||
placeholder={placeholder}
|
||||
disabled={isReadOnly}
|
||||
autoComplete='off'
|
||||
className={cn('allow-scroll w-full overflow-auto', inputClassName)}
|
||||
style={{ overflowX: 'auto' }}
|
||||
className={cn('allow-scroll w-full overflow-x-auto overflow-y-hidden', inputClassName)}
|
||||
/>
|
||||
<div
|
||||
ref={(el) => {
|
||||
if (el) nameOverlayRefs.current[field.id] = el
|
||||
}}
|
||||
className='pointer-events-none absolute inset-0 flex items-center overflow-x-auto bg-transparent px-[8px] py-[6px] font-medium font-sans text-sm'
|
||||
style={{ overflowX: 'auto' }}
|
||||
style={{ scrollbarWidth: 'none' }}
|
||||
>
|
||||
<div
|
||||
className='w-full whitespace-pre'
|
||||
@@ -359,12 +393,8 @@ export function FieldFormat({
|
||||
</Code.Placeholder>
|
||||
<Editor
|
||||
value={fieldValue}
|
||||
onValueChange={(newValue) => {
|
||||
if (!isReadOnly) {
|
||||
updateField(field.id, 'value', newValue)
|
||||
}
|
||||
}}
|
||||
highlight={(code) => highlight(code, languages.json, 'json')}
|
||||
onValueChange={getEditorValueChangeHandler(field.id)}
|
||||
highlight={jsonHighlight}
|
||||
disabled={isReadOnly}
|
||||
{...getCodeEditorProps({ disabled: isReadOnly })}
|
||||
/>
|
||||
@@ -398,12 +428,8 @@ export function FieldFormat({
|
||||
</Code.Placeholder>
|
||||
<Editor
|
||||
value={fieldValue}
|
||||
onValueChange={(newValue) => {
|
||||
if (!isReadOnly) {
|
||||
updateField(field.id, 'value', newValue)
|
||||
}
|
||||
}}
|
||||
highlight={(code) => highlight(code, languages.json, 'json')}
|
||||
onValueChange={getEditorValueChangeHandler(field.id)}
|
||||
highlight={jsonHighlight}
|
||||
disabled={isReadOnly}
|
||||
{...getCodeEditorProps({ disabled: isReadOnly })}
|
||||
/>
|
||||
@@ -439,12 +465,8 @@ export function FieldFormat({
|
||||
</Code.Placeholder>
|
||||
<Editor
|
||||
value={fieldValue}
|
||||
onValueChange={(newValue) => {
|
||||
if (!isReadOnly) {
|
||||
updateField(field.id, 'value', newValue)
|
||||
}
|
||||
}}
|
||||
highlight={(code) => highlight(code, languages.json, 'json')}
|
||||
onValueChange={getEditorValueChangeHandler(field.id)}
|
||||
highlight={jsonHighlight}
|
||||
disabled={isReadOnly}
|
||||
{...getCodeEditorProps({ disabled: isReadOnly })}
|
||||
/>
|
||||
@@ -476,15 +498,14 @@ export function FieldFormat({
|
||||
placeholder={valuePlaceholder}
|
||||
disabled={isReadOnly}
|
||||
autoComplete='off'
|
||||
className={cn('allow-scroll w-full overflow-auto', inputClassName)}
|
||||
style={{ overflowX: 'auto' }}
|
||||
className={cn('allow-scroll w-full overflow-x-auto overflow-y-hidden', inputClassName)}
|
||||
/>
|
||||
<div
|
||||
ref={(el) => {
|
||||
if (el) overlayRefs.current[field.id] = el
|
||||
}}
|
||||
className='pointer-events-none absolute inset-0 flex items-center overflow-x-auto bg-transparent px-[8px] py-[6px] font-medium font-sans text-sm'
|
||||
style={{ overflowX: 'auto' }}
|
||||
style={{ scrollbarWidth: 'none' }}
|
||||
>
|
||||
<div
|
||||
className='w-full whitespace-pre'
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { usePopoverContext } from '@/components/emcn'
|
||||
import type { BlockTagGroup, NestedBlockTagGroup } from '../types'
|
||||
import { useNestedNavigation } from '../tag-dropdown'
|
||||
import type { BlockTagGroup, NestedBlockTagGroup, NestedTag } from '../types'
|
||||
|
||||
/**
|
||||
* Keyboard navigation handler component that uses popover context
|
||||
@@ -15,6 +16,90 @@ interface KeyboardNavigationHandlerProps {
|
||||
handleTagSelect: (tag: string, group?: BlockTagGroup) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively finds a folder in nested tags by its ID
|
||||
*/
|
||||
const findFolderInNested = (
|
||||
nestedTags: NestedTag[],
|
||||
blockId: string,
|
||||
targetFolderId: string
|
||||
): NestedTag | null => {
|
||||
for (const nestedTag of nestedTags) {
|
||||
const folderId = `${blockId}-${nestedTag.key}`
|
||||
if (folderId === targetFolderId) {
|
||||
return nestedTag
|
||||
}
|
||||
if (nestedTag.nestedChildren) {
|
||||
const found = findFolderInNested(nestedTag.nestedChildren, blockId, targetFolderId)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively finds folder info for a tag that can be expanded.
|
||||
* Returns both the folder metadata and the NestedTag object for navigation.
|
||||
*/
|
||||
const findFolderInfoForTag = (
|
||||
nestedTags: NestedTag[],
|
||||
targetTag: string,
|
||||
group: NestedBlockTagGroup
|
||||
): {
|
||||
id: string
|
||||
title: string
|
||||
parentTag: string
|
||||
group: NestedBlockTagGroup
|
||||
nestedTag: NestedTag
|
||||
} | null => {
|
||||
for (const nestedTag of nestedTags) {
|
||||
if (
|
||||
nestedTag.parentTag === targetTag &&
|
||||
(nestedTag.children?.length || nestedTag.nestedChildren?.length)
|
||||
) {
|
||||
return {
|
||||
id: `${group.blockId}-${nestedTag.key}`,
|
||||
title: nestedTag.display,
|
||||
parentTag: nestedTag.parentTag,
|
||||
group,
|
||||
nestedTag,
|
||||
}
|
||||
}
|
||||
if (nestedTag.nestedChildren) {
|
||||
const found = findFolderInfoForTag(nestedTag.nestedChildren, targetTag, group)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively checks if a tag is a child of any folder.
|
||||
* This includes both leaf children and nested folder parent tags.
|
||||
*/
|
||||
const isChildOfAnyFolder = (nestedTags: NestedTag[], tag: string): boolean => {
|
||||
for (const nestedTag of nestedTags) {
|
||||
if (nestedTag.children) {
|
||||
for (const child of nestedTag.children) {
|
||||
if (child.fullTag === tag) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
if (nestedTag.nestedChildren) {
|
||||
for (const nestedChild of nestedTag.nestedChildren) {
|
||||
if (nestedChild.parentTag === tag) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if (isChildOfAnyFolder(nestedTag.nestedChildren, tag)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export const KeyboardNavigationHandler: React.FC<KeyboardNavigationHandlerProps> = ({
|
||||
visible,
|
||||
selectedIndex,
|
||||
@@ -23,55 +108,66 @@ export const KeyboardNavigationHandler: React.FC<KeyboardNavigationHandlerProps>
|
||||
nestedBlockTagGroups,
|
||||
handleTagSelect,
|
||||
}) => {
|
||||
const { openFolder, closeFolder, isInFolder, currentFolder } = usePopoverContext()
|
||||
const { openFolder, closeFolder, isInFolder, currentFolder, setKeyboardNav } = usePopoverContext()
|
||||
const nestedNav = useNestedNavigation()
|
||||
|
||||
const visibleIndices = useMemo(() => {
|
||||
const indices: number[] = []
|
||||
const nestedPath = nestedNav?.nestedPath ?? []
|
||||
|
||||
if (isInFolder && currentFolder) {
|
||||
for (const group of nestedBlockTagGroups) {
|
||||
for (const nestedTag of group.nestedTags) {
|
||||
const folderId = `${group.blockId}-${nestedTag.key}`
|
||||
if (folderId === currentFolder && nestedTag.children) {
|
||||
// First, add the parent tag itself (so it's navigable as the first item)
|
||||
if (nestedTag.parentTag) {
|
||||
const parentIdx = flatTagList.findIndex((item) => item.tag === nestedTag.parentTag)
|
||||
if (parentIdx >= 0) {
|
||||
indices.push(parentIdx)
|
||||
}
|
||||
}
|
||||
// Then add all children
|
||||
for (const child of nestedTag.children) {
|
||||
const idx = flatTagList.findIndex((item) => item.tag === child.fullTag)
|
||||
if (idx >= 0) {
|
||||
indices.push(idx)
|
||||
}
|
||||
}
|
||||
let currentNestedTag: NestedTag | null = null
|
||||
|
||||
if (nestedPath.length > 0) {
|
||||
currentNestedTag = nestedPath[nestedPath.length - 1]
|
||||
} else {
|
||||
for (const group of nestedBlockTagGroups) {
|
||||
const folder = findFolderInNested(group.nestedTags, group.blockId, currentFolder)
|
||||
if (folder) {
|
||||
currentNestedTag = folder
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (currentNestedTag) {
|
||||
if (currentNestedTag.parentTag) {
|
||||
const parentIdx = flatTagList.findIndex(
|
||||
(item) => item.tag === currentNestedTag!.parentTag
|
||||
)
|
||||
if (parentIdx >= 0) {
|
||||
indices.push(parentIdx)
|
||||
}
|
||||
}
|
||||
if (currentNestedTag.children) {
|
||||
for (const child of currentNestedTag.children) {
|
||||
const idx = flatTagList.findIndex((item) => item.tag === child.fullTag)
|
||||
if (idx >= 0) {
|
||||
indices.push(idx)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (currentNestedTag.nestedChildren) {
|
||||
for (const nestedChild of currentNestedTag.nestedChildren) {
|
||||
if (nestedChild.parentTag) {
|
||||
const idx = flatTagList.findIndex((item) => item.tag === nestedChild.parentTag)
|
||||
if (idx >= 0) {
|
||||
indices.push(idx)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// We're at root level, show all non-child items
|
||||
// (variables and parent tags, but not their children)
|
||||
for (let i = 0; i < flatTagList.length; i++) {
|
||||
const tag = flatTagList[i].tag
|
||||
|
||||
// Check if this is a child of a parent folder
|
||||
let isChild = false
|
||||
for (const group of nestedBlockTagGroups) {
|
||||
for (const nestedTag of group.nestedTags) {
|
||||
if (nestedTag.children) {
|
||||
for (const child of nestedTag.children) {
|
||||
if (child.fullTag === tag) {
|
||||
isChild = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isChild) break
|
||||
if (isChildOfAnyFolder(group.nestedTags, tag)) {
|
||||
isChild = true
|
||||
break
|
||||
}
|
||||
if (isChild) break
|
||||
}
|
||||
|
||||
if (!isChild) {
|
||||
@@ -81,16 +177,16 @@ export const KeyboardNavigationHandler: React.FC<KeyboardNavigationHandlerProps>
|
||||
}
|
||||
|
||||
return indices
|
||||
}, [isInFolder, currentFolder, flatTagList, nestedBlockTagGroups])
|
||||
}, [isInFolder, currentFolder, flatTagList, nestedBlockTagGroups, nestedNav])
|
||||
|
||||
const nestedPathLength = nestedNav?.nestedPath.length ?? 0
|
||||
|
||||
// Auto-select first visible item when entering/exiting folders
|
||||
useEffect(() => {
|
||||
if (!visible || visibleIndices.length === 0) return
|
||||
|
||||
if (!visibleIndices.includes(selectedIndex)) {
|
||||
setSelectedIndex(visibleIndices[0])
|
||||
}
|
||||
}, [visible, isInFolder, currentFolder, visibleIndices, selectedIndex, setSelectedIndex])
|
||||
setSelectedIndex(visibleIndices[0])
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [visible, isInFolder, currentFolder, nestedPathLength])
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible || !flatTagList.length) return
|
||||
@@ -117,89 +213,98 @@ export const KeyboardNavigationHandler: React.FC<KeyboardNavigationHandlerProps>
|
||||
id: string
|
||||
title: string
|
||||
parentTag: string
|
||||
group: BlockTagGroup
|
||||
group: NestedBlockTagGroup
|
||||
nestedTag: NestedTag
|
||||
} | null = null
|
||||
|
||||
if (selected) {
|
||||
for (const group of nestedBlockTagGroups) {
|
||||
for (const nestedTag of group.nestedTags) {
|
||||
if (
|
||||
nestedTag.parentTag === selected.tag &&
|
||||
nestedTag.children &&
|
||||
nestedTag.children.length > 0
|
||||
) {
|
||||
currentFolderInfo = {
|
||||
id: `${selected.group?.blockId}-${nestedTag.key}`,
|
||||
title: nestedTag.display,
|
||||
parentTag: nestedTag.parentTag,
|
||||
group,
|
||||
}
|
||||
break
|
||||
}
|
||||
const folderInfo = findFolderInfoForTag(group.nestedTags, selected.tag, group)
|
||||
if (folderInfo) {
|
||||
currentFolderInfo = folderInfo
|
||||
break
|
||||
}
|
||||
if (currentFolderInfo) break
|
||||
}
|
||||
}
|
||||
|
||||
const scrollIntoView = () => {
|
||||
setTimeout(() => {
|
||||
const selectedItem = document.querySelector<HTMLElement>(
|
||||
'[data-radix-popper-content-wrapper] [aria-selected="true"]'
|
||||
)
|
||||
if (selectedItem) {
|
||||
selectedItem.scrollIntoView({ behavior: 'auto', block: 'nearest' })
|
||||
}
|
||||
}, 0)
|
||||
}
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setKeyboardNav(true)
|
||||
if (visibleIndices.length > 0) {
|
||||
const currentVisibleIndex = visibleIndices.indexOf(selectedIndex)
|
||||
let newIndex: number
|
||||
if (currentVisibleIndex === -1) {
|
||||
setSelectedIndex(visibleIndices[0])
|
||||
newIndex = visibleIndices[0]
|
||||
} else if (currentVisibleIndex < visibleIndices.length - 1) {
|
||||
setSelectedIndex(visibleIndices[currentVisibleIndex + 1])
|
||||
newIndex = visibleIndices[currentVisibleIndex + 1]
|
||||
} else {
|
||||
newIndex = visibleIndices[0]
|
||||
}
|
||||
setSelectedIndex(newIndex)
|
||||
scrollIntoView()
|
||||
}
|
||||
break
|
||||
case 'ArrowUp':
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setKeyboardNav(true)
|
||||
if (visibleIndices.length > 0) {
|
||||
const currentVisibleIndex = visibleIndices.indexOf(selectedIndex)
|
||||
let newIndex: number
|
||||
if (currentVisibleIndex === -1) {
|
||||
setSelectedIndex(visibleIndices[0])
|
||||
newIndex = visibleIndices[visibleIndices.length - 1]
|
||||
} else if (currentVisibleIndex > 0) {
|
||||
setSelectedIndex(visibleIndices[currentVisibleIndex - 1])
|
||||
newIndex = visibleIndices[currentVisibleIndex - 1]
|
||||
} else {
|
||||
newIndex = visibleIndices[visibleIndices.length - 1]
|
||||
}
|
||||
setSelectedIndex(newIndex)
|
||||
scrollIntoView()
|
||||
}
|
||||
break
|
||||
case 'Enter':
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (selected && selectedIndex >= 0 && selectedIndex < flatTagList.length) {
|
||||
if (currentFolderInfo && !isInFolder) {
|
||||
// It's a folder, open it
|
||||
handleTagSelect(selected.tag, selected.group)
|
||||
}
|
||||
break
|
||||
case 'ArrowRight':
|
||||
if (currentFolderInfo) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (isInFolder && nestedNav) {
|
||||
nestedNav.navigateIn(currentFolderInfo.nestedTag, currentFolderInfo.group)
|
||||
} else {
|
||||
openFolderWithSelection(
|
||||
currentFolderInfo.id,
|
||||
currentFolderInfo.title,
|
||||
currentFolderInfo.parentTag,
|
||||
currentFolderInfo.group
|
||||
)
|
||||
} else {
|
||||
// Not a folder, select it
|
||||
handleTagSelect(selected.tag, selected.group)
|
||||
}
|
||||
}
|
||||
break
|
||||
case 'ArrowRight':
|
||||
if (currentFolderInfo && !isInFolder) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
openFolderWithSelection(
|
||||
currentFolderInfo.id,
|
||||
currentFolderInfo.title,
|
||||
currentFolderInfo.parentTag,
|
||||
currentFolderInfo.group
|
||||
)
|
||||
}
|
||||
break
|
||||
case 'ArrowLeft':
|
||||
if (isInFolder) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (nestedNav?.navigateBack()) {
|
||||
return
|
||||
}
|
||||
closeFolder()
|
||||
let firstRootIndex = 0
|
||||
for (let i = 0; i < flatTagList.length; i++) {
|
||||
@@ -239,6 +344,8 @@ export const KeyboardNavigationHandler: React.FC<KeyboardNavigationHandlerProps>
|
||||
isInFolder,
|
||||
setSelectedIndex,
|
||||
handleTagSelect,
|
||||
nestedNav,
|
||||
setKeyboardNav,
|
||||
])
|
||||
|
||||
return null
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,14 +10,27 @@ export interface BlockTagGroup {
|
||||
}
|
||||
|
||||
/**
|
||||
* Nested tag structure for hierarchical display
|
||||
* Child tag within a nested structure
|
||||
*/
|
||||
export interface NestedTagChild {
|
||||
key: string
|
||||
display: string
|
||||
fullTag: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Nested tag structure for hierarchical display.
|
||||
* Supports recursive nesting for deeply nested object structures.
|
||||
*/
|
||||
export interface NestedTag {
|
||||
key: string
|
||||
display: string
|
||||
fullTag?: string
|
||||
parentTag?: string // Tag for the parent object when it has children
|
||||
children?: Array<{ key: string; display: string; fullTag: string }>
|
||||
parentTag?: string
|
||||
/** Leaf children (no further nesting) */
|
||||
children?: NestedTagChild[]
|
||||
/** Recursively nested folders */
|
||||
nestedChildren?: NestedTag[]
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type React from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { Loader2, WrenchIcon, XIcon } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
Code,
|
||||
FileSelectorInput,
|
||||
FileUpload,
|
||||
FolderSelectorInput,
|
||||
LongInput,
|
||||
ProjectSelectorInput,
|
||||
SheetSelectorInput,
|
||||
@@ -45,7 +46,9 @@ import {
|
||||
TimeInput,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components'
|
||||
import { DocumentSelector } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/document-selector/document-selector'
|
||||
import { DocumentTagEntry } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/document-tag-entry/document-tag-entry'
|
||||
import { KnowledgeBaseSelector } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/knowledge-base-selector/knowledge-base-selector'
|
||||
import { KnowledgeTagFilters } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/knowledge-tag-filters/knowledge-tag-filters'
|
||||
import {
|
||||
type CustomTool,
|
||||
CustomToolModal,
|
||||
@@ -75,6 +78,13 @@ import {
|
||||
isPasswordParameter,
|
||||
type ToolParameterConfig,
|
||||
} from '@/tools/params'
|
||||
import {
|
||||
buildCanonicalIndex,
|
||||
buildPreviewContextValues,
|
||||
type CanonicalIndex,
|
||||
evaluateSubBlockCondition,
|
||||
type SubBlockCondition,
|
||||
} from '@/tools/params-resolver'
|
||||
|
||||
const logger = createLogger('ToolInput')
|
||||
|
||||
@@ -304,6 +314,42 @@ function SheetSelectorSyncWrapper({
|
||||
)
|
||||
}
|
||||
|
||||
function FolderSelectorSyncWrapper({
|
||||
blockId,
|
||||
paramId,
|
||||
value,
|
||||
onChange,
|
||||
uiComponent,
|
||||
disabled,
|
||||
previewContextValues,
|
||||
}: {
|
||||
blockId: string
|
||||
paramId: string
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
uiComponent: any
|
||||
disabled: boolean
|
||||
previewContextValues?: Record<string, any>
|
||||
}) {
|
||||
return (
|
||||
<GenericSyncWrapper blockId={blockId} paramId={paramId} value={value} onChange={onChange}>
|
||||
<FolderSelectorInput
|
||||
blockId={blockId}
|
||||
subBlock={{
|
||||
id: paramId,
|
||||
type: 'folder-selector' as const,
|
||||
title: paramId,
|
||||
serviceId: uiComponent.serviceId,
|
||||
requiredScopes: uiComponent.requiredScopes || [],
|
||||
placeholder: uiComponent.placeholder,
|
||||
dependsOn: uiComponent.dependsOn,
|
||||
}}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</GenericSyncWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
function KnowledgeBaseSelectorSyncWrapper({
|
||||
blockId,
|
||||
paramId,
|
||||
@@ -342,6 +388,7 @@ function DocumentSelectorSyncWrapper({
|
||||
onChange,
|
||||
uiComponent,
|
||||
disabled,
|
||||
previewContextValues,
|
||||
}: {
|
||||
blockId: string
|
||||
paramId: string
|
||||
@@ -349,6 +396,7 @@ function DocumentSelectorSyncWrapper({
|
||||
onChange: (value: string) => void
|
||||
uiComponent: any
|
||||
disabled: boolean
|
||||
previewContextValues?: Record<string, any>
|
||||
}) {
|
||||
return (
|
||||
<GenericSyncWrapper blockId={blockId} paramId={paramId} value={value} onChange={onChange}>
|
||||
@@ -361,6 +409,67 @@ function DocumentSelectorSyncWrapper({
|
||||
dependsOn: ['knowledgeBaseId'],
|
||||
}}
|
||||
disabled={disabled}
|
||||
previewContextValues={previewContextValues}
|
||||
/>
|
||||
</GenericSyncWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
function DocumentTagEntrySyncWrapper({
|
||||
blockId,
|
||||
paramId,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
previewContextValues,
|
||||
}: {
|
||||
blockId: string
|
||||
paramId: string
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
disabled: boolean
|
||||
previewContextValues?: Record<string, any>
|
||||
}) {
|
||||
return (
|
||||
<GenericSyncWrapper blockId={blockId} paramId={paramId} value={value} onChange={onChange}>
|
||||
<DocumentTagEntry
|
||||
blockId={blockId}
|
||||
subBlock={{
|
||||
id: paramId,
|
||||
type: 'document-tag-entry',
|
||||
}}
|
||||
disabled={disabled}
|
||||
previewContextValues={previewContextValues}
|
||||
/>
|
||||
</GenericSyncWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
function KnowledgeTagFiltersSyncWrapper({
|
||||
blockId,
|
||||
paramId,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
previewContextValues,
|
||||
}: {
|
||||
blockId: string
|
||||
paramId: string
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
disabled: boolean
|
||||
previewContextValues?: Record<string, any>
|
||||
}) {
|
||||
return (
|
||||
<GenericSyncWrapper blockId={blockId} paramId={paramId} value={value} onChange={onChange}>
|
||||
<KnowledgeTagFilters
|
||||
blockId={blockId}
|
||||
subBlock={{
|
||||
id: paramId,
|
||||
type: 'knowledge-tag-filters',
|
||||
}}
|
||||
disabled={disabled}
|
||||
previewContextValues={previewContextValues}
|
||||
/>
|
||||
</GenericSyncWrapper>
|
||||
)
|
||||
@@ -497,11 +606,15 @@ function CheckboxListSyncWrapper({
|
||||
}
|
||||
|
||||
function ComboboxSyncWrapper({
|
||||
blockId,
|
||||
paramId,
|
||||
value,
|
||||
onChange,
|
||||
uiComponent,
|
||||
disabled,
|
||||
}: {
|
||||
blockId: string
|
||||
paramId: string
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
uiComponent: any
|
||||
@@ -512,13 +625,15 @@ function ComboboxSyncWrapper({
|
||||
)
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
options={options}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={uiComponent.placeholder || 'Select option'}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<GenericSyncWrapper blockId={blockId} paramId={paramId} value={value} onChange={onChange}>
|
||||
<Combobox
|
||||
options={options}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={uiComponent.placeholder || 'Select option'}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</GenericSyncWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -597,6 +712,8 @@ function SlackSelectorSyncWrapper({
|
||||
}
|
||||
|
||||
function WorkflowSelectorSyncWrapper({
|
||||
blockId,
|
||||
paramId,
|
||||
value,
|
||||
onChange,
|
||||
uiComponent,
|
||||
@@ -604,6 +721,8 @@ function WorkflowSelectorSyncWrapper({
|
||||
workspaceId,
|
||||
currentWorkflowId,
|
||||
}: {
|
||||
blockId: string
|
||||
paramId: string
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
uiComponent: any
|
||||
@@ -623,15 +742,17 @@ function WorkflowSelectorSyncWrapper({
|
||||
}))
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
options={options}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={uiComponent.placeholder || 'Select workflow'}
|
||||
disabled={disabled || isLoading}
|
||||
searchable
|
||||
searchPlaceholder='Search workflows...'
|
||||
/>
|
||||
<GenericSyncWrapper blockId={blockId} paramId={paramId} value={value} onChange={onChange}>
|
||||
<Combobox
|
||||
options={options}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={uiComponent.placeholder || 'Select workflow'}
|
||||
disabled={disabled || isLoading}
|
||||
searchable
|
||||
searchPlaceholder='Search workflows...'
|
||||
/>
|
||||
</GenericSyncWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -877,7 +998,7 @@ function createToolIcon(bgColor: string, IconComponent: any) {
|
||||
* - Allows drag-and-drop reordering of selected tools
|
||||
* - Supports tool usage control (auto/force/none) for compatible LLM providers
|
||||
*/
|
||||
export function ToolInput({
|
||||
export const ToolInput = memo(function ToolInput({
|
||||
blockId,
|
||||
subBlockId,
|
||||
isPreview = false,
|
||||
@@ -1792,57 +1913,13 @@ export function ToolInput({
|
||||
return toolParams?.toolConfig?.oauth
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluates parameter conditions to determine if a parameter should be visible.
|
||||
*
|
||||
* @remarks
|
||||
* Supports field value matching with arrays, negation via `not`, and
|
||||
* compound conditions via `and`. Used for conditional parameter visibility.
|
||||
*
|
||||
* @param param - The parameter configuration with optional condition
|
||||
* @param tool - The current tool instance with its parameter values
|
||||
* @returns `true` if the parameter should be shown based on its condition
|
||||
*/
|
||||
const evaluateParameterCondition = (param: any, tool: StoredTool): boolean => {
|
||||
if (!('uiComponent' in param) || !param.uiComponent?.condition) return true
|
||||
|
||||
const condition = param.uiComponent.condition
|
||||
const currentValues: Record<string, any> = {
|
||||
operation: tool.operation,
|
||||
...tool.params,
|
||||
}
|
||||
|
||||
const fieldValue = currentValues[condition.field]
|
||||
let result = false
|
||||
|
||||
if (Array.isArray(condition.value)) {
|
||||
result = condition.value.includes(fieldValue)
|
||||
} else {
|
||||
result = fieldValue === condition.value
|
||||
}
|
||||
|
||||
if (condition.not) {
|
||||
result = !result
|
||||
}
|
||||
|
||||
if (condition.and) {
|
||||
const andFieldValue = currentValues[condition.and.field]
|
||||
let andResult = false
|
||||
|
||||
if (Array.isArray(condition.and.value)) {
|
||||
andResult = condition.and.value.includes(andFieldValue)
|
||||
} else {
|
||||
andResult = andFieldValue === condition.and.value
|
||||
}
|
||||
|
||||
if (condition.and.not) {
|
||||
andResult = !andResult
|
||||
}
|
||||
|
||||
result = result && andResult
|
||||
}
|
||||
|
||||
return result
|
||||
const currentValues: Record<string, any> = { operation: tool.operation, ...tool.params }
|
||||
return evaluateSubBlockCondition(
|
||||
param.uiComponent.condition as SubBlockCondition,
|
||||
currentValues
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1961,7 +2038,7 @@ export function ToolInput({
|
||||
onChange={onChange}
|
||||
uiComponent={uiComponent}
|
||||
disabled={disabled}
|
||||
previewContextValues={currentToolParams as any}
|
||||
previewContextValues={currentToolParams}
|
||||
selectorType='channel-selector'
|
||||
/>
|
||||
)
|
||||
@@ -1975,7 +2052,7 @@ export function ToolInput({
|
||||
onChange={onChange}
|
||||
uiComponent={uiComponent}
|
||||
disabled={disabled}
|
||||
previewContextValues={currentToolParams as any}
|
||||
previewContextValues={currentToolParams}
|
||||
selectorType='user-selector'
|
||||
/>
|
||||
)
|
||||
@@ -1992,10 +2069,11 @@ export function ToolInput({
|
||||
placeholder: uiComponent.placeholder,
|
||||
requiredScopes: uiComponent.requiredScopes,
|
||||
dependsOn: uiComponent.dependsOn,
|
||||
canonicalParamId: uiComponent.canonicalParamId ?? param.id,
|
||||
}}
|
||||
onProjectSelect={onChange}
|
||||
disabled={disabled}
|
||||
previewContextValues={currentToolParams as any}
|
||||
previewContextValues={currentToolParams}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -2020,7 +2098,7 @@ export function ToolInput({
|
||||
onChange={onChange}
|
||||
uiComponent={uiComponent}
|
||||
disabled={disabled}
|
||||
previewContextValues={currentToolParams as any}
|
||||
previewContextValues={currentToolParams}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -2033,7 +2111,20 @@ export function ToolInput({
|
||||
onChange={onChange}
|
||||
uiComponent={uiComponent}
|
||||
disabled={disabled}
|
||||
previewContextValues={currentToolParams as any}
|
||||
previewContextValues={currentToolParams}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'folder-selector':
|
||||
return (
|
||||
<FolderSelectorSyncWrapper
|
||||
blockId={blockId}
|
||||
paramId={param.id}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
uiComponent={uiComponent}
|
||||
disabled={disabled}
|
||||
previewContextValues={currentToolParams}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -2052,6 +2143,8 @@ export function ToolInput({
|
||||
case 'combobox':
|
||||
return (
|
||||
<ComboboxSyncWrapper
|
||||
blockId={blockId}
|
||||
paramId={param.id}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
uiComponent={uiComponent}
|
||||
@@ -2110,6 +2203,8 @@ export function ToolInput({
|
||||
case 'workflow-selector':
|
||||
return (
|
||||
<WorkflowSelectorSyncWrapper
|
||||
blockId={blockId}
|
||||
paramId={param.id}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
uiComponent={uiComponent}
|
||||
@@ -2167,6 +2262,31 @@ export function ToolInput({
|
||||
onChange={onChange}
|
||||
uiComponent={uiComponent}
|
||||
disabled={disabled}
|
||||
previewContextValues={currentToolParams}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'document-tag-entry':
|
||||
return (
|
||||
<DocumentTagEntrySyncWrapper
|
||||
blockId={blockId}
|
||||
paramId={param.id}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
previewContextValues={currentToolParams}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'knowledge-tag-filters':
|
||||
return (
|
||||
<KnowledgeTagFiltersSyncWrapper
|
||||
blockId={blockId}
|
||||
paramId={param.id}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
previewContextValues={currentToolParams}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -2225,9 +2345,27 @@ export function ToolInput({
|
||||
// Get tool parameters using the new utility with block type for UI components
|
||||
const toolParams =
|
||||
!isCustomTool && !isMcpTool && currentToolId
|
||||
? getToolParametersConfig(currentToolId, tool.type)
|
||||
? getToolParametersConfig(currentToolId, tool.type, {
|
||||
operation: tool.operation,
|
||||
...tool.params,
|
||||
})
|
||||
: null
|
||||
|
||||
// Build canonical index for proper dependency resolution
|
||||
const toolCanonicalIndex: CanonicalIndex | null = toolBlock?.subBlocks
|
||||
? buildCanonicalIndex(toolBlock.subBlocks)
|
||||
: null
|
||||
|
||||
// Build preview context with canonical resolution
|
||||
const toolContextValues = toolCanonicalIndex
|
||||
? buildPreviewContextValues(tool.params || {}, {
|
||||
blockType: tool.type,
|
||||
subBlocks: toolBlock!.subBlocks,
|
||||
canonicalIndex: toolCanonicalIndex,
|
||||
values: { operation: tool.operation, ...tool.params },
|
||||
})
|
||||
: tool.params || {}
|
||||
|
||||
// For custom tools, resolve from reference (new format) or use inline (legacy)
|
||||
const resolvedCustomTool = isCustomTool
|
||||
? resolveCustomToolFromReference(tool, customTools)
|
||||
@@ -2590,7 +2728,7 @@ export function ToolInput({
|
||||
{param.required && param.visibility === 'user-only' && (
|
||||
<span className='ml-1'>*</span>
|
||||
)}
|
||||
{(!param.required || param.visibility !== 'user-only') && (
|
||||
{param.visibility === 'user-or-llm' && (
|
||||
<span className='ml-[6px] text-[12px] text-[var(--text-tertiary)]'>
|
||||
(optional)
|
||||
</span>
|
||||
@@ -2603,7 +2741,7 @@ export function ToolInput({
|
||||
tool.params?.[param.id] || '',
|
||||
(value) => handleParamChange(toolIndex, param.id, value),
|
||||
toolIndex,
|
||||
tool.params || {}
|
||||
toolContextValues as Record<string, string>
|
||||
)
|
||||
) : (
|
||||
<ShortInput
|
||||
@@ -2682,4 +2820,4 @@ export function ToolInput({
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { isEqual } from 'lodash'
|
||||
import {
|
||||
buildCanonicalIndex,
|
||||
isNonEmptyValue,
|
||||
@@ -97,47 +98,60 @@ export function useDependsOnGate(
|
||||
return rawValue
|
||||
}
|
||||
|
||||
// Get values for all dependency fields (both all and any)
|
||||
const dependencyValuesMap = useSubBlockStore((state) => {
|
||||
if (allDependsOnFields.length === 0) return {} as Record<string, unknown>
|
||||
const dependencySelector = useCallback(
|
||||
(state: ReturnType<typeof useSubBlockStore.getState>) => {
|
||||
if (allDependsOnFields.length === 0) return {} as Record<string, unknown>
|
||||
|
||||
// If previewContextValues are provided (e.g., tool parameters), use those first
|
||||
if (previewContextValues) {
|
||||
// If previewContextValues are provided (e.g., tool parameters), use those first
|
||||
if (previewContextValues) {
|
||||
const map: Record<string, unknown> = {}
|
||||
for (const key of allDependsOnFields) {
|
||||
const resolvedValue = resolveDependencyValue(
|
||||
key,
|
||||
previewContextValues,
|
||||
canonicalIndex,
|
||||
canonicalModeOverrides
|
||||
)
|
||||
map[key] = normalizeDependencyValue(resolvedValue)
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
if (!activeWorkflowId) {
|
||||
const map: Record<string, unknown> = {}
|
||||
for (const key of allDependsOnFields) {
|
||||
map[key] = null
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
const workflowValues = state.workflowValues[activeWorkflowId] || {}
|
||||
const blockValues = (workflowValues as any)[blockId] || {}
|
||||
const map: Record<string, unknown> = {}
|
||||
for (const key of allDependsOnFields) {
|
||||
const resolvedValue = resolveDependencyValue(
|
||||
key,
|
||||
previewContextValues,
|
||||
blockValues,
|
||||
canonicalIndex,
|
||||
canonicalModeOverrides
|
||||
)
|
||||
map[key] = normalizeDependencyValue(resolvedValue)
|
||||
}
|
||||
return map
|
||||
}
|
||||
},
|
||||
[
|
||||
allDependsOnFields,
|
||||
previewContextValues,
|
||||
activeWorkflowId,
|
||||
blockId,
|
||||
canonicalIndex,
|
||||
canonicalModeOverrides,
|
||||
]
|
||||
)
|
||||
|
||||
if (!activeWorkflowId) {
|
||||
const map: Record<string, unknown> = {}
|
||||
for (const key of allDependsOnFields) {
|
||||
map[key] = null
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
const workflowValues = state.workflowValues[activeWorkflowId] || {}
|
||||
const blockValues = (workflowValues as any)[blockId] || {}
|
||||
const map: Record<string, unknown> = {}
|
||||
for (const key of allDependsOnFields) {
|
||||
const resolvedValue = resolveDependencyValue(
|
||||
key,
|
||||
blockValues,
|
||||
canonicalIndex,
|
||||
canonicalModeOverrides
|
||||
)
|
||||
map[key] = normalizeDependencyValue(resolvedValue)
|
||||
}
|
||||
return map
|
||||
})
|
||||
// Get values for all dependency fields (both all and any)
|
||||
// Use isEqual to prevent re-renders when dependency values haven't actually changed
|
||||
const dependencyValuesMap = useSubBlockStore(dependencySelector, isEqual)
|
||||
|
||||
const depsSatisfied = useMemo(() => {
|
||||
// Check all fields (AND logic) - all must be satisfied
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { isEqual } from 'lodash'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
||||
import { getProviderFromModel } from '@/providers/utils'
|
||||
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
|
||||
@@ -51,16 +52,12 @@ export function useSubBlockValue<T = any>(
|
||||
)
|
||||
)
|
||||
|
||||
// Keep a ref to the latest value to prevent unnecessary re-renders
|
||||
const valueRef = useRef<T | null>(null)
|
||||
|
||||
// Streaming refs
|
||||
const lastEmittedValueRef = useRef<T | null>(null)
|
||||
const streamingValueRef = useRef<T | null>(null)
|
||||
const wasStreamingRef = useRef<boolean>(false)
|
||||
|
||||
// Get value from subblock store, keyed by active workflow id
|
||||
// Optimized: use shallow equality comparison to prevent re-renders when other fields change
|
||||
const storeValue = useSubBlockStore(
|
||||
useCallback(
|
||||
(state) => {
|
||||
@@ -70,11 +67,17 @@ export function useSubBlockValue<T = any>(
|
||||
},
|
||||
[activeWorkflowId, blockId, subBlockId]
|
||||
),
|
||||
(a, b) => isEqual(a, b) // Use deep equality to prevent re-renders for same values
|
||||
(a, b) => isEqual(a, b)
|
||||
)
|
||||
|
||||
// Check if we're in diff mode and get diff value if available
|
||||
const { isShowingDiff, hasActiveDiff, baselineWorkflow } = useWorkflowDiffStore()
|
||||
const { isShowingDiff, hasActiveDiff, baselineWorkflow } = useWorkflowDiffStore(
|
||||
useShallow((state) => ({
|
||||
isShowingDiff: state.isShowingDiff,
|
||||
hasActiveDiff: state.hasActiveDiff,
|
||||
baselineWorkflow: state.baselineWorkflow,
|
||||
}))
|
||||
)
|
||||
const isBaselineView = hasActiveDiff && !isShowingDiff
|
||||
const snapshotSubBlock =
|
||||
isBaselineView && baselineWorkflow
|
||||
@@ -101,7 +104,7 @@ export function useSubBlockValue<T = any>(
|
||||
// Compute the modelValue based on block type
|
||||
const modelValue = isProviderBasedBlock ? (modelSubBlockValue as string) : null
|
||||
|
||||
// Emit the value to socket/DB
|
||||
// Emit the value to socket/DB and update local store
|
||||
const emitValue = useCallback(
|
||||
(value: T) => {
|
||||
collaborativeSetSubblockValue(blockId, subBlockId, value)
|
||||
@@ -155,20 +158,6 @@ export function useSubBlockValue<T = any>(
|
||||
return
|
||||
}
|
||||
|
||||
// Update local store immediately for UI responsiveness (non-streaming)
|
||||
useSubBlockStore.setState((state) => ({
|
||||
workflowValues: {
|
||||
...state.workflowValues,
|
||||
[currentActiveWorkflowId]: {
|
||||
...state.workflowValues[currentActiveWorkflowId],
|
||||
[blockId]: {
|
||||
...state.workflowValues[currentActiveWorkflowId]?.[blockId],
|
||||
[subBlockId]: newValue,
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
// Handle model changes for provider-based blocks - clear API key when provider changes (non-streaming)
|
||||
if (
|
||||
subBlockId === 'model' &&
|
||||
@@ -206,6 +195,8 @@ export function useSubBlockValue<T = any>(
|
||||
isStreaming,
|
||||
emitValue,
|
||||
isBaselineView,
|
||||
collaborativeSetSubblockValue,
|
||||
isProviderBasedBlock,
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { type JSX, type MouseEvent, memo, useRef, useState } from 'react'
|
||||
import { isEqual } from 'lodash'
|
||||
import { AlertTriangle, ArrowLeftRight, ArrowUp } from 'lucide-react'
|
||||
import { Button, Input, Label, Tooltip } from '@/components/emcn/components'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
@@ -168,6 +169,8 @@ const getPreviewValue = (
|
||||
* @param isValidJson - Whether the JSON content is valid (for code blocks)
|
||||
* @param subBlockValues - Current values of all subblocks for evaluating conditional requirements
|
||||
* @param wandState - Optional state and handlers for the AI wand feature
|
||||
* @param canonicalToggle - Optional canonical toggle metadata and handlers
|
||||
* @param canonicalToggleIsDisabled - Whether the canonical toggle is disabled
|
||||
* @returns The label JSX element, or `null` for switch types or when no title is defined
|
||||
*/
|
||||
const renderLabel = (
|
||||
@@ -192,7 +195,8 @@ const renderLabel = (
|
||||
mode: 'basic' | 'advanced'
|
||||
disabled?: boolean
|
||||
onToggle?: () => void
|
||||
}
|
||||
},
|
||||
canonicalToggleIsDisabled?: boolean
|
||||
): JSX.Element | null => {
|
||||
if (config.type === 'switch') return null
|
||||
if (!config.title) return null
|
||||
@@ -200,28 +204,28 @@ const renderLabel = (
|
||||
const required = isFieldRequired(config, subBlockValues)
|
||||
const showWand = wandState?.isWandEnabled && !wandState.isPreview && !wandState.disabled
|
||||
const showCanonicalToggle = !!canonicalToggle && !wandState?.isPreview
|
||||
const canonicalToggleDisabled = wandState?.disabled || canonicalToggle?.disabled
|
||||
const canonicalToggleDisabledResolved = canonicalToggleIsDisabled ?? canonicalToggle?.disabled
|
||||
|
||||
return (
|
||||
<div className='flex items-center justify-between gap-[6px] pl-[2px]'>
|
||||
<Label className='flex items-center gap-[6px] whitespace-nowrap'>
|
||||
{config.title}
|
||||
{required && <span className='ml-0.5'>*</span>}
|
||||
{config.type === 'code' && config.language === 'json' && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<AlertTriangle
|
||||
className={cn(
|
||||
'h-4 w-4 cursor-pointer text-destructive',
|
||||
!isValidJson ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>
|
||||
<p>Invalid JSON</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
{config.type === 'code' &&
|
||||
config.language === 'json' &&
|
||||
!isValidJson &&
|
||||
!wandState?.isStreaming && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<span className='inline-flex'>
|
||||
<AlertTriangle className='h-3 w-3 flex-shrink-0 cursor-pointer text-destructive' />
|
||||
</span>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>
|
||||
<p>Invalid JSON</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
</Label>
|
||||
<div className='flex items-center gap-[6px]'>
|
||||
{showWand && (
|
||||
@@ -239,9 +243,11 @@ const renderLabel = (
|
||||
<Input
|
||||
ref={wandState.searchInputRef}
|
||||
value={wandState.isStreaming ? 'Generating...' : wandState.searchQuery}
|
||||
onChange={(e) => wandState.onSearchChange(e.target.value)}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
wandState.onSearchChange(e.target.value)
|
||||
}
|
||||
onBlur={wandState.onSearchBlur}
|
||||
onKeyDown={(e) => {
|
||||
onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (
|
||||
e.key === 'Enter' &&
|
||||
wandState.searchQuery.trim() &&
|
||||
@@ -262,11 +268,11 @@ const renderLabel = (
|
||||
<Button
|
||||
variant='tertiary'
|
||||
disabled={!wandState.searchQuery.trim() || wandState.isStreaming}
|
||||
onMouseDown={(e) => {
|
||||
onMouseDown={(e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}}
|
||||
onClick={(e) => {
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
wandState.onSearchSubmit()
|
||||
}}
|
||||
@@ -283,7 +289,7 @@ const renderLabel = (
|
||||
type='button'
|
||||
className='flex h-[12px] w-[12px] flex-shrink-0 items-center justify-center bg-transparent p-0 disabled:cursor-not-allowed disabled:opacity-50'
|
||||
onClick={canonicalToggle?.onToggle}
|
||||
disabled={canonicalToggleDisabled}
|
||||
disabled={canonicalToggleDisabledResolved}
|
||||
aria-label={canonicalToggle?.mode === 'advanced' ? 'Use selector' : 'Enter manual ID'}
|
||||
>
|
||||
<ArrowLeftRight
|
||||
@@ -302,22 +308,27 @@ const renderLabel = (
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares props to prevent unnecessary re-renders.
|
||||
*
|
||||
* @remarks
|
||||
* Used with React.memo to optimize performance by skipping re-renders
|
||||
* when props haven't meaningfully changed.
|
||||
* Compares props for memo equality check.
|
||||
*
|
||||
* @param prevProps - Previous component props
|
||||
* @param nextProps - Next component props
|
||||
* @returns `true` if props are equal and re-render should be skipped
|
||||
*/
|
||||
const arePropsEqual = (prevProps: SubBlockProps, nextProps: SubBlockProps): boolean => {
|
||||
const subBlockId = prevProps.config.id
|
||||
const prevValue = prevProps.subBlockValues?.[subBlockId]?.value
|
||||
const nextValue = nextProps.subBlockValues?.[subBlockId]?.value
|
||||
|
||||
const valueEqual = prevValue === nextValue || isEqual(prevValue, nextValue)
|
||||
|
||||
const configEqual =
|
||||
prevProps.config.id === nextProps.config.id && prevProps.config.type === nextProps.config.type
|
||||
|
||||
return (
|
||||
prevProps.blockId === nextProps.blockId &&
|
||||
prevProps.config === nextProps.config &&
|
||||
configEqual &&
|
||||
prevProps.isPreview === nextProps.isPreview &&
|
||||
prevProps.subBlockValues === nextProps.subBlockValues &&
|
||||
valueEqual &&
|
||||
prevProps.disabled === nextProps.disabled &&
|
||||
prevProps.fieldDiffStatus === nextProps.fieldDiffStatus &&
|
||||
prevProps.allowExpandInPreview === nextProps.allowExpandInPreview &&
|
||||
@@ -941,7 +952,8 @@ function SubBlockComponent({
|
||||
onSearchCancel: handleSearchCancel,
|
||||
searchInputRef,
|
||||
},
|
||||
canonicalToggle
|
||||
canonicalToggle,
|
||||
Boolean(canonicalToggle?.disabled || disabled || isPreview)
|
||||
)}
|
||||
{renderInput()}
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { isEqual } from 'lodash'
|
||||
import { BookOpen, Check, ChevronDown, ChevronUp, Pencil } from 'lucide-react'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { Button, Tooltip } from '@/components/emcn'
|
||||
import {
|
||||
buildCanonicalIndex,
|
||||
evaluateSubBlockCondition,
|
||||
hasAdvancedValues,
|
||||
hasStandaloneAdvancedFields,
|
||||
isCanonicalPair,
|
||||
resolveCanonicalMode,
|
||||
} from '@/lib/workflows/subblocks/visibility'
|
||||
@@ -33,6 +35,9 @@ import { usePanelEditorStore } from '@/stores/panel'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
|
||||
/** Stable empty object to avoid creating new references */
|
||||
const EMPTY_SUBBLOCK_VALUES = {} as Record<string, any>
|
||||
|
||||
/**
|
||||
* Icon component for rendering block icons.
|
||||
*
|
||||
@@ -58,7 +63,15 @@ export function Editor() {
|
||||
toggleConnectionsCollapsed,
|
||||
shouldFocusRename,
|
||||
setShouldFocusRename,
|
||||
} = usePanelEditorStore()
|
||||
} = usePanelEditorStore(
|
||||
useShallow((state) => ({
|
||||
currentBlockId: state.currentBlockId,
|
||||
connectionsHeight: state.connectionsHeight,
|
||||
toggleConnectionsCollapsed: state.toggleConnectionsCollapsed,
|
||||
shouldFocusRename: state.shouldFocusRename,
|
||||
setShouldFocusRename: state.setShouldFocusRename,
|
||||
}))
|
||||
)
|
||||
const currentWorkflow = useCurrentWorkflow()
|
||||
const currentBlock = currentBlockId ? currentWorkflow.getBlockById(currentBlockId) : null
|
||||
const blockConfig = currentBlock ? getBlock(currentBlock.type) : null
|
||||
@@ -86,15 +99,15 @@ export function Editor() {
|
||||
currentWorkflow.isSnapshotView
|
||||
)
|
||||
|
||||
// Subscribe to block's subblock values
|
||||
const blockSubBlockValues = useSubBlockStore(
|
||||
useCallback(
|
||||
(state) => {
|
||||
if (!activeWorkflowId || !currentBlockId) return {}
|
||||
return state.workflowValues[activeWorkflowId]?.[currentBlockId] || {}
|
||||
if (!activeWorkflowId || !currentBlockId) return EMPTY_SUBBLOCK_VALUES
|
||||
return state.workflowValues[activeWorkflowId]?.[currentBlockId] ?? EMPTY_SUBBLOCK_VALUES
|
||||
},
|
||||
[activeWorkflowId, currentBlockId]
|
||||
)
|
||||
),
|
||||
isEqual
|
||||
)
|
||||
|
||||
const subBlocksForCanonical = useMemo(() => {
|
||||
@@ -118,10 +131,24 @@ export function Editor() {
|
||||
)
|
||||
const displayAdvancedOptions = advancedMode || advancedValuesPresent
|
||||
|
||||
const hasAdvancedOnlyFields = useMemo(
|
||||
() => hasStandaloneAdvancedFields(subBlocksForCanonical, canonicalIndex),
|
||||
[subBlocksForCanonical, canonicalIndex]
|
||||
)
|
||||
const hasAdvancedOnlyFields = useMemo(() => {
|
||||
for (const subBlock of subBlocksForCanonical) {
|
||||
// Must be standalone advanced (mode: 'advanced' without canonicalParamId)
|
||||
if (subBlock.mode !== 'advanced') continue
|
||||
if (canonicalIndex.canonicalIdBySubBlockId[subBlock.id]) continue
|
||||
|
||||
// Check condition - skip if condition not met for current values
|
||||
if (
|
||||
subBlock.condition &&
|
||||
!evaluateSubBlockCondition(subBlock.condition, blockSubBlockValues)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}, [subBlocksForCanonical, canonicalIndex.canonicalIdBySubBlockId, blockSubBlockValues])
|
||||
|
||||
// Get subblock layout using custom hook
|
||||
const { subBlocks, stateToUse: subBlockState } = useEditorSubblockLayout(
|
||||
@@ -467,7 +494,9 @@ export function Editor() {
|
||||
onClick={handleToggleAdvancedMode}
|
||||
className='flex items-center gap-[6px] whitespace-nowrap font-medium text-[13px] text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
|
||||
>
|
||||
{displayAdvancedOptions ? 'Hide advanced fields' : 'Show advanced fields'}
|
||||
{displayAdvancedOptions
|
||||
? 'Hide additional fields'
|
||||
: 'Show additional fields'}
|
||||
<ChevronDown
|
||||
className={`h-[14px] w-[14px] transition-transform duration-200 ${displayAdvancedOptions ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
|
||||
@@ -43,13 +43,12 @@ export function useBlockConnections(blockId: string) {
|
||||
)
|
||||
|
||||
const workflowId = useWorkflowRegistry((state) => state.activeWorkflowId)
|
||||
const workflowSubBlockValues = useSubBlockStore((state) =>
|
||||
workflowId ? (state.workflowValues[workflowId] ?? {}) : {}
|
||||
)
|
||||
|
||||
// Helper function to merge block subBlocks with live values from subblock store
|
||||
const getMergedSubBlocks = (sourceBlockId: string): Record<string, any> => {
|
||||
const base = blocks[sourceBlockId]?.subBlocks || {}
|
||||
const workflowSubBlockValues = workflowId
|
||||
? (useSubBlockStore.getState().workflowValues[workflowId] ?? {})
|
||||
: {}
|
||||
const live = workflowSubBlockValues?.[sourceBlockId] || {}
|
||||
const merged: Record<string, any> = { ...base }
|
||||
for (const [subId, liveVal] of Object.entries(live)) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { usePanelEditorStore } from '@/stores/panel'
|
||||
|
||||
/**
|
||||
@@ -27,7 +28,12 @@ interface UseConnectionsResizeProps {
|
||||
* @returns Object containing resize handler
|
||||
*/
|
||||
export function useConnectionsResize({ subBlocksRef }: UseConnectionsResizeProps) {
|
||||
const { connectionsHeight, setConnectionsHeight } = usePanelEditorStore()
|
||||
const { connectionsHeight, setConnectionsHeight } = usePanelEditorStore(
|
||||
useShallow((state) => ({
|
||||
connectionsHeight: state.connectionsHeight,
|
||||
setConnectionsHeight: state.setConnectionsHeight,
|
||||
}))
|
||||
)
|
||||
|
||||
const [isResizing, setIsResizing] = useState(false)
|
||||
const startYRef = useRef<number>(0)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { useCallback } from 'react'
|
||||
import { shallow } from 'zustand/shallow'
|
||||
import { useWorkflowDiffStore } from '@/stores/workflow-diff'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
|
||||
@@ -11,27 +12,36 @@ import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
* @returns Block display properties (advanced mode, trigger mode)
|
||||
*/
|
||||
export function useEditorBlockProperties(blockId: string | null, isSnapshotView: boolean) {
|
||||
const normalBlocks = useWorkflowStore(useCallback((state) => state.blocks, []))
|
||||
const baselineBlocks = useWorkflowDiffStore(
|
||||
useCallback((state) => state.baselineWorkflow?.blocks || {}, [])
|
||||
const normalBlockProps = useWorkflowStore(
|
||||
useCallback(
|
||||
(state) => {
|
||||
if (!blockId) return { advancedMode: false, triggerMode: false }
|
||||
const block = state.blocks?.[blockId]
|
||||
return {
|
||||
advancedMode: block?.advancedMode ?? false,
|
||||
triggerMode: block?.triggerMode ?? false,
|
||||
}
|
||||
},
|
||||
[blockId]
|
||||
),
|
||||
shallow
|
||||
)
|
||||
|
||||
const blockProperties = useMemo(() => {
|
||||
if (!blockId) {
|
||||
return {
|
||||
advancedMode: false,
|
||||
triggerMode: false,
|
||||
}
|
||||
}
|
||||
const baselineBlockProps = useWorkflowDiffStore(
|
||||
useCallback(
|
||||
(state) => {
|
||||
if (!blockId) return { advancedMode: false, triggerMode: false }
|
||||
const block = state.baselineWorkflow?.blocks?.[blockId]
|
||||
return {
|
||||
advancedMode: block?.advancedMode ?? false,
|
||||
triggerMode: block?.triggerMode ?? false,
|
||||
}
|
||||
},
|
||||
[blockId]
|
||||
),
|
||||
shallow
|
||||
)
|
||||
|
||||
const blocks = isSnapshotView ? baselineBlocks : normalBlocks
|
||||
const block = blocks?.[blockId]
|
||||
|
||||
return {
|
||||
advancedMode: block?.advancedMode ?? false,
|
||||
triggerMode: block?.triggerMode ?? false,
|
||||
}
|
||||
}, [blockId, isSnapshotView, normalBlocks, baselineBlocks])
|
||||
|
||||
return blockProperties
|
||||
// Use the appropriate props based on view mode
|
||||
return isSnapshotView ? baselineBlockProps : normalBlockProps
|
||||
}
|
||||
|
||||
@@ -53,22 +53,27 @@ const SUBFLOW_CONFIG = {
|
||||
* @returns Subflow editor state and handlers
|
||||
*/
|
||||
export function useSubflowEditor(currentBlock: BlockState | null, currentBlockId: string | null) {
|
||||
const workflowStore = useWorkflowStore()
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null)
|
||||
const editorContainerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// State
|
||||
const [tempInputValue, setTempInputValue] = useState<string | null>(null)
|
||||
const [showTagDropdown, setShowTagDropdown] = useState(false)
|
||||
const [cursorPosition, setCursorPosition] = useState(0)
|
||||
|
||||
// Check if current block is a subflow
|
||||
const isSubflow =
|
||||
currentBlock && (currentBlock.type === 'loop' || currentBlock.type === 'parallel')
|
||||
|
||||
// Get subflow configuration
|
||||
const subflowConfig = isSubflow ? SUBFLOW_CONFIG[currentBlock.type as 'loop' | 'parallel'] : null
|
||||
const nodeConfig = isSubflow ? workflowStore[subflowConfig!.storeKey][currentBlockId!] : null
|
||||
|
||||
const nodeConfig = useWorkflowStore(
|
||||
useCallback(
|
||||
(state) => {
|
||||
if (!isSubflow || !subflowConfig || !currentBlockId) return null
|
||||
return state[subflowConfig.storeKey][currentBlockId] ?? null
|
||||
},
|
||||
[isSubflow, subflowConfig, currentBlockId]
|
||||
)
|
||||
)
|
||||
|
||||
// Get block data for fallback values
|
||||
const blockData = isSubflow ? currentBlock?.data : null
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,10 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { memo, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { ArrowUp, Square } from 'lucide-react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import {
|
||||
BubbleChatClose,
|
||||
BubbleChatPreview,
|
||||
@@ -49,7 +50,6 @@ import { usePanelStore, useVariablesStore as usePanelVariablesStore } from '@/st
|
||||
import { useVariablesStore } from '@/stores/variables/store'
|
||||
import { getWorkflowWithValues } from '@/stores/workflows'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
|
||||
const logger = createLogger('Panel')
|
||||
/**
|
||||
@@ -69,14 +69,22 @@ const logger = createLogger('Panel')
|
||||
*
|
||||
* @returns Panel on the right side of the workflow
|
||||
*/
|
||||
export function Panel() {
|
||||
export const Panel = memo(function Panel() {
|
||||
const router = useRouter()
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
|
||||
const panelRef = useRef<HTMLElement>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const { activeTab, setActiveTab, panelWidth, _hasHydrated, setHasHydrated } = usePanelStore()
|
||||
const { activeTab, setActiveTab, panelWidth, _hasHydrated, setHasHydrated } = usePanelStore(
|
||||
useShallow((state) => ({
|
||||
activeTab: state.activeTab,
|
||||
setActiveTab: state.setActiveTab,
|
||||
panelWidth: state.panelWidth,
|
||||
_hasHydrated: state._hasHydrated,
|
||||
setHasHydrated: state.setHasHydrated,
|
||||
}))
|
||||
)
|
||||
const copilotRef = useRef<{
|
||||
createNewChat: () => void
|
||||
setInputValueAndFocus: (value: string) => void
|
||||
@@ -97,12 +105,18 @@ export function Panel() {
|
||||
const userPermissions = useUserPermissionsContext()
|
||||
const { config: permissionConfig } = usePermissionConfig()
|
||||
const { isImporting, handleFileChange } = useImportWorkflow({ workspaceId })
|
||||
const { workflows, activeWorkflowId, duplicateWorkflow, hydration } = useWorkflowRegistry()
|
||||
const { workflows, activeWorkflowId, duplicateWorkflow, hydration } = useWorkflowRegistry(
|
||||
useShallow((state) => ({
|
||||
workflows: state.workflows,
|
||||
activeWorkflowId: state.activeWorkflowId,
|
||||
duplicateWorkflow: state.duplicateWorkflow,
|
||||
hydration: state.hydration,
|
||||
}))
|
||||
)
|
||||
const isRegistryLoading =
|
||||
hydration.phase === 'idle' ||
|
||||
hydration.phase === 'metadata-loading' ||
|
||||
hydration.phase === 'state-loading'
|
||||
const { blocks } = useWorkflowStore()
|
||||
const { handleAutoLayout: autoLayoutWithFitView } = useAutoLayout(activeWorkflowId || null)
|
||||
|
||||
// Delete workflow hook
|
||||
@@ -157,8 +171,18 @@ export function Panel() {
|
||||
}, [usageExceeded, handleRunWorkflow])
|
||||
|
||||
// Chat state
|
||||
const { isChatOpen, setIsChatOpen } = useChatStore()
|
||||
const { isOpen: isVariablesOpen, setIsOpen: setVariablesOpen } = useVariablesStore()
|
||||
const { isChatOpen, setIsChatOpen } = useChatStore(
|
||||
useShallow((state) => ({
|
||||
isChatOpen: state.isChatOpen,
|
||||
setIsChatOpen: state.setIsChatOpen,
|
||||
}))
|
||||
)
|
||||
const { isOpen: isVariablesOpen, setIsOpen: setVariablesOpen } = useVariablesStore(
|
||||
useShallow((state) => ({
|
||||
isOpen: state.isOpen,
|
||||
setIsOpen: state.setIsOpen,
|
||||
}))
|
||||
)
|
||||
|
||||
const currentWorkflow = activeWorkflowId ? workflows[activeWorkflowId] : null
|
||||
|
||||
@@ -583,4 +607,4 @@ export function Panel() {
|
||||
<Variables />
|
||||
</>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -34,6 +34,7 @@ interface LogRowContextMenuProps {
|
||||
onCopyRunId: (runId: string) => void
|
||||
onClearFilters: () => void
|
||||
onClearConsole: () => void
|
||||
onFixInCopilot: (entry: ConsoleEntry) => void
|
||||
hasActiveFilters: boolean
|
||||
}
|
||||
|
||||
@@ -54,6 +55,7 @@ export function LogRowContextMenu({
|
||||
onCopyRunId,
|
||||
onClearFilters,
|
||||
onClearConsole,
|
||||
onFixInCopilot,
|
||||
hasActiveFilters,
|
||||
}: LogRowContextMenuProps) {
|
||||
const hasRunId = entry?.executionId != null
|
||||
@@ -96,6 +98,21 @@ export function LogRowContextMenu({
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Fix in Copilot - only for error rows */}
|
||||
{entry && !entry.success && (
|
||||
<>
|
||||
<PopoverItem
|
||||
onClick={() => {
|
||||
onFixInCopilot(entry)
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
Fix in Copilot
|
||||
</PopoverItem>
|
||||
<PopoverDivider />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Filter actions */}
|
||||
{entry && (
|
||||
<>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,8 +14,12 @@ export function getProviderName(providerId: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares two WorkflowBlock props to determine if a re-render should be skipped
|
||||
* Used as the comparison function for React.memo
|
||||
* Compares two WorkflowBlock props to determine if a re-render should be skipped.
|
||||
* Used as the comparison function for React.memo.
|
||||
*
|
||||
* Note: xPos and yPos are intentionally excluded since WorkflowBlock doesn't use
|
||||
* position props - ReactFlow handles positioning via CSS transforms. Including them
|
||||
* would cause unnecessary re-renders during drag (100+ times per drag operation).
|
||||
*
|
||||
* @param prevProps - Previous node props
|
||||
* @param nextProps - Next node props
|
||||
@@ -37,9 +41,7 @@ export function shouldSkipBlockRender(
|
||||
prevProps.data.subBlockValues === nextProps.data.subBlockValues &&
|
||||
prevProps.data.blockState === nextProps.data.blockState &&
|
||||
prevProps.selected === nextProps.selected &&
|
||||
prevProps.dragging === nextProps.dragging &&
|
||||
prevProps.xPos === nextProps.xPos &&
|
||||
prevProps.yPos === nextProps.yPos
|
||||
prevProps.dragging === nextProps.dragging
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { memo, useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { isEqual } from 'lodash'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { Handle, type NodeProps, Position, useUpdateNodeInternals } from 'reactflow'
|
||||
import { Badge, Tooltip } from '@/components/emcn'
|
||||
@@ -7,6 +8,7 @@ import { cn } from '@/lib/core/utils/cn'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { createMcpToolId } from '@/lib/mcp/utils'
|
||||
import { getProviderIdFromServiceId } from '@/lib/oauth'
|
||||
import { BLOCK_DIMENSIONS, HANDLE_POSITIONS } from '@/lib/workflows/blocks/block-dimensions'
|
||||
import {
|
||||
buildCanonicalIndex,
|
||||
evaluateSubBlockCondition,
|
||||
@@ -28,11 +30,7 @@ import {
|
||||
shouldSkipBlockRender,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/utils'
|
||||
import { useBlockVisual } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
|
||||
import {
|
||||
BLOCK_DIMENSIONS,
|
||||
HANDLE_POSITIONS,
|
||||
useBlockDimensions,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-dimensions'
|
||||
import { useBlockDimensions } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-dimensions'
|
||||
import { SELECTOR_TYPES_HYDRATION_REQUIRED, type SubBlockConfig } from '@/blocks/types'
|
||||
import { getDependsOnFields } from '@/blocks/utils'
|
||||
import { useKnowledgeBase } from '@/hooks/kb/use-knowledge'
|
||||
@@ -49,6 +47,9 @@ import { wouldCreateCycle } from '@/stores/workflows/workflow/utils'
|
||||
|
||||
const logger = createLogger('WorkflowBlock')
|
||||
|
||||
/** Stable empty object to avoid creating new references */
|
||||
const EMPTY_SUBBLOCK_VALUES = {} as Record<string, any>
|
||||
|
||||
/**
|
||||
* Type guard for table row structure
|
||||
*/
|
||||
@@ -323,23 +324,7 @@ export const getDisplayValue = (value: unknown): string => {
|
||||
return stringValue.trim().length > 0 ? stringValue : '-'
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single subblock row with title and optional value.
|
||||
* Automatically hydrates IDs to display names for all selector types.
|
||||
*/
|
||||
const SubBlockRow = ({
|
||||
title,
|
||||
value,
|
||||
subBlock,
|
||||
rawValue,
|
||||
workspaceId,
|
||||
workflowId,
|
||||
blockId,
|
||||
allSubBlockValues,
|
||||
displayAdvancedOptions,
|
||||
canonicalIndex,
|
||||
canonicalModeOverrides,
|
||||
}: {
|
||||
interface SubBlockRowProps {
|
||||
title: string
|
||||
value?: string
|
||||
subBlock?: SubBlockConfig
|
||||
@@ -351,7 +336,53 @@ const SubBlockRow = ({
|
||||
displayAdvancedOptions?: boolean
|
||||
canonicalIndex?: ReturnType<typeof buildCanonicalIndex>
|
||||
canonicalModeOverrides?: Record<string, 'basic' | 'advanced'>
|
||||
}) => {
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares SubBlockRow props for memo equality check.
|
||||
*/
|
||||
const areSubBlockRowPropsEqual = (
|
||||
prevProps: SubBlockRowProps,
|
||||
nextProps: SubBlockRowProps
|
||||
): boolean => {
|
||||
const subBlockId = prevProps.subBlock?.id
|
||||
const prevValue = subBlockId ? prevProps.allSubBlockValues?.[subBlockId]?.value : undefined
|
||||
const nextValue = subBlockId ? nextProps.allSubBlockValues?.[subBlockId]?.value : undefined
|
||||
const valueEqual = prevValue === nextValue || isEqual(prevValue, nextValue)
|
||||
|
||||
return (
|
||||
prevProps.title === nextProps.title &&
|
||||
prevProps.value === nextProps.value &&
|
||||
prevProps.subBlock === nextProps.subBlock &&
|
||||
prevProps.rawValue === nextProps.rawValue &&
|
||||
prevProps.workspaceId === nextProps.workspaceId &&
|
||||
prevProps.workflowId === nextProps.workflowId &&
|
||||
prevProps.blockId === nextProps.blockId &&
|
||||
valueEqual &&
|
||||
prevProps.displayAdvancedOptions === nextProps.displayAdvancedOptions &&
|
||||
prevProps.canonicalIndex === nextProps.canonicalIndex &&
|
||||
prevProps.canonicalModeOverrides === nextProps.canonicalModeOverrides
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single subblock row with title and optional value.
|
||||
* Automatically hydrates IDs to display names for all selector types.
|
||||
* Memoized to prevent excessive re-renders when parent components update.
|
||||
*/
|
||||
const SubBlockRow = memo(function SubBlockRow({
|
||||
title,
|
||||
value,
|
||||
subBlock,
|
||||
rawValue,
|
||||
workspaceId,
|
||||
workflowId,
|
||||
blockId,
|
||||
allSubBlockValues,
|
||||
displayAdvancedOptions,
|
||||
canonicalIndex,
|
||||
canonicalModeOverrides,
|
||||
}: SubBlockRowProps) {
|
||||
const getStringValue = useCallback(
|
||||
(key?: string): string | undefined => {
|
||||
if (!key || !allSubBlockValues) return undefined
|
||||
@@ -489,21 +520,34 @@ const SubBlockRow = ({
|
||||
: `${baseUrl}/api/webhooks/trigger/${blockId}`
|
||||
}, [subBlock?.id, blockId, allSubBlockValues])
|
||||
|
||||
const allVariables = useVariablesStore((state) => state.variables)
|
||||
/**
|
||||
* Subscribe only to variables for this workflow to avoid re-renders from other workflows.
|
||||
* Uses isEqual for deep comparison since Object.fromEntries creates a new object each time.
|
||||
*/
|
||||
const workflowVariables = useVariablesStore(
|
||||
useCallback(
|
||||
(state) => {
|
||||
if (!workflowId) return {}
|
||||
return Object.fromEntries(
|
||||
Object.entries(state.variables).filter(([, v]) => v.workflowId === workflowId)
|
||||
)
|
||||
},
|
||||
[workflowId]
|
||||
),
|
||||
isEqual
|
||||
)
|
||||
|
||||
const variablesDisplayValue = useMemo(() => {
|
||||
if (subBlock?.type !== 'variables-input' || !isVariableAssignmentsArray(rawValue)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const workflowVariables = Object.values(allVariables).filter(
|
||||
(v: any) => v.workflowId === workflowId
|
||||
)
|
||||
const variablesArray = Object.values(workflowVariables)
|
||||
|
||||
const names = rawValue
|
||||
.map((a) => {
|
||||
if (a.variableId) {
|
||||
const variable = workflowVariables.find((v: any) => v.id === a.variableId)
|
||||
const variable = variablesArray.find((v: any) => v.id === a.variableId)
|
||||
return variable?.name
|
||||
}
|
||||
if (a.variableName) return a.variableName
|
||||
@@ -515,7 +559,7 @@ const SubBlockRow = ({
|
||||
if (names.length === 1) return names[0]
|
||||
if (names.length === 2) return `${names[0]}, ${names[1]}`
|
||||
return `${names[0]}, ${names[1]} +${names.length - 2}`
|
||||
}, [subBlock?.type, rawValue, workflowId, allVariables])
|
||||
}, [subBlock?.type, rawValue, workflowVariables])
|
||||
|
||||
const isPasswordField = subBlock?.password === true
|
||||
const maskedValue = isPasswordField && value && value !== '-' ? '•••' : null
|
||||
@@ -551,7 +595,7 @@ const SubBlockRow = ({
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}, areSubBlockRowPropsEqual)
|
||||
|
||||
export const WorkflowBlock = memo(function WorkflowBlock({
|
||||
id,
|
||||
@@ -629,18 +673,15 @@ export const WorkflowBlock = memo(function WorkflowBlock({
|
||||
const isStarterBlock = type === 'starter'
|
||||
const isWebhookTriggerBlock = type === 'webhook' || type === 'generic_webhook'
|
||||
|
||||
/**
|
||||
* Subscribe to this block's subblock values to track changes for conditional rendering
|
||||
* of subblocks based on their conditions.
|
||||
*/
|
||||
const blockSubBlockValues = useSubBlockStore(
|
||||
useCallback(
|
||||
(state) => {
|
||||
if (!activeWorkflowId) return {}
|
||||
return state.workflowValues[activeWorkflowId]?.[id] || {}
|
||||
if (!activeWorkflowId) return EMPTY_SUBBLOCK_VALUES
|
||||
return state.workflowValues[activeWorkflowId]?.[id] ?? EMPTY_SUBBLOCK_VALUES
|
||||
},
|
||||
[activeWorkflowId, id]
|
||||
)
|
||||
),
|
||||
isEqual
|
||||
)
|
||||
const canonicalIndex = useMemo(() => buildCanonicalIndex(config.subBlocks), [config.subBlocks])
|
||||
const canonicalModeOverrides = currentStoreBlock?.data?.canonicalModes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { memo, useCallback, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import clsx from 'clsx'
|
||||
import { Scan } from 'lucide-react'
|
||||
@@ -22,27 +22,29 @@ import {
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
|
||||
import { createCommand } from '@/app/workspace/[workspaceId]/utils/commands-utils'
|
||||
import { useUpdateGeneralSetting } from '@/hooks/queries/general-settings'
|
||||
import { useShowActionBar, useUpdateGeneralSetting } from '@/hooks/queries/general-settings'
|
||||
import { useCanvasViewport } from '@/hooks/use-canvas-viewport'
|
||||
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
||||
import { useCanvasModeStore } from '@/stores/canvas-mode'
|
||||
import { useGeneralStore } from '@/stores/settings/general'
|
||||
import { useTerminalStore } from '@/stores/terminal'
|
||||
import { useUndoRedoStore } from '@/stores/undo-redo'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
|
||||
const logger = createLogger('WorkflowControls')
|
||||
|
||||
export function WorkflowControls() {
|
||||
/**
|
||||
* Floating controls for canvas mode, undo/redo, and fit-to-view.
|
||||
*/
|
||||
export const WorkflowControls = memo(function WorkflowControls() {
|
||||
const reactFlowInstance = useReactFlow()
|
||||
const { fitViewToBounds } = useCanvasViewport(reactFlowInstance)
|
||||
const { mode, setMode } = useCanvasModeStore()
|
||||
const { undo, redo } = useCollaborativeWorkflow()
|
||||
const showWorkflowControls = useGeneralStore((s) => s.showActionBar)
|
||||
const showWorkflowControls = useShowActionBar()
|
||||
const updateSetting = useUpdateGeneralSetting()
|
||||
const isTerminalResizing = useTerminalStore((state) => state.isResizing)
|
||||
|
||||
const { activeWorkflowId } = useWorkflowRegistry()
|
||||
const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId)
|
||||
const { data: session } = useSession()
|
||||
const userId = session?.user?.id || 'unknown'
|
||||
const stacks = useUndoRedoStore((s) => s.stacks)
|
||||
@@ -222,4 +224,4 @@ export function WorkflowControls() {
|
||||
</Popover>
|
||||
</>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
export {
|
||||
clearDragHighlights,
|
||||
computeClampedPositionUpdates,
|
||||
computeParentUpdateEntries,
|
||||
getClampedPositionForNode,
|
||||
isInEditableElement,
|
||||
resolveParentChildSelectionConflicts,
|
||||
validateTriggerPaste,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers'
|
||||
export { useFloatBoundarySync, useFloatDrag, useFloatResize } from './float'
|
||||
export { useAccessibleReferencePrefixes } from './use-accessible-reference-prefixes'
|
||||
export { useAutoLayout } from './use-auto-layout'
|
||||
export { BLOCK_DIMENSIONS, useBlockDimensions } from './use-block-dimensions'
|
||||
export { useBlockDimensions } from './use-block-dimensions'
|
||||
export { useBlockOutputFields } from './use-block-output-fields'
|
||||
export { useBlockVisual } from './use-block-visual'
|
||||
export { useCanvasContextMenu } from './use-canvas-context-menu'
|
||||
export { type CurrentWorkflow, useCurrentWorkflow } from './use-current-workflow'
|
||||
export { calculateContainerDimensions, useNodeUtilities } from './use-node-utilities'
|
||||
export { useNodeUtilities } from './use-node-utilities'
|
||||
export { usePreventZoom } from './use-prevent-zoom'
|
||||
export { useScrollManagement } from './use-scroll-management'
|
||||
export { useShiftSelectionLock } from './use-shift-selection-lock'
|
||||
export { useWand, type WandConfig } from './use-wand'
|
||||
export { useWorkflowExecution } from './use-workflow-execution'
|
||||
|
||||
@@ -2,9 +2,6 @@ import { useEffect, useRef } from 'react'
|
||||
import { useUpdateNodeInternals } from 'reactflow'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
|
||||
// Re-export for backwards compatibility
|
||||
export { BLOCK_DIMENSIONS, HANDLE_POSITIONS } from '@/lib/workflows/blocks/block-dimensions'
|
||||
|
||||
interface BlockDimensions {
|
||||
width: number
|
||||
height: number
|
||||
|
||||
@@ -50,8 +50,18 @@ export function useBlockVisual({
|
||||
} = useBlockState(blockId, currentWorkflow, data)
|
||||
|
||||
const currentBlockId = usePanelEditorStore((state) => state.currentBlockId)
|
||||
const activeTab = usePanelStore((state) => state.activeTab)
|
||||
const isEditorOpen = !isPreview && currentBlockId === blockId && activeTab === 'editor'
|
||||
|
||||
const isThisBlockInEditor = currentBlockId === blockId
|
||||
const activeTabIsEditor = usePanelStore(
|
||||
useCallback(
|
||||
(state) => {
|
||||
if (isPreview || !isThisBlockInEditor) return false
|
||||
return state.activeTab === 'editor'
|
||||
},
|
||||
[isPreview, isThisBlockInEditor]
|
||||
)
|
||||
)
|
||||
const isEditorOpen = !isPreview && isThisBlockInEditor && activeTabIsEditor
|
||||
|
||||
const lastRunPath = useExecutionStore((state) => state.lastRunPath)
|
||||
const runPathStatus = isPreview ? undefined : lastRunPath.get(blockId)
|
||||
|
||||
@@ -8,13 +8,14 @@ type MenuType = 'block' | 'pane' | null
|
||||
interface UseCanvasContextMenuProps {
|
||||
blocks: Record<string, BlockState>
|
||||
getNodes: () => Node[]
|
||||
setNodes: (updater: (nodes: Node[]) => Node[]) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing workflow canvas context menus.
|
||||
* Handles right-click events, menu state, click-outside detection, and block info extraction.
|
||||
*/
|
||||
export function useCanvasContextMenu({ blocks, getNodes }: UseCanvasContextMenuProps) {
|
||||
export function useCanvasContextMenu({ blocks, getNodes, setNodes }: UseCanvasContextMenuProps) {
|
||||
const [activeMenu, setActiveMenu] = useState<MenuType>(null)
|
||||
const [position, setPosition] = useState({ x: 0, y: 0 })
|
||||
const [selectedBlocks, setSelectedBlocks] = useState<BlockInfo[]>([])
|
||||
@@ -44,14 +45,26 @@ export function useCanvasContextMenu({ blocks, getNodes }: UseCanvasContextMenuP
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
const isMultiSelect = event.shiftKey || event.metaKey || event.ctrlKey
|
||||
setNodes((nodes) =>
|
||||
nodes.map((n) => ({
|
||||
...n,
|
||||
selected: isMultiSelect ? (n.id === node.id ? true : n.selected) : n.id === node.id,
|
||||
}))
|
||||
)
|
||||
|
||||
const selectedNodes = getNodes().filter((n) => n.selected)
|
||||
const nodesToUse = selectedNodes.some((n) => n.id === node.id) ? selectedNodes : [node]
|
||||
const nodesToUse = isMultiSelect
|
||||
? selectedNodes.some((n) => n.id === node.id)
|
||||
? selectedNodes
|
||||
: [...selectedNodes, node]
|
||||
: [node]
|
||||
|
||||
setPosition({ x: event.clientX, y: event.clientY })
|
||||
setSelectedBlocks(nodesToBlockInfos(nodesToUse))
|
||||
setActiveMenu('block')
|
||||
},
|
||||
[getNodes, nodesToBlockInfos]
|
||||
[getNodes, nodesToBlockInfos, setNodes]
|
||||
)
|
||||
|
||||
const handlePaneContextMenu = useCallback((event: React.MouseEvent) => {
|
||||
|
||||
@@ -2,107 +2,15 @@ import { useCallback } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useReactFlow } from 'reactflow'
|
||||
import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions'
|
||||
import { getBlock } from '@/blocks/registry'
|
||||
import {
|
||||
calculateContainerDimensions,
|
||||
clampPositionToContainer,
|
||||
estimateBlockDimensions,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/node-position-utils'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
|
||||
const logger = createLogger('NodeUtilities')
|
||||
|
||||
/**
|
||||
* Estimates block dimensions based on block type.
|
||||
* Uses subblock count to estimate height for blocks that haven't been measured yet.
|
||||
*
|
||||
* @param blockType - The type of block (e.g., 'condition', 'agent')
|
||||
* @returns Estimated width and height for the block
|
||||
*/
|
||||
export function estimateBlockDimensions(blockType: string): { width: number; height: number } {
|
||||
const blockConfig = getBlock(blockType)
|
||||
const subBlockCount = blockConfig?.subBlocks?.length ?? 3
|
||||
// Many subblocks are conditionally rendered (advanced mode, provider-specific, etc.)
|
||||
// Use roughly half the config count as a reasonable estimate, capped between 3-7 rows
|
||||
const estimatedRows = Math.max(3, Math.min(Math.ceil(subBlockCount / 2), 7))
|
||||
const hasErrorRow = blockType !== 'starter' && blockType !== 'response' ? 1 : 0
|
||||
|
||||
const height =
|
||||
BLOCK_DIMENSIONS.HEADER_HEIGHT +
|
||||
BLOCK_DIMENSIONS.WORKFLOW_CONTENT_PADDING +
|
||||
(estimatedRows + hasErrorRow) * BLOCK_DIMENSIONS.WORKFLOW_ROW_HEIGHT
|
||||
|
||||
return {
|
||||
width: BLOCK_DIMENSIONS.FIXED_WIDTH,
|
||||
height: Math.max(height, BLOCK_DIMENSIONS.MIN_HEIGHT),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clamps a position to keep a block fully inside a container's content area.
|
||||
* Content area starts after the header and padding, and ends before the right/bottom padding.
|
||||
*
|
||||
* @param position - Raw position relative to container origin
|
||||
* @param containerDimensions - Container width and height
|
||||
* @param blockDimensions - Block width and height
|
||||
* @returns Clamped position that keeps block inside content area
|
||||
*/
|
||||
export function clampPositionToContainer(
|
||||
position: { x: number; y: number },
|
||||
containerDimensions: { width: number; height: number },
|
||||
blockDimensions: { width: number; height: number }
|
||||
): { x: number; y: number } {
|
||||
const { width: containerWidth, height: containerHeight } = containerDimensions
|
||||
const { width: blockWidth, height: blockHeight } = blockDimensions
|
||||
|
||||
// Content area bounds (where blocks can be placed)
|
||||
const minX = CONTAINER_DIMENSIONS.LEFT_PADDING
|
||||
const minY = CONTAINER_DIMENSIONS.HEADER_HEIGHT + CONTAINER_DIMENSIONS.TOP_PADDING
|
||||
const maxX = containerWidth - CONTAINER_DIMENSIONS.RIGHT_PADDING - blockWidth
|
||||
const maxY = containerHeight - CONTAINER_DIMENSIONS.BOTTOM_PADDING - blockHeight
|
||||
|
||||
return {
|
||||
x: Math.max(minX, Math.min(position.x, Math.max(minX, maxX))),
|
||||
y: Math.max(minY, Math.min(position.y, Math.max(minY, maxY))),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates container dimensions based on child block positions.
|
||||
* Single source of truth for container sizing - ensures consistency between
|
||||
* live drag updates and final dimension calculations.
|
||||
*
|
||||
* @param childPositions - Array of child positions with their dimensions
|
||||
* @returns Calculated width and height for the container
|
||||
*/
|
||||
export function calculateContainerDimensions(
|
||||
childPositions: Array<{ x: number; y: number; width: number; height: number }>
|
||||
): { width: number; height: number } {
|
||||
if (childPositions.length === 0) {
|
||||
return {
|
||||
width: CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
|
||||
height: CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
|
||||
}
|
||||
}
|
||||
|
||||
let maxRight = 0
|
||||
let maxBottom = 0
|
||||
|
||||
for (const child of childPositions) {
|
||||
maxRight = Math.max(maxRight, child.x + child.width)
|
||||
maxBottom = Math.max(maxBottom, child.y + child.height)
|
||||
}
|
||||
|
||||
const width = Math.max(
|
||||
CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
|
||||
CONTAINER_DIMENSIONS.LEFT_PADDING + maxRight + CONTAINER_DIMENSIONS.RIGHT_PADDING
|
||||
)
|
||||
const height = Math.max(
|
||||
CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
|
||||
CONTAINER_DIMENSIONS.HEADER_HEIGHT +
|
||||
CONTAINER_DIMENSIONS.TOP_PADDING +
|
||||
maxBottom +
|
||||
CONTAINER_DIMENSIONS.BOTTOM_PADDING
|
||||
)
|
||||
|
||||
return { width, height }
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook providing utilities for node position, hierarchy, and dimension calculations
|
||||
*/
|
||||
@@ -138,7 +46,6 @@ export function useNodeUtilities(blocks: Record<string, any>) {
|
||||
}
|
||||
}
|
||||
|
||||
// Prefer deterministic height published by the block component; fallback to estimate
|
||||
if (block.height) {
|
||||
return {
|
||||
width: BLOCK_DIMENSIONS.FIXED_WIDTH,
|
||||
@@ -146,7 +53,6 @@ export function useNodeUtilities(blocks: Record<string, any>) {
|
||||
}
|
||||
}
|
||||
|
||||
// Use shared estimation utility for blocks without measured height
|
||||
return estimateBlockDimensions(block.type)
|
||||
},
|
||||
[blocks, isContainerType]
|
||||
@@ -230,8 +136,6 @@ export function useNodeUtilities(blocks: Record<string, any>) {
|
||||
|
||||
const parentPos = getNodeAbsolutePosition(parentId)
|
||||
|
||||
// Child positions are stored relative to the content area (after header and padding)
|
||||
// Add these offsets when calculating absolute position
|
||||
const headerHeight = 50
|
||||
const leftPadding = 16
|
||||
const topPadding = 16
|
||||
@@ -314,7 +218,6 @@ export function useNodeUtilities(blocks: Record<string, any>) {
|
||||
})
|
||||
.map((n) => ({
|
||||
loopId: n.id,
|
||||
// Return absolute position so callers can compute relative placement correctly
|
||||
loopPosition: getNodeAbsolutePosition(n.id),
|
||||
dimensions: {
|
||||
width: n.data?.width || CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
|
||||
@@ -449,7 +352,6 @@ export function useNodeUtilities(blocks: Record<string, any>) {
|
||||
return absPos
|
||||
}
|
||||
|
||||
// Use known defaults per node type without type casting
|
||||
const isSubflow = node.type === 'subflowNode'
|
||||
const width = isSubflow
|
||||
? typeof node.data?.width === 'number'
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
interface UseShiftSelectionLockProps {
|
||||
isHandMode: boolean
|
||||
}
|
||||
|
||||
interface UseShiftSelectionLockResult {
|
||||
/** Whether a shift-selection is currently active (locked in until mouseup) */
|
||||
isShiftSelecting: boolean
|
||||
/** Handler to attach to canvas mousedown */
|
||||
handleCanvasMouseDown: (event: React.MouseEvent) => void
|
||||
/** Computed ReactFlow props based on current selection state */
|
||||
selectionProps: {
|
||||
selectionOnDrag: boolean
|
||||
panOnDrag: [number, number] | false
|
||||
selectionKeyCode: string | null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Locks shift-selection mode from mousedown to mouseup.
|
||||
* Prevents selection from canceling when shift is released mid-drag.
|
||||
*/
|
||||
export function useShiftSelectionLock({
|
||||
isHandMode,
|
||||
}: UseShiftSelectionLockProps): UseShiftSelectionLockResult {
|
||||
const [isShiftSelecting, setIsShiftSelecting] = useState(false)
|
||||
|
||||
const handleCanvasMouseDown = useCallback(
|
||||
(event: React.MouseEvent) => {
|
||||
if (!event.shiftKey) return
|
||||
|
||||
const target = event.target as HTMLElement | null
|
||||
const isPaneTarget = Boolean(target?.closest('.react-flow__pane, .react-flow__selectionpane'))
|
||||
|
||||
if (isPaneTarget && isHandMode) {
|
||||
setIsShiftSelecting(true)
|
||||
}
|
||||
|
||||
if (isPaneTarget) {
|
||||
event.preventDefault()
|
||||
window.getSelection()?.removeAllRanges()
|
||||
}
|
||||
},
|
||||
[isHandMode]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isShiftSelecting) return
|
||||
|
||||
const handleMouseUp = () => setIsShiftSelecting(false)
|
||||
window.addEventListener('mouseup', handleMouseUp)
|
||||
return () => window.removeEventListener('mouseup', handleMouseUp)
|
||||
}, [isShiftSelecting])
|
||||
|
||||
const selectionProps = {
|
||||
selectionOnDrag: !isHandMode || isShiftSelecting,
|
||||
panOnDrag: (isHandMode && !isShiftSelecting ? [0, 1] : false) as [number, number] | false,
|
||||
selectionKeyCode: isShiftSelecting ? null : 'Shift',
|
||||
}
|
||||
|
||||
return { isShiftSelecting, handleCanvasMouseDown, selectionProps }
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
export * from './auto-layout-utils'
|
||||
export * from './block-ring-utils'
|
||||
export * from './node-position-utils'
|
||||
export * from './workflow-canvas-helpers'
|
||||
export * from './workflow-execution-utils'
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions'
|
||||
import { getBlock } from '@/blocks/registry'
|
||||
|
||||
/**
|
||||
* Estimates block dimensions based on block type.
|
||||
* Uses subblock count to estimate height for blocks that haven't been measured yet.
|
||||
*
|
||||
* @param blockType - The type of block (e.g., 'condition', 'agent')
|
||||
* @returns Estimated width and height for the block
|
||||
*/
|
||||
export function estimateBlockDimensions(blockType: string): { width: number; height: number } {
|
||||
const blockConfig = getBlock(blockType)
|
||||
const subBlockCount = blockConfig?.subBlocks?.length ?? 3
|
||||
const estimatedRows = Math.max(3, Math.min(Math.ceil(subBlockCount / 2), 7))
|
||||
const hasErrorRow = blockType !== 'starter' && blockType !== 'response' ? 1 : 0
|
||||
|
||||
const height =
|
||||
BLOCK_DIMENSIONS.HEADER_HEIGHT +
|
||||
BLOCK_DIMENSIONS.WORKFLOW_CONTENT_PADDING +
|
||||
(estimatedRows + hasErrorRow) * BLOCK_DIMENSIONS.WORKFLOW_ROW_HEIGHT
|
||||
|
||||
return {
|
||||
width: BLOCK_DIMENSIONS.FIXED_WIDTH,
|
||||
height: Math.max(height, BLOCK_DIMENSIONS.MIN_HEIGHT),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clamps a position to keep a block fully inside a container's content area.
|
||||
* Content area starts after the header and padding, and ends before the right/bottom padding.
|
||||
*
|
||||
* @param position - Raw position relative to container origin
|
||||
* @param containerDimensions - Container width and height
|
||||
* @param blockDimensions - Block width and height
|
||||
* @returns Clamped position that keeps block inside content area
|
||||
*/
|
||||
export function clampPositionToContainer(
|
||||
position: { x: number; y: number },
|
||||
containerDimensions: { width: number; height: number },
|
||||
blockDimensions: { width: number; height: number }
|
||||
): { x: number; y: number } {
|
||||
const { width: containerWidth, height: containerHeight } = containerDimensions
|
||||
const { width: blockWidth, height: blockHeight } = blockDimensions
|
||||
|
||||
const minX = CONTAINER_DIMENSIONS.LEFT_PADDING
|
||||
const minY = CONTAINER_DIMENSIONS.HEADER_HEIGHT + CONTAINER_DIMENSIONS.TOP_PADDING
|
||||
const maxX = containerWidth - CONTAINER_DIMENSIONS.RIGHT_PADDING - blockWidth
|
||||
const maxY = containerHeight - CONTAINER_DIMENSIONS.BOTTOM_PADDING - blockHeight
|
||||
|
||||
return {
|
||||
x: Math.max(minX, Math.min(position.x, Math.max(minX, maxX))),
|
||||
y: Math.max(minY, Math.min(position.y, Math.max(minY, maxY))),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates container dimensions based on child block positions.
|
||||
* Single source of truth for container sizing - ensures consistency between
|
||||
* live drag updates and final dimension calculations.
|
||||
*
|
||||
* @param childPositions - Array of child positions with their dimensions
|
||||
* @returns Calculated width and height for the container
|
||||
*/
|
||||
export function calculateContainerDimensions(
|
||||
childPositions: Array<{ x: number; y: number; width: number; height: number }>
|
||||
): { width: number; height: number } {
|
||||
if (childPositions.length === 0) {
|
||||
return {
|
||||
width: CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
|
||||
height: CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
|
||||
}
|
||||
}
|
||||
|
||||
let maxRight = 0
|
||||
let maxBottom = 0
|
||||
|
||||
for (const child of childPositions) {
|
||||
maxRight = Math.max(maxRight, child.x + child.width)
|
||||
maxBottom = Math.max(maxBottom, child.y + child.height)
|
||||
}
|
||||
|
||||
const width = Math.max(
|
||||
CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
|
||||
CONTAINER_DIMENSIONS.LEFT_PADDING + maxRight + CONTAINER_DIMENSIONS.RIGHT_PADDING
|
||||
)
|
||||
const height = Math.max(
|
||||
CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
|
||||
CONTAINER_DIMENSIONS.HEADER_HEIGHT +
|
||||
CONTAINER_DIMENSIONS.TOP_PADDING +
|
||||
maxBottom +
|
||||
CONTAINER_DIMENSIONS.BOTTOM_PADDING
|
||||
)
|
||||
|
||||
return { width, height }
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Edge, Node } from 'reactflow'
|
||||
import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions'
|
||||
import { TriggerUtils } from '@/lib/workflows/triggers/triggers'
|
||||
import { clampPositionToContainer } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities'
|
||||
import { clampPositionToContainer } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/node-position-utils'
|
||||
import type { BlockState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
/**
|
||||
|
||||
@@ -42,26 +42,28 @@ import { WorkflowBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/comp
|
||||
import { WorkflowControls } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-controls/workflow-controls'
|
||||
import { WorkflowEdge } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge'
|
||||
import {
|
||||
clearDragHighlights,
|
||||
computeClampedPositionUpdates,
|
||||
getClampedPositionForNode,
|
||||
isInEditableElement,
|
||||
resolveParentChildSelectionConflicts,
|
||||
useAutoLayout,
|
||||
useCanvasContextMenu,
|
||||
useCurrentWorkflow,
|
||||
useNodeUtilities,
|
||||
validateTriggerPaste,
|
||||
useShiftSelectionLock,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
|
||||
import { useCanvasContextMenu } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-canvas-context-menu'
|
||||
import {
|
||||
calculateContainerDimensions,
|
||||
clampPositionToContainer,
|
||||
clearDragHighlights,
|
||||
computeClampedPositionUpdates,
|
||||
estimateBlockDimensions,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities'
|
||||
getClampedPositionForNode,
|
||||
isInEditableElement,
|
||||
resolveParentChildSelectionConflicts,
|
||||
validateTriggerPaste,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/utils'
|
||||
import { useSocket } from '@/app/workspace/providers/socket-provider'
|
||||
import { getBlock } from '@/blocks'
|
||||
import { isAnnotationOnlyBlock } from '@/executor/constants'
|
||||
import { useWorkspaceEnvironment } from '@/hooks/queries/environment'
|
||||
import { useAutoConnect, useSnapToGridSize } from '@/hooks/queries/general-settings'
|
||||
import { useCanvasViewport } from '@/hooks/use-canvas-viewport'
|
||||
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
||||
import { usePermissionConfig } from '@/hooks/use-permission-config'
|
||||
@@ -73,7 +75,6 @@ import { useExecutionStore } from '@/stores/execution'
|
||||
import { useSearchModalStore } from '@/stores/modals/search/store'
|
||||
import { useNotificationStore } from '@/stores/notifications'
|
||||
import { useCopilotStore, usePanelEditorStore } from '@/stores/panel'
|
||||
import { useGeneralStore } from '@/stores/settings/general'
|
||||
import { useUndoRedoStore } from '@/stores/undo-redo'
|
||||
import { useVariablesStore } from '@/stores/variables/store'
|
||||
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
|
||||
@@ -233,8 +234,10 @@ const WorkflowContent = React.memo(() => {
|
||||
const [potentialParentId, setPotentialParentId] = useState<string | null>(null)
|
||||
const [selectedEdges, setSelectedEdges] = useState<SelectedEdgesMap>(new Map())
|
||||
const [isErrorConnectionDrag, setIsErrorConnectionDrag] = useState(false)
|
||||
const selectedIdsRef = useRef<string[] | null>(null)
|
||||
const canvasMode = useCanvasModeStore((state) => state.mode)
|
||||
const isHandMode = canvasMode === 'hand'
|
||||
const { handleCanvasMouseDown, selectionProps } = useShiftSelectionLock({ isHandMode })
|
||||
const [oauthModal, setOauthModal] = useState<{
|
||||
provider: OAuthProvider
|
||||
serviceId: string
|
||||
@@ -264,6 +267,9 @@ const WorkflowContent = React.memo(() => {
|
||||
preparePasteData,
|
||||
hasClipboard,
|
||||
clipboard,
|
||||
pendingSelection,
|
||||
setPendingSelection,
|
||||
clearPendingSelection,
|
||||
} = useWorkflowRegistry(
|
||||
useShallow((state) => ({
|
||||
workflows: state.workflows,
|
||||
@@ -274,6 +280,9 @@ const WorkflowContent = React.memo(() => {
|
||||
preparePasteData: state.preparePasteData,
|
||||
hasClipboard: state.hasClipboard,
|
||||
clipboard: state.clipboard,
|
||||
pendingSelection: state.pendingSelection,
|
||||
setPendingSelection: state.setPendingSelection,
|
||||
clearPendingSelection: state.clearPendingSelection,
|
||||
}))
|
||||
)
|
||||
|
||||
@@ -300,9 +309,15 @@ const WorkflowContent = React.memo(() => {
|
||||
|
||||
const showTrainingModal = useCopilotTrainingStore((state) => state.showModal)
|
||||
|
||||
const snapToGridSize = useGeneralStore((state) => state.snapToGridSize)
|
||||
const snapToGridSize = useSnapToGridSize()
|
||||
const snapToGrid = snapToGridSize > 0
|
||||
|
||||
const isAutoConnectEnabled = useAutoConnect()
|
||||
const autoConnectRef = useRef(isAutoConnectEnabled)
|
||||
useEffect(() => {
|
||||
autoConnectRef.current = isAutoConnectEnabled
|
||||
}, [isAutoConnectEnabled])
|
||||
|
||||
// Panel open states for context menu
|
||||
const isVariablesOpen = useVariablesStore((state) => state.isOpen)
|
||||
const isChatOpen = useChatStore((state) => state.isChatOpen)
|
||||
@@ -441,9 +456,6 @@ const WorkflowContent = React.memo(() => {
|
||||
new Map()
|
||||
)
|
||||
|
||||
/** Stores node IDs to select on next derivedNodes sync (for paste/duplicate operations). */
|
||||
const pendingSelectionRef = useRef<Set<string> | null>(null)
|
||||
|
||||
/** Re-applies diff markers when blocks change after socket rehydration. */
|
||||
const blocksRef = useRef(blocks)
|
||||
useEffect(() => {
|
||||
@@ -680,9 +692,10 @@ const WorkflowContent = React.memo(() => {
|
||||
parentId?: string,
|
||||
extent?: 'parent',
|
||||
autoConnectEdge?: Edge,
|
||||
triggerMode?: boolean
|
||||
triggerMode?: boolean,
|
||||
presetSubBlockValues?: Record<string, unknown>
|
||||
) => {
|
||||
pendingSelectionRef.current = new Set([id])
|
||||
setPendingSelection([id])
|
||||
setSelectedEdges(new Map())
|
||||
|
||||
const blockData: Record<string, unknown> = { ...(data || {}) }
|
||||
@@ -710,6 +723,14 @@ const WorkflowContent = React.memo(() => {
|
||||
}
|
||||
}
|
||||
|
||||
// Apply preset subblock values (e.g., from tool-operation search)
|
||||
if (presetSubBlockValues) {
|
||||
if (!subBlockValues[id]) {
|
||||
subBlockValues[id] = {}
|
||||
}
|
||||
Object.assign(subBlockValues[id], presetSubBlockValues)
|
||||
}
|
||||
|
||||
collaborativeBatchAddBlocks(
|
||||
[block],
|
||||
autoConnectEdge ? [autoConnectEdge] : [],
|
||||
@@ -719,7 +740,7 @@ const WorkflowContent = React.memo(() => {
|
||||
)
|
||||
usePanelEditorStore.getState().setCurrentBlockId(id)
|
||||
},
|
||||
[collaborativeBatchAddBlocks, setSelectedEdges]
|
||||
[collaborativeBatchAddBlocks, setSelectedEdges, setPendingSelection]
|
||||
)
|
||||
|
||||
const { activeBlockIds, pendingBlocks, isDebugging } = useExecutionStore(
|
||||
@@ -853,7 +874,7 @@ const WorkflowContent = React.memo(() => {
|
||||
handlePaneContextMenu,
|
||||
handleSelectionContextMenu,
|
||||
closeMenu: closeContextMenu,
|
||||
} = useCanvasContextMenu({ blocks, getNodes })
|
||||
} = useCanvasContextMenu({ blocks, getNodes, setNodes })
|
||||
|
||||
const handleContextCopy = useCallback(() => {
|
||||
const blockIds = contextMenuBlocks.map((b) => b.id)
|
||||
@@ -881,10 +902,7 @@ const WorkflowContent = React.memo(() => {
|
||||
}
|
||||
|
||||
// Set pending selection before adding blocks - sync effect will apply it (accumulates for rapid pastes)
|
||||
pendingSelectionRef.current = new Set([
|
||||
...(pendingSelectionRef.current ?? []),
|
||||
...pastedBlocksArray.map((b) => b.id),
|
||||
])
|
||||
setPendingSelection(pastedBlocksArray.map((b) => b.id))
|
||||
|
||||
collaborativeBatchAddBlocks(
|
||||
pastedBlocksArray,
|
||||
@@ -894,7 +912,14 @@ const WorkflowContent = React.memo(() => {
|
||||
pasteData.subBlockValues
|
||||
)
|
||||
},
|
||||
[preparePasteData, blocks, addNotification, activeWorkflowId, collaborativeBatchAddBlocks]
|
||||
[
|
||||
preparePasteData,
|
||||
blocks,
|
||||
addNotification,
|
||||
activeWorkflowId,
|
||||
collaborativeBatchAddBlocks,
|
||||
setPendingSelection,
|
||||
]
|
||||
)
|
||||
|
||||
const handleContextPaste = useCallback(() => {
|
||||
@@ -1208,8 +1233,7 @@ const WorkflowContent = React.memo(() => {
|
||||
containerId?: string
|
||||
}
|
||||
): Edge | undefined => {
|
||||
const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled
|
||||
if (!isAutoConnectEnabled) return undefined
|
||||
if (!autoConnectRef.current) return undefined
|
||||
|
||||
// Don't auto-connect starter or annotation-only blocks
|
||||
if (options.blockType === 'starter' || isAnnotationOnlyBlock(options.blockType)) {
|
||||
@@ -1474,7 +1498,7 @@ const WorkflowContent = React.memo(() => {
|
||||
return
|
||||
}
|
||||
|
||||
const { type, enableTriggerMode } = event.detail
|
||||
const { type, enableTriggerMode, presetOperation } = event.detail
|
||||
|
||||
if (!type) return
|
||||
if (type === 'connectionBlock') return
|
||||
@@ -1537,7 +1561,8 @@ const WorkflowContent = React.memo(() => {
|
||||
undefined,
|
||||
undefined,
|
||||
autoConnectEdge,
|
||||
enableTriggerMode
|
||||
enableTriggerMode,
|
||||
presetOperation ? { operation: presetOperation } : undefined
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2041,26 +2066,28 @@ const WorkflowContent = React.memo(() => {
|
||||
|
||||
useEffect(() => {
|
||||
// Check for pending selection (from paste/duplicate), otherwise preserve existing selection
|
||||
const pendingSelection = pendingSelectionRef.current
|
||||
pendingSelectionRef.current = null
|
||||
if (pendingSelection && pendingSelection.length > 0) {
|
||||
const pendingSet = new Set(pendingSelection)
|
||||
clearPendingSelection()
|
||||
|
||||
// Apply pending selection and resolve parent-child conflicts
|
||||
const withSelection = derivedNodes.map((node) => ({
|
||||
...node,
|
||||
selected: pendingSet.has(node.id),
|
||||
}))
|
||||
setDisplayNodes(resolveParentChildSelectionConflicts(withSelection, blocks))
|
||||
return
|
||||
}
|
||||
|
||||
// Preserve existing selection state
|
||||
setDisplayNodes((currentNodes) => {
|
||||
if (pendingSelection) {
|
||||
// Apply pending selection and resolve parent-child conflicts
|
||||
const withSelection = derivedNodes.map((node) => ({
|
||||
...node,
|
||||
selected: pendingSelection.has(node.id),
|
||||
}))
|
||||
return resolveParentChildSelectionConflicts(withSelection, blocks)
|
||||
}
|
||||
// Preserve existing selection state
|
||||
const selectedIds = new Set(currentNodes.filter((n) => n.selected).map((n) => n.id))
|
||||
return derivedNodes.map((node) => ({
|
||||
...node,
|
||||
selected: selectedIds.has(node.id),
|
||||
}))
|
||||
})
|
||||
}, [derivedNodes, blocks])
|
||||
}, [derivedNodes, blocks, pendingSelection, clearPendingSelection])
|
||||
|
||||
/** Handles ActionBar remove-from-subflow events. */
|
||||
useEffect(() => {
|
||||
@@ -2137,11 +2164,25 @@ const WorkflowContent = React.memo(() => {
|
||||
/** Handles node changes - applies changes and resolves parent-child selection conflicts. */
|
||||
const onNodesChange = useCallback(
|
||||
(changes: NodeChange[]) => {
|
||||
selectedIdsRef.current = null
|
||||
setDisplayNodes((nds) => {
|
||||
const updated = applyNodeChanges(changes, nds)
|
||||
const hasSelectionChange = changes.some((c) => c.type === 'select')
|
||||
return hasSelectionChange ? resolveParentChildSelectionConflicts(updated, blocks) : updated
|
||||
if (!hasSelectionChange) return updated
|
||||
const resolved = resolveParentChildSelectionConflicts(updated, blocks)
|
||||
selectedIdsRef.current = resolved.filter((node) => node.selected).map((node) => node.id)
|
||||
return resolved
|
||||
})
|
||||
const selectedIds = selectedIdsRef.current as string[] | null
|
||||
if (selectedIds !== null) {
|
||||
const { currentBlockId, clearCurrentBlock, setCurrentBlockId } =
|
||||
usePanelEditorStore.getState()
|
||||
if (selectedIds.length === 1 && selectedIds[0] !== currentBlockId) {
|
||||
setCurrentBlockId(selectedIds[0])
|
||||
} else if (selectedIds.length === 0 && currentBlockId) {
|
||||
clearCurrentBlock()
|
||||
}
|
||||
}
|
||||
},
|
||||
[blocks]
|
||||
)
|
||||
@@ -3010,23 +3051,6 @@ const WorkflowContent = React.memo(() => {
|
||||
usePanelEditorStore.getState().clearCurrentBlock()
|
||||
}, [])
|
||||
|
||||
/** Prevents native text selection when starting a shift-drag on the pane. */
|
||||
const handleCanvasMouseDown = useCallback((event: React.MouseEvent) => {
|
||||
if (!event.shiftKey) return
|
||||
|
||||
const target = event.target as HTMLElement | null
|
||||
if (!target) return
|
||||
|
||||
const isPaneTarget = Boolean(target.closest('.react-flow__pane, .react-flow__selectionpane'))
|
||||
if (!isPaneTarget) return
|
||||
|
||||
event.preventDefault()
|
||||
const selection = window.getSelection()
|
||||
if (selection && selection.rangeCount > 0) {
|
||||
selection.removeAllRanges()
|
||||
}
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Handles node click to select the node in ReactFlow.
|
||||
* Parent-child conflict resolution happens automatically in onNodesChange.
|
||||
@@ -3226,9 +3250,10 @@ const WorkflowContent = React.memo(() => {
|
||||
onPointerMove={handleCanvasPointerMove}
|
||||
onPointerLeave={handleCanvasPointerLeave}
|
||||
elementsSelectable={true}
|
||||
selectionOnDrag={!isHandMode}
|
||||
selectionOnDrag={selectionProps.selectionOnDrag}
|
||||
selectionMode={SelectionMode.Partial}
|
||||
panOnDrag={isHandMode ? [0, 1] : false}
|
||||
panOnDrag={selectionProps.panOnDrag}
|
||||
selectionKeyCode={selectionProps.selectionKeyCode}
|
||||
multiSelectionKeyCode={['Meta', 'Control', 'Shift']}
|
||||
nodesConnectable={effectivePermissions.canEdit}
|
||||
nodesDraggable={effectivePermissions.canEdit}
|
||||
|
||||
@@ -19,7 +19,7 @@ import { NoteBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/componen
|
||||
import { SubflowNodeComponent } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node'
|
||||
import { WorkflowBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block'
|
||||
import { WorkflowEdge } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge'
|
||||
import { estimateBlockDimensions } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities'
|
||||
import { estimateBlockDimensions } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils'
|
||||
import { WorkflowPreviewBlock } from '@/app/workspace/[workspaceId]/w/components/preview/components/block'
|
||||
import { WorkflowPreviewSubflow } from '@/app/workspace/[workspaceId]/w/components/preview/components/subflow'
|
||||
import { getBlock } from '@/blocks'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { memo, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
||||
import * as VisuallyHidden from '@radix-ui/react-visually-hidden'
|
||||
import { BookOpen, Layout, RepeatIcon, ScrollText, Search, SplitIcon } from 'lucide-react'
|
||||
@@ -8,6 +8,7 @@ import { useParams, useRouter } from 'next/navigation'
|
||||
import { Dialog, DialogPortal, DialogTitle } from '@/components/ui/dialog'
|
||||
import { useBrandConfig } from '@/lib/branding/branding'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { getToolOperationsIndex } from '@/lib/search/tool-operations'
|
||||
import { getTriggersForSidebar, hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils'
|
||||
import { searchItems } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-utils'
|
||||
import { SIDEBAR_SCROLL_EVENT } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar'
|
||||
@@ -81,13 +82,102 @@ type SearchItem = {
|
||||
color?: string
|
||||
href?: string
|
||||
shortcut?: string
|
||||
type: 'block' | 'trigger' | 'tool' | 'workflow' | 'workspace' | 'page' | 'doc'
|
||||
type: 'block' | 'trigger' | 'tool' | 'tool-operation' | 'workflow' | 'workspace' | 'page' | 'doc'
|
||||
isCurrent?: boolean
|
||||
blockType?: string
|
||||
config?: any
|
||||
operationId?: string
|
||||
aliases?: string[]
|
||||
}
|
||||
|
||||
export function SearchModal({
|
||||
interface SearchResultItemProps {
|
||||
item: SearchItem
|
||||
visualIndex: number
|
||||
isSelected: boolean
|
||||
onItemClick: (item: SearchItem) => void
|
||||
}
|
||||
|
||||
const SearchResultItem = memo(function SearchResultItem({
|
||||
item,
|
||||
visualIndex,
|
||||
isSelected,
|
||||
onItemClick,
|
||||
}: SearchResultItemProps) {
|
||||
const Icon = item.icon
|
||||
const showColoredIcon =
|
||||
item.type === 'block' ||
|
||||
item.type === 'trigger' ||
|
||||
item.type === 'tool' ||
|
||||
item.type === 'tool-operation'
|
||||
const isWorkflow = item.type === 'workflow'
|
||||
const isWorkspace = item.type === 'workspace'
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
onItemClick(item)
|
||||
}, [onItemClick, item])
|
||||
|
||||
return (
|
||||
<button
|
||||
data-search-item-index={visualIndex}
|
||||
onClick={handleClick}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
className={cn(
|
||||
'group flex h-[28px] w-full items-center gap-[8px] rounded-[6px] bg-[var(--surface-4)]/60 px-[10px] text-left text-[15px] transition-all focus:outline-none',
|
||||
isSelected ? 'bg-[var(--border)] shadow-sm' : 'hover:bg-[var(--border)]'
|
||||
)}
|
||||
>
|
||||
{/* Icon - different rendering for workflows vs others */}
|
||||
{!isWorkspace && (
|
||||
<>
|
||||
{isWorkflow ? (
|
||||
<div
|
||||
className='h-[14px] w-[14px] flex-shrink-0 rounded-[3px]'
|
||||
style={{ backgroundColor: item.color }}
|
||||
/>
|
||||
) : (
|
||||
Icon && (
|
||||
<div
|
||||
className='relative flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center overflow-hidden rounded-[4px]'
|
||||
style={{ background: showColoredIcon ? item.bgColor : 'transparent' }}
|
||||
>
|
||||
<Icon
|
||||
className={cn(
|
||||
'transition-transform duration-100 group-hover:scale-110',
|
||||
showColoredIcon
|
||||
? '!h-[10px] !w-[10px] text-white'
|
||||
: 'h-[14px] w-[14px] text-[var(--text-tertiary)] group-hover:text-[var(--text-primary)]'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<span
|
||||
className={cn(
|
||||
'truncate font-medium',
|
||||
isSelected
|
||||
? 'text-[var(--text-primary)]'
|
||||
: 'text-[var(--text-tertiary)] group-hover:text-[var(--text-primary)]'
|
||||
)}
|
||||
>
|
||||
{item.name}
|
||||
{item.isCurrent && ' (current)'}
|
||||
</span>
|
||||
|
||||
{/* Shortcut */}
|
||||
{item.shortcut && (
|
||||
<span className='ml-auto flex-shrink-0 font-medium text-[13px] text-[var(--text-subtle)]'>
|
||||
{item.shortcut}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})
|
||||
|
||||
export const SearchModal = memo(function SearchModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
workflows = [],
|
||||
@@ -103,7 +193,7 @@ export function SearchModal({
|
||||
const { filterBlocks } = usePermissionConfig()
|
||||
|
||||
const blocks = useMemo(() => {
|
||||
if (!isOnWorkflowPage) return []
|
||||
if (!open || !isOnWorkflowPage) return []
|
||||
|
||||
const allBlocks = getAllBlocks()
|
||||
const filteredAllBlocks = filterBlocks(allBlocks)
|
||||
@@ -142,10 +232,10 @@ export function SearchModal({
|
||||
]
|
||||
|
||||
return [...regularBlocks, ...filterBlocks(specialBlocks)]
|
||||
}, [isOnWorkflowPage, filterBlocks])
|
||||
}, [open, isOnWorkflowPage, filterBlocks])
|
||||
|
||||
const triggers = useMemo(() => {
|
||||
if (!isOnWorkflowPage) return []
|
||||
if (!open || !isOnWorkflowPage) return []
|
||||
|
||||
const allTriggers = getTriggersForSidebar()
|
||||
const filteredTriggers = filterBlocks(allTriggers)
|
||||
@@ -174,10 +264,10 @@ export function SearchModal({
|
||||
config: block,
|
||||
})
|
||||
)
|
||||
}, [isOnWorkflowPage, filterBlocks])
|
||||
}, [open, isOnWorkflowPage, filterBlocks])
|
||||
|
||||
const tools = useMemo(() => {
|
||||
if (!isOnWorkflowPage) return []
|
||||
if (!open || !isOnWorkflowPage) return []
|
||||
|
||||
const allBlocks = getAllBlocks()
|
||||
const filteredAllBlocks = filterBlocks(allBlocks)
|
||||
@@ -193,7 +283,25 @@ export function SearchModal({
|
||||
type: block.type,
|
||||
})
|
||||
)
|
||||
}, [isOnWorkflowPage, filterBlocks])
|
||||
}, [open, isOnWorkflowPage, filterBlocks])
|
||||
|
||||
const toolOperations = useMemo(() => {
|
||||
if (!open || !isOnWorkflowPage) return []
|
||||
|
||||
const allowedBlockTypes = new Set(tools.map((t) => t.type))
|
||||
|
||||
return getToolOperationsIndex()
|
||||
.filter((op) => allowedBlockTypes.has(op.blockType))
|
||||
.map((op) => ({
|
||||
id: op.id,
|
||||
name: `${op.serviceName}: ${op.operationName}`,
|
||||
icon: op.icon,
|
||||
bgColor: op.bgColor,
|
||||
blockType: op.blockType,
|
||||
operationId: op.operationId,
|
||||
aliases: op.aliases,
|
||||
}))
|
||||
}, [open, isOnWorkflowPage, tools])
|
||||
|
||||
const pages = useMemo(
|
||||
(): PageItem[] => [
|
||||
@@ -221,6 +329,8 @@ export function SearchModal({
|
||||
)
|
||||
|
||||
const docs = useMemo((): DocItem[] => {
|
||||
if (!open) return []
|
||||
|
||||
const allBlocks = getAllBlocks()
|
||||
const docsItems: DocItem[] = []
|
||||
|
||||
@@ -237,7 +347,7 @@ export function SearchModal({
|
||||
})
|
||||
|
||||
return docsItems
|
||||
}, [])
|
||||
}, [open])
|
||||
|
||||
const allItems = useMemo((): SearchItem[] => {
|
||||
const items: SearchItem[] = []
|
||||
@@ -311,6 +421,19 @@ export function SearchModal({
|
||||
})
|
||||
})
|
||||
|
||||
toolOperations.forEach((op) => {
|
||||
items.push({
|
||||
id: op.id,
|
||||
name: op.name,
|
||||
icon: op.icon,
|
||||
bgColor: op.bgColor,
|
||||
type: 'tool-operation',
|
||||
blockType: op.blockType,
|
||||
operationId: op.operationId,
|
||||
aliases: op.aliases,
|
||||
})
|
||||
})
|
||||
|
||||
docs.forEach((doc) => {
|
||||
items.push({
|
||||
id: doc.id,
|
||||
@@ -322,10 +445,10 @@ export function SearchModal({
|
||||
})
|
||||
|
||||
return items
|
||||
}, [workspaces, workflows, pages, blocks, triggers, tools, docs])
|
||||
}, [workspaces, workflows, pages, blocks, triggers, tools, toolOperations, docs])
|
||||
|
||||
const sectionOrder = useMemo<SearchItem['type'][]>(
|
||||
() => ['block', 'tool', 'trigger', 'workflow', 'workspace', 'page', 'doc'],
|
||||
() => ['block', 'tool', 'tool-operation', 'trigger', 'workflow', 'workspace', 'page', 'doc'],
|
||||
[]
|
||||
)
|
||||
|
||||
@@ -372,6 +495,7 @@ export function SearchModal({
|
||||
page: [],
|
||||
trigger: [],
|
||||
block: [],
|
||||
'tool-operation': [],
|
||||
tool: [],
|
||||
doc: [],
|
||||
}
|
||||
@@ -427,6 +551,17 @@ export function SearchModal({
|
||||
window.dispatchEvent(event)
|
||||
}
|
||||
break
|
||||
case 'tool-operation':
|
||||
if (item.blockType && item.operationId) {
|
||||
const event = new CustomEvent('add-block-from-toolbar', {
|
||||
detail: {
|
||||
type: item.blockType,
|
||||
presetOperation: item.operationId,
|
||||
},
|
||||
})
|
||||
window.dispatchEvent(event)
|
||||
}
|
||||
break
|
||||
case 'workspace':
|
||||
if (item.isCurrent) {
|
||||
break
|
||||
@@ -507,6 +642,7 @@ export function SearchModal({
|
||||
page: 'Pages',
|
||||
trigger: 'Triggers',
|
||||
block: 'Blocks',
|
||||
'tool-operation': 'Tool Operations',
|
||||
tool: 'Tools',
|
||||
doc: 'Docs',
|
||||
}
|
||||
@@ -549,78 +685,16 @@ export function SearchModal({
|
||||
|
||||
{/* Section items */}
|
||||
<div className='space-y-[2px]'>
|
||||
{items.map((item, itemIndex) => {
|
||||
const Icon = item.icon
|
||||
{items.map((item) => {
|
||||
const visualIndex = displayedItemsInVisualOrder.indexOf(item)
|
||||
const isSelected = visualIndex === selectedIndex
|
||||
const showColoredIcon =
|
||||
item.type === 'block' || item.type === 'trigger' || item.type === 'tool'
|
||||
const isWorkflow = item.type === 'workflow'
|
||||
const isWorkspace = item.type === 'workspace'
|
||||
|
||||
return (
|
||||
<button
|
||||
<SearchResultItem
|
||||
key={`${item.type}-${item.id}`}
|
||||
data-search-item-index={visualIndex}
|
||||
onClick={() => handleItemClick(item)}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
className={cn(
|
||||
'group flex h-[28px] w-full items-center gap-[8px] rounded-[6px] bg-[var(--surface-4)]/60 px-[10px] text-left text-[15px] transition-all focus:outline-none',
|
||||
isSelected
|
||||
? 'bg-[var(--border)] shadow-sm'
|
||||
: 'hover:bg-[var(--border)]'
|
||||
)}
|
||||
>
|
||||
{/* Icon - different rendering for workflows vs others */}
|
||||
{!isWorkspace && (
|
||||
<>
|
||||
{isWorkflow ? (
|
||||
<div
|
||||
className='h-[14px] w-[14px] flex-shrink-0 rounded-[3px]'
|
||||
style={{ backgroundColor: item.color }}
|
||||
/>
|
||||
) : (
|
||||
Icon && (
|
||||
<div
|
||||
className='relative flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center overflow-hidden rounded-[4px]'
|
||||
style={{
|
||||
background: showColoredIcon ? item.bgColor : 'transparent',
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
className={cn(
|
||||
'transition-transform duration-100 group-hover:scale-110',
|
||||
showColoredIcon
|
||||
? '!h-[10px] !w-[10px] text-white'
|
||||
: 'h-[14px] w-[14px] text-[var(--text-tertiary)] group-hover:text-[var(--text-primary)]'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<span
|
||||
className={cn(
|
||||
'truncate font-medium',
|
||||
isSelected
|
||||
? 'text-[var(--text-primary)]'
|
||||
: 'text-[var(--text-tertiary)] group-hover:text-[var(--text-primary)]'
|
||||
)}
|
||||
>
|
||||
{item.name}
|
||||
{item.isCurrent && ' (current)'}
|
||||
</span>
|
||||
|
||||
{/* Shortcut */}
|
||||
{item.shortcut && (
|
||||
<span className='ml-auto flex-shrink-0 font-medium text-[13px] text-[var(--text-subtle)]'>
|
||||
{item.shortcut}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
item={item}
|
||||
visualIndex={visualIndex}
|
||||
isSelected={visualIndex === selectedIndex}
|
||||
onItemClick={handleItemClick}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
@@ -639,4 +713,4 @@ export function SearchModal({
|
||||
</DialogPortal>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -8,17 +8,19 @@ export interface SearchableItem {
|
||||
name: string
|
||||
description?: string
|
||||
type: string
|
||||
aliases?: string[]
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
export interface SearchResult<T extends SearchableItem> {
|
||||
item: T
|
||||
score: number
|
||||
matchType: 'exact' | 'prefix' | 'word-boundary' | 'substring' | 'description'
|
||||
matchType: 'exact' | 'prefix' | 'alias' | 'word-boundary' | 'substring' | 'description'
|
||||
}
|
||||
|
||||
const SCORE_EXACT_MATCH = 10000
|
||||
const SCORE_PREFIX_MATCH = 5000
|
||||
const SCORE_ALIAS_MATCH = 3000
|
||||
const SCORE_WORD_BOUNDARY = 1000
|
||||
const SCORE_SUBSTRING_MATCH = 100
|
||||
const DESCRIPTION_WEIGHT = 0.3
|
||||
@@ -67,6 +69,39 @@ function calculateFieldScore(
|
||||
return { score: 0, matchType: null }
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if query matches any alias in the item's aliases array
|
||||
* Returns the alias score if a match is found, 0 otherwise
|
||||
*/
|
||||
function calculateAliasScore(
|
||||
query: string,
|
||||
aliases?: string[]
|
||||
): { score: number; matchType: 'alias' | null } {
|
||||
if (!aliases || aliases.length === 0) {
|
||||
return { score: 0, matchType: null }
|
||||
}
|
||||
|
||||
const normalizedQuery = query.toLowerCase().trim()
|
||||
|
||||
for (const alias of aliases) {
|
||||
const normalizedAlias = alias.toLowerCase().trim()
|
||||
|
||||
if (normalizedAlias === normalizedQuery) {
|
||||
return { score: SCORE_ALIAS_MATCH, matchType: 'alias' }
|
||||
}
|
||||
|
||||
if (normalizedAlias.startsWith(normalizedQuery)) {
|
||||
return { score: SCORE_ALIAS_MATCH * 0.8, matchType: 'alias' }
|
||||
}
|
||||
|
||||
if (normalizedQuery.includes(normalizedAlias) || normalizedAlias.includes(normalizedQuery)) {
|
||||
return { score: SCORE_ALIAS_MATCH * 0.6, matchType: 'alias' }
|
||||
}
|
||||
}
|
||||
|
||||
return { score: 0, matchType: null }
|
||||
}
|
||||
|
||||
/**
|
||||
* Search items using tiered matching algorithm
|
||||
* Returns items sorted by relevance (highest score first)
|
||||
@@ -90,15 +125,20 @@ export function searchItems<T extends SearchableItem>(
|
||||
? calculateFieldScore(normalizedQuery, item.description)
|
||||
: { score: 0, matchType: null }
|
||||
|
||||
const aliasMatch = calculateAliasScore(normalizedQuery, item.aliases)
|
||||
|
||||
const nameScore = nameMatch.score
|
||||
const descScore = descMatch.score * DESCRIPTION_WEIGHT
|
||||
const aliasScore = aliasMatch.score
|
||||
|
||||
const bestScore = Math.max(nameScore, descScore)
|
||||
const bestScore = Math.max(nameScore, descScore, aliasScore)
|
||||
|
||||
if (bestScore > 0) {
|
||||
let matchType: SearchResult<T>['matchType'] = 'substring'
|
||||
if (nameScore >= descScore) {
|
||||
if (nameScore >= descScore && nameScore >= aliasScore) {
|
||||
matchType = nameMatch.matchType || 'substring'
|
||||
} else if (aliasScore >= descScore) {
|
||||
matchType = 'alias'
|
||||
} else {
|
||||
matchType = 'description'
|
||||
}
|
||||
@@ -125,6 +165,8 @@ export function getMatchTypeLabel(matchType: SearchResult<any>['matchType']): st
|
||||
return 'Exact match'
|
||||
case 'prefix':
|
||||
return 'Starts with'
|
||||
case 'alias':
|
||||
return 'Similar to'
|
||||
case 'word-boundary':
|
||||
return 'Word match'
|
||||
case 'substring':
|
||||
|
||||
@@ -1078,7 +1078,7 @@ export function AccessControl() {
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button
|
||||
variant='default'
|
||||
variant='destructive'
|
||||
onClick={() => {
|
||||
setShowUnsavedChanges(false)
|
||||
setShowConfigModal(false)
|
||||
|
||||
@@ -294,10 +294,9 @@ export function BYOK() {
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='primary'
|
||||
variant='tertiary'
|
||||
onClick={handleSave}
|
||||
disabled={!apiKeyInput.trim() || upsertKey.isPending}
|
||||
className='!bg-[var(--brand-tertiary-2)] !text-[var(--text-inverse)] hover:!bg-[var(--brand-tertiary-2)]/90'
|
||||
>
|
||||
{upsertKey.isPending ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
@@ -321,12 +320,7 @@ export function BYOK() {
|
||||
<Button variant='default' onClick={() => setDeleteConfirmProvider(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='primary'
|
||||
onClick={handleDelete}
|
||||
disabled={deleteKey.isPending}
|
||||
className='!bg-[var(--brand-tertiary-2)] !text-[var(--text-inverse)] hover:!bg-[var(--brand-tertiary-2)]/90'
|
||||
>
|
||||
<Button variant='destructive' onClick={handleDelete} disabled={deleteKey.isPending}>
|
||||
{deleteKey.isPending ? 'Deleting...' : 'Delete'}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
|
||||
@@ -334,7 +334,7 @@ export function Copilot() {
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='ghost'
|
||||
variant='destructive'
|
||||
onClick={handleDeleteKey}
|
||||
disabled={deleteKeyMutation.isPending}
|
||||
>
|
||||
|
||||
@@ -831,7 +831,7 @@ export function EnvironmentVariables({ registerBeforeLeaveHandler }: Environment
|
||||
</p>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant='default' onClick={handleCancel}>
|
||||
<Button variant='destructive' onClick={handleCancel}>
|
||||
Discard Changes
|
||||
</Button>
|
||||
{hasConflicts || hasInvalidKeys ? (
|
||||
|
||||
@@ -32,11 +32,13 @@ import {
|
||||
UsageLimit,
|
||||
type UsageLimitRef,
|
||||
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/usage-limit'
|
||||
import { useUpdateGeneralSetting } from '@/hooks/queries/general-settings'
|
||||
import {
|
||||
useBillingUsageNotifications,
|
||||
useUpdateGeneralSetting,
|
||||
} from '@/hooks/queries/general-settings'
|
||||
import { useOrganizationBilling, useOrganizations } from '@/hooks/queries/organization'
|
||||
import { useSubscriptionData, useUsageLimitData } from '@/hooks/queries/subscription'
|
||||
import { useUpdateWorkspaceSettings, useWorkspaceSettings } from '@/hooks/queries/workspace'
|
||||
import { useGeneralStore } from '@/stores/settings/general'
|
||||
|
||||
const CONSTANTS = {
|
||||
UPGRADE_ERROR_TIMEOUT: 3000, // 3 seconds
|
||||
@@ -627,7 +629,7 @@ export function Subscription() {
|
||||
}
|
||||
|
||||
function BillingUsageNotificationsToggle() {
|
||||
const enabled = useGeneralStore((s) => s.isBillingUsageNotificationsEnabled)
|
||||
const enabled = useBillingUsageNotifications()
|
||||
const updateSetting = useUpdateGeneralSetting()
|
||||
const isLoading = updateSetting.isPending
|
||||
|
||||
|
||||
@@ -117,7 +117,7 @@ export function TeamSeats({
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<Button variant='active' onClick={() => onOpenChange(false)} disabled={isLoading}>
|
||||
<Button variant='default' onClick={() => onOpenChange(false)} disabled={isLoading}>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Check } from 'lucide-react'
|
||||
import {
|
||||
Button,
|
||||
@@ -11,10 +11,110 @@ import {
|
||||
PopoverDivider,
|
||||
PopoverFolder,
|
||||
PopoverItem,
|
||||
usePopoverContext,
|
||||
} from '@/components/emcn'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { WORKFLOW_COLORS } from '@/lib/workflows/colors'
|
||||
|
||||
const GRID_COLUMNS = 6
|
||||
|
||||
/**
|
||||
* Color grid with keyboard navigation support.
|
||||
* Uses roving tabindex pattern for accessibility.
|
||||
*/
|
||||
function ColorGrid({
|
||||
hexInput,
|
||||
setHexInput,
|
||||
}: {
|
||||
hexInput: string
|
||||
setHexInput: (color: string) => void
|
||||
}) {
|
||||
const { isInFolder } = usePopoverContext()
|
||||
const [focusedIndex, setFocusedIndex] = useState(-1)
|
||||
const gridRef = useRef<HTMLDivElement>(null)
|
||||
const buttonRefs = useRef<(HTMLButtonElement | null)[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
if (isInFolder && gridRef.current) {
|
||||
const selectedIndex = WORKFLOW_COLORS.findIndex(
|
||||
({ color }) => color.toLowerCase() === hexInput.toLowerCase()
|
||||
)
|
||||
const initialIndex = selectedIndex >= 0 ? selectedIndex : 0
|
||||
setFocusedIndex(initialIndex)
|
||||
setTimeout(() => {
|
||||
buttonRefs.current[initialIndex]?.focus()
|
||||
}, 50)
|
||||
}
|
||||
}, [isInFolder, hexInput])
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent, index: number) => {
|
||||
const totalItems = WORKFLOW_COLORS.length
|
||||
let newIndex = index
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowRight':
|
||||
e.preventDefault()
|
||||
newIndex = index + 1 < totalItems ? index + 1 : index
|
||||
break
|
||||
case 'ArrowLeft':
|
||||
e.preventDefault()
|
||||
newIndex = index - 1 >= 0 ? index - 1 : index
|
||||
break
|
||||
case 'ArrowDown':
|
||||
e.preventDefault()
|
||||
newIndex = index + GRID_COLUMNS < totalItems ? index + GRID_COLUMNS : index
|
||||
break
|
||||
case 'ArrowUp':
|
||||
e.preventDefault()
|
||||
newIndex = index - GRID_COLUMNS >= 0 ? index - GRID_COLUMNS : index
|
||||
break
|
||||
case 'Enter':
|
||||
case ' ':
|
||||
e.preventDefault()
|
||||
setHexInput(WORKFLOW_COLORS[index].color)
|
||||
return
|
||||
default:
|
||||
return
|
||||
}
|
||||
|
||||
if (newIndex !== index) {
|
||||
setFocusedIndex(newIndex)
|
||||
buttonRefs.current[newIndex]?.focus()
|
||||
}
|
||||
},
|
||||
[setHexInput]
|
||||
)
|
||||
|
||||
return (
|
||||
<div ref={gridRef} className='grid grid-cols-6 gap-[4px]' role='grid'>
|
||||
{WORKFLOW_COLORS.map(({ color, name }, index) => (
|
||||
<button
|
||||
key={color}
|
||||
ref={(el) => {
|
||||
buttonRefs.current[index] = el
|
||||
}}
|
||||
type='button'
|
||||
role='gridcell'
|
||||
title={name}
|
||||
tabIndex={focusedIndex === index ? 0 : -1}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setHexInput(color)
|
||||
}}
|
||||
onKeyDown={(e) => handleKeyDown(e, index)}
|
||||
onFocus={() => setFocusedIndex(index)}
|
||||
className={cn(
|
||||
'h-[20px] w-[20px] rounded-[4px] focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-1 focus:ring-offset-[#1b1b1b]',
|
||||
hexInput.toLowerCase() === color.toLowerCase() && 'ring-1 ring-white'
|
||||
)}
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a hex color string.
|
||||
* Accepts 3 or 6 character hex codes with or without #.
|
||||
@@ -349,25 +449,8 @@ export function ContextMenu({
|
||||
className={disableColorChange ? 'pointer-events-none opacity-50' : ''}
|
||||
>
|
||||
<div className='flex w-[140px] flex-col gap-[8px] p-[2px]'>
|
||||
{/* Preset colors */}
|
||||
<div className='grid grid-cols-6 gap-[4px]'>
|
||||
{WORKFLOW_COLORS.map(({ color, name }) => (
|
||||
<button
|
||||
key={color}
|
||||
type='button'
|
||||
title={name}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setHexInput(color)
|
||||
}}
|
||||
className={cn(
|
||||
'h-[20px] w-[20px] rounded-[4px]',
|
||||
hexInput.toLowerCase() === color.toLowerCase() && 'ring-1 ring-white'
|
||||
)}
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{/* Preset colors with keyboard navigation */}
|
||||
<ColorGrid hexInput={hexInput} setHexInput={setHexInput} />
|
||||
|
||||
{/* Hex input */}
|
||||
<div className='flex items-center gap-[4px]'>
|
||||
|
||||
@@ -97,6 +97,15 @@ export function DeleteModal({
|
||||
return 'Are you sure you want to delete this folder? This will permanently remove all associated workflows, logs, and knowledge bases.'
|
||||
}
|
||||
|
||||
if (isSingle && displayNames.length > 0) {
|
||||
return (
|
||||
<>
|
||||
Are you sure you want to delete{' '}
|
||||
<span className='font-medium text-[var(--text-primary)]'>{displayNames[0]}</span>? This
|
||||
will permanently remove all associated workflows, folders, logs, and knowledge bases.
|
||||
</>
|
||||
)
|
||||
}
|
||||
return 'Are you sure you want to delete this workspace? This will permanently remove all associated workflows, folders, logs, and knowledge bases.'
|
||||
}
|
||||
|
||||
|
||||
@@ -52,8 +52,8 @@ export function WorkflowItem({
|
||||
}: WorkflowItemProps) {
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
const { selectedWorkflows } = useFolderStore()
|
||||
const { updateWorkflow, workflows } = useWorkflowRegistry()
|
||||
const selectedWorkflows = useFolderStore((state) => state.selectedWorkflows)
|
||||
const updateWorkflow = useWorkflowRegistry((state) => state.updateWorkflow)
|
||||
const userPermissions = useUserPermissionsContext()
|
||||
const isSelected = selectedWorkflows.has(workflow.id)
|
||||
|
||||
@@ -141,6 +141,7 @@ export function WorkflowItem({
|
||||
const workflowIds =
|
||||
finalIsSelected && finalSelection.size > 1 ? Array.from(finalSelection) : [workflow.id]
|
||||
|
||||
const { workflows } = useWorkflowRegistry.getState()
|
||||
const workflowNames = workflowIds
|
||||
.map((id) => workflows[id]?.name)
|
||||
.filter((name): name is string => !!name)
|
||||
@@ -151,7 +152,7 @@ export function WorkflowItem({
|
||||
}
|
||||
|
||||
setCanDeleteCaptured(canDeleteWorkflows(workflowIds))
|
||||
}, [workflow.id, workflows, canDeleteWorkflows])
|
||||
}, [workflow.id, canDeleteWorkflows])
|
||||
|
||||
/**
|
||||
* Handle right-click - ensure proper selection behavior and capture selection state
|
||||
|
||||
@@ -709,7 +709,7 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='default'
|
||||
variant='tertiary'
|
||||
disabled={isSaving || isSubmitting}
|
||||
onClick={handleSaveChanges}
|
||||
className='h-[32px] gap-[8px] px-[12px] font-medium'
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback } from 'react'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { getNextWorkflowColor } from '@/lib/workflows/colors'
|
||||
import { useCreateWorkflow, useWorkflows } from '@/hooks/queries/workflows'
|
||||
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
|
||||
@@ -15,19 +16,19 @@ interface UseWorkflowOperationsProps {
|
||||
|
||||
export function useWorkflowOperations({ workspaceId }: UseWorkflowOperationsProps) {
|
||||
const router = useRouter()
|
||||
const { workflows } = useWorkflowRegistry()
|
||||
const workflows = useWorkflowRegistry(useShallow((state) => state.workflows))
|
||||
const workflowsQuery = useWorkflows(workspaceId)
|
||||
const createWorkflowMutation = useCreateWorkflow()
|
||||
|
||||
/**
|
||||
* Filter and sort workflows for the current workspace
|
||||
*/
|
||||
const regularWorkflows = Object.values(workflows)
|
||||
.filter((workflow) => workflow.workspaceId === workspaceId)
|
||||
.sort((a, b) => {
|
||||
// Sort by creation date (newest first) for stable ordering
|
||||
return b.createdAt.getTime() - a.createdAt.getTime()
|
||||
})
|
||||
const regularWorkflows = useMemo(
|
||||
() =>
|
||||
Object.values(workflows)
|
||||
.filter((workflow) => workflow.workspaceId === workspaceId)
|
||||
.sort((a, b) => {
|
||||
return b.createdAt.getTime() - a.createdAt.getTime()
|
||||
}),
|
||||
[workflows, workspaceId]
|
||||
)
|
||||
|
||||
const handleCreateWorkflow = useCallback(async (): Promise<string | null> => {
|
||||
try {
|
||||
@@ -55,13 +56,11 @@ export function useWorkflowOperations({ workspaceId }: UseWorkflowOperationsProp
|
||||
}, [createWorkflowMutation, workspaceId, router])
|
||||
|
||||
return {
|
||||
// State
|
||||
workflows,
|
||||
regularWorkflows,
|
||||
workflowsLoading: workflowsQuery.isLoading,
|
||||
isCreatingWorkflow: createWorkflowMutation.isPending,
|
||||
|
||||
// Operations
|
||||
handleCreateWorkflow,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ export function useWorkspaceManagement({
|
||||
}: UseWorkspaceManagementProps) {
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const { switchToWorkspace } = useWorkflowRegistry()
|
||||
const switchToWorkspace = useWorkflowRegistry((state) => state.switchToWorkspace)
|
||||
|
||||
// Workspace management state
|
||||
const [workspaces, setWorkspaces] = useState<Workspace[]>([])
|
||||
@@ -95,10 +95,6 @@ export function useWorkspaceManagement({
|
||||
}
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Fetch workspaces for the current user with full validation and URL handling
|
||||
* Uses refs for workspaceId and router to avoid unnecessary recreations
|
||||
*/
|
||||
const fetchWorkspaces = useCallback(async () => {
|
||||
setIsWorkspacesLoading(true)
|
||||
try {
|
||||
@@ -181,10 +177,6 @@ export function useWorkspaceManagement({
|
||||
[]
|
||||
)
|
||||
|
||||
/**
|
||||
* Switch to a different workspace
|
||||
* Uses refs for activeWorkspace and router to avoid unnecessary recreations
|
||||
*/
|
||||
const switchWorkspace = useCallback(
|
||||
async (workspace: Workspace) => {
|
||||
// If already on this workspace, return
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { Database, HelpCircle, Layout, Plus, Search, Settings } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
@@ -60,7 +60,7 @@ export const SIDEBAR_SCROLL_EVENT = 'sidebar-scroll-to-item'
|
||||
*
|
||||
* @returns Sidebar with workflows panel
|
||||
*/
|
||||
export function Sidebar() {
|
||||
export const Sidebar = memo(function Sidebar() {
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
const workflowId = params.workflowId as string | undefined
|
||||
@@ -142,11 +142,9 @@ export function Sidebar() {
|
||||
window.removeEventListener(SIDEBAR_SCROLL_EVENT, handleScrollToItem as EventListener)
|
||||
}, [])
|
||||
|
||||
const {
|
||||
isOpen: isSearchModalOpen,
|
||||
setOpen: setIsSearchModalOpen,
|
||||
open: openSearchModal,
|
||||
} = useSearchModalStore()
|
||||
const isSearchModalOpen = useSearchModalStore((state) => state.isOpen)
|
||||
const setIsSearchModalOpen = useSearchModalStore((state) => state.setOpen)
|
||||
const openSearchModal = useSearchModalStore((state) => state.open)
|
||||
|
||||
const {
|
||||
workspaces,
|
||||
@@ -176,7 +174,6 @@ export function Sidebar() {
|
||||
workspaceId,
|
||||
})
|
||||
|
||||
/** Context menu state for navigation items */
|
||||
const [activeNavItemHref, setActiveNavItemHref] = useState<string | null>(null)
|
||||
const {
|
||||
isOpen: isNavContextMenuOpen,
|
||||
@@ -285,7 +282,6 @@ export function Sidebar() {
|
||||
const isLoading = workflowsLoading || sessionLoading
|
||||
const initialScrollDoneRef = useRef(false)
|
||||
|
||||
/** Scrolls to active workflow on initial page load only */
|
||||
useEffect(() => {
|
||||
if (!workflowId || workflowsLoading || initialScrollDoneRef.current) return
|
||||
initialScrollDoneRef.current = true
|
||||
@@ -296,7 +292,6 @@ export function Sidebar() {
|
||||
})
|
||||
}, [workflowId, workflowsLoading])
|
||||
|
||||
/** Forces sidebar to minimum width and ensures it's expanded when not on a workflow page */
|
||||
useEffect(() => {
|
||||
if (!isOnWorkflowPage) {
|
||||
if (isCollapsed) {
|
||||
@@ -306,7 +301,6 @@ export function Sidebar() {
|
||||
}
|
||||
}, [isOnWorkflowPage, isCollapsed, setIsCollapsed, setSidebarWidth])
|
||||
|
||||
/** Creates a workflow and scrolls to it */
|
||||
const handleCreateWorkflow = useCallback(async () => {
|
||||
const workflowId = await createWorkflow()
|
||||
if (workflowId) {
|
||||
@@ -316,7 +310,6 @@ export function Sidebar() {
|
||||
}
|
||||
}, [createWorkflow])
|
||||
|
||||
/** Creates a folder and scrolls to it */
|
||||
const handleCreateFolder = useCallback(async () => {
|
||||
const folderId = await createFolder()
|
||||
if (folderId) {
|
||||
@@ -324,12 +317,10 @@ export function Sidebar() {
|
||||
}
|
||||
}, [createFolder])
|
||||
|
||||
/** Triggers file input for workflow import */
|
||||
const handleImportWorkflow = useCallback(() => {
|
||||
fileInputRef.current?.click()
|
||||
}, [])
|
||||
|
||||
/** Handles workspace switch from popover menu */
|
||||
const handleWorkspaceSwitch = useCallback(
|
||||
async (workspace: { id: string; name: string; ownerId: string; role?: string }) => {
|
||||
if (workspace.id === workspaceId) {
|
||||
@@ -342,12 +333,10 @@ export function Sidebar() {
|
||||
[workspaceId, switchWorkspace]
|
||||
)
|
||||
|
||||
/** Toggles sidebar collapse state */
|
||||
const handleToggleCollapse = useCallback(() => {
|
||||
setIsCollapsed(!isCollapsed)
|
||||
}, [isCollapsed, setIsCollapsed])
|
||||
|
||||
/** Reverts to active workflow selection when clicking sidebar background */
|
||||
const handleSidebarClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLElement>) => {
|
||||
const target = e.target as HTMLElement
|
||||
@@ -360,7 +349,6 @@ export function Sidebar() {
|
||||
[workflowId]
|
||||
)
|
||||
|
||||
/** Renames a workspace */
|
||||
const handleRenameWorkspace = useCallback(
|
||||
async (workspaceIdToRename: string, newName: string) => {
|
||||
await updateWorkspaceName(workspaceIdToRename, newName)
|
||||
@@ -368,7 +356,6 @@ export function Sidebar() {
|
||||
[updateWorkspaceName]
|
||||
)
|
||||
|
||||
/** Deletes a workspace */
|
||||
const handleDeleteWorkspace = useCallback(
|
||||
async (workspaceIdToDelete: string) => {
|
||||
const workspaceToDelete = workspaces.find((w) => w.id === workspaceIdToDelete)
|
||||
@@ -379,7 +366,6 @@ export function Sidebar() {
|
||||
[workspaces, confirmDeleteWorkspace]
|
||||
)
|
||||
|
||||
/** Leaves a workspace */
|
||||
const handleLeaveWorkspaceWrapper = useCallback(
|
||||
async (workspaceIdToLeave: string) => {
|
||||
const workspaceToLeave = workspaces.find((w) => w.id === workspaceIdToLeave)
|
||||
@@ -390,7 +376,6 @@ export function Sidebar() {
|
||||
[workspaces, handleLeaveWorkspace]
|
||||
)
|
||||
|
||||
/** Duplicates a workspace */
|
||||
const handleDuplicateWorkspace = useCallback(
|
||||
async (_workspaceIdToDuplicate: string, workspaceName: string) => {
|
||||
await duplicateWorkspace(workspaceName)
|
||||
@@ -398,7 +383,6 @@ export function Sidebar() {
|
||||
[duplicateWorkspace]
|
||||
)
|
||||
|
||||
/** Exports a workspace */
|
||||
const handleExportWorkspace = useCallback(
|
||||
async (workspaceIdToExport: string, workspaceName: string) => {
|
||||
await exportWorkspace(workspaceIdToExport, workspaceName)
|
||||
@@ -406,12 +390,10 @@ export function Sidebar() {
|
||||
[exportWorkspace]
|
||||
)
|
||||
|
||||
/** Triggers file input for workspace import */
|
||||
const handleImportWorkspace = useCallback(() => {
|
||||
workspaceFileInputRef.current?.click()
|
||||
}, [])
|
||||
|
||||
/** Handles workspace import file selection */
|
||||
const handleWorkspaceFileChange = useCallback(
|
||||
async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = event.target.files
|
||||
@@ -427,7 +409,6 @@ export function Sidebar() {
|
||||
[importWorkspace]
|
||||
)
|
||||
|
||||
/** Resolves workspace ID from params or URL path */
|
||||
const resolveWorkspaceIdFromPath = useCallback((): string | undefined => {
|
||||
if (workspaceId) return workspaceId
|
||||
if (typeof window === 'undefined') return undefined
|
||||
@@ -439,7 +420,6 @@ export function Sidebar() {
|
||||
return parts[idx + 1]
|
||||
}, [workspaceId])
|
||||
|
||||
/** Registers global sidebar commands with the central commands registry */
|
||||
useRegisterGlobalCommands(() =>
|
||||
createCommands([
|
||||
{
|
||||
@@ -772,4 +752,4 @@ export function Sidebar() {
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user