Compare commits

...

18 Commits

Author SHA1 Message Date
Waleed
04c9057229 fix(kb): disable connectors after repeated sync failures (#4046)
* fix(kb): improve error logging when connector token resolution fails

The generic "Failed to obtain access token" error hid the actual root cause.
Now logs credentialId, userId, authMode, and provider to help diagnose
token refresh failures in trigger.dev.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(kb): disable connectors after 10 consecutive sync failures

Connectors that fail 10 times in a row are set to 'disabled' status,
stopping the cron from scheduling further syncs. The UI shows an alert
triangle with a reconnect banner. Users can re-enable via the play
button or by reconnecting their account, which resets failures.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(kb): disable sync button for disabled connectors, use amber badge variant

Sync button should be disabled when connector is in disabled state to
guide users toward reconnecting first. Badge variant changed from red
to amber to match the warning banner styling.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(kb): address PR review comments for disabled connector feature

- Use `=== undefined` instead of falsy check for nextSyncAt to preserve
  explicit null (manual sync only) when syncIntervalMinutes is 0
- Gate Reconnect button on serviceId/providerId so it only renders for
  OAuth connectors; show appropriate copy for API key connectors

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(kb): move resolveAccessToken inside try/catch for circuit-breaker coverage

Token resolution failures (e.g. revoked OAuth tokens) were thrown before
the try/catch block, bypassing consecutiveFailures tracking entirely.
Also removes dead `if (refreshed)` guards at mid-sync refresh sites since
resolveAccessToken now always returns a string or throws.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(kb): remove dead interval branch when re-enabling connector

When `updates.nextSyncAt === undefined`, syncIntervalMinutes was not in
the request, so `parsed.data.syncIntervalMinutes` is always undefined.
Simplify to just schedule an immediate sync — the sync engine sets the
proper nextSyncAt based on the connector's DB interval after completion.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 10:41:06 -07:00
Vikhyath Mondreti
650487c3c9 fix(kb): doc selector (#4048) 2026-04-08 10:28:14 -07:00
Vikhyath Mondreti
efb582e96a feat(voice): voice input migration to eleven labs (#4041)
* feat(speech): unified voice interface

* add metering for voice input usage

* ip key

* use shared getclientip helper, fix deployed chat

* cleanup code

* prep merge

* merge staging in

* add billing check

* add voice input section

* remove skip billing

* address comments
2026-04-08 01:01:51 -07:00
Waleed
3c7bfa797a improvement(kb): deferred content fetching and metadata-based hashes for connectors (#4044)
* improvement(kb): deferred content fetching and metadata-based hashes for connectors

* fix(kb): remove message count from outlook contentHash to prevent list/get divergence

* fix(kb): increase outlook getDocument message limit from 50 to 250

* fix(kb): skip outlook messages without conversationId to prevent broken stubs

* fix(kb): scope outlook getDocument to same folder as listDocuments to prevent hash divergence

* fix(kb): add missing connector sync cron job to Helm values

The connector sync endpoint existed but had no cron job configured to trigger it,
meaning scheduled syncs would never fire.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 00:59:54 -07:00
Waleed
d0d35dd406 fix: address PR review comments (#4042)
* fix: address PR review comments on staging release

- Add try/catch around clipboard.writeText() in CopyCodeButton
- Add missing folder and past_chat cases in resolveResourceFromContext
- Return 400 for ZodError instead of 500 in all 8 Athena API routes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(api): return 400 for Zod validation errors across 27 API routes

Routes using z.parse() were returning 500 for ZodError (client input
validation failures). Added instanceof z.ZodError check to return 400
before the generic 500 handler, matching the established pattern used
by 115+ other routes.

Affected services: CloudWatch (7), CloudFormation (7), DynamoDB (6),
Slack (3), Outlook (2), OneDrive (1), Google Drive (1).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(api): add success:false to ZodError responses for consistency

7 routes used { success: false, error: ... } in their generic error
handler but our ZodError handler only returned { error: ... }. Aligned
the ZodError response shape to match.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 00:26:33 -07:00
Waleed
9282d1bf54 feat(secrets): allow admins to view and edit workspace secret values (#4040)
* feat(secrets): allow admins to view and edit workspace secret values

* fix(secrets): cross-browser masking and grid layout for non-admin users
2026-04-07 23:40:06 -07:00
Waleed
7b81a760ea fix(kb): show 'pending' instead of past date for overdue next sync (#4039) 2026-04-07 22:35:34 -07:00
Vikhyath Mondreti
a591d7c227 fix(manual): mock payloads nested recursion (#4037) 2026-04-07 21:05:45 -07:00
Waleed
086b7d9ca1 refactor(polling): consolidate polling services into provider handler pattern (#4035)
* refactor(polling): consolidate polling services into provider handler pattern

Eliminate self-POST anti-pattern and extract shared boilerplate from 4 polling
services into a clean handler registry mirroring lib/webhooks/providers/.

- Add processPolledWebhookEvent() to processor.ts for direct in-process webhook
  execution, removing HTTP round-trips that caused Lambda 403/timeout errors
- Extract shared utilities (markWebhookFailed/Success, fetchActiveWebhooks,
  runWithConcurrency, resolveOAuthCredential, updateWebhookProviderConfig)
- Create PollingProviderHandler interface with per-provider implementations
- Consolidate 4 identical route files into single dynamic [provider] route
- Standardize concurrency to 10 across all providers
- No infra changes needed — Helm cron paths resolve via dynamic route

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* polish(polling): extract lock TTL constant and remove unnecessary type casts

- Widen processPolledWebhookEvent body param to accept object, eliminating
  `as unknown as Record<string, unknown>` double casts in all 4 handlers
- Extract LOCK_TTL_SECONDS constant in route, tying maxDuration and lock TTL
  to a single value

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(polling): address PR review feedback

- Add archivedAt filters to fetchActiveWebhooks query, matching
  findWebhookAndWorkflow in processor.ts to prevent polling archived
  webhooks/workflows
- Move provider validation after auth check to prevent provider
  enumeration by unauthenticated callers
- Fix inconsistent pollingIdempotency import path in outlook.ts to
  match other handlers

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(polling): use literal for maxDuration segment config

Next.js requires segment config exports to be statically analyzable
literals. Using a variable reference caused build failure.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 20:55:20 -07:00
Vikhyath Mondreti
2760b4bff1 Revert "fix(sockets): joining currently deleted workflow (#4004)" (#4036)
This reverts commit 609ba619bc.
2026-04-07 20:43:52 -07:00
Theodore Li
6f9f336f16 feat(ui): Add copy button for code blocks in mothership (#4033)
* Add copy button for code blocks in mothership

* Move to shared copy code button

* Handle react node case for copy

* fix(copy-button): address PR review feedback

- Await clipboard write and clear timeout on unmount in CopyCodeButton
- Fix hover bg color matching container bg (surface-4 -> surface-5)
- Extract extractTextContent to shared util at lib/core/utils/react-node-text.ts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Fix lint

---------

Co-authored-by: Theodore Li <theo@sim.ai>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 23:33:04 -04:00
Theodore Li
712e58a7b5 fix(admin): delete workspaces on ban (#4029)
* fix(admin): delete workspaces on ban

* Fix lint

* Wait until workspace deletion to return ban success

---------

Co-authored-by: Theodore Li <theo@sim.ai>
2026-04-07 23:21:43 -04:00
Waleed
2504bfbaf8 feat(athena): add AWS Athena integration (#4034)
* feat(athena): add AWS Athena integration

* fix(athena): address PR review comments

- Fix variable shadowing: rename inner `data` to `rowData` in row mapper
- Fix first-page maxResults off-by-one: request maxResults+1 to compensate for header row
- Add missing runtime guard for queryString in create_named_query
- Move athena registry entries to correct alphabetical position

* fix(athena): alphabetize registry keys and add type re-exports

- Reorder athena_* registry keys to strict alphabetical order
- Add type re-exports from index.ts barrel

* fix(athena): cap maxResults at 999 to prevent overflow with header row adjustment

The +1 adjustment for the header row on first-page requests could
produce MaxResults=1001 when user requests 1000, exceeding the AWS
API hard cap of 1000.
2026-04-07 20:20:53 -07:00
Waleed
6c3caf61e1 feat(chat): drag workflows and folders from sidebar into chat input (#4028)
* feat(chat): drag workflows and folders from sidebar into chat input

* fix(chat): fix effectAllowed, stale atInsertPosRef, and drag-enter overlay for resource drags

* feat(chat): add task dragging and visible drag ghost for sidebar items

* feat(sidebar): add drag ghost with icons and task icon to context chips

* refactor(types): narrow ChatMessageContext.kind to ChatContextKind union and add workflowBorderColor utility

* feat(user-input): support Tab to select resource in mention dropdown

* fix(user-input): narrow ChatContext discriminated union before accessing workflowId

* fix(colors): overload workflowBorderColor to accept string | undefined

* fix(colors): simplify workflowBorderColor to single string | undefined signature

* fix(chat): remove resource panel tab when context mention is deleted from input

* fix(chat): use resource ID for context removal identity check

* fix(chat): add folder/task cases to resource resolver, task key to existingResourceKeys, and use workflowBorderColor in drag ghost

* revert(chat): remove folder/task from resolveResourceFromContext — no panel UI for these types

* fix(chat): add chatId to stored context types and workflow.color to drag callback deps

* fix(chat): guard chatId before adding task key to existingResourceKeys
2026-04-07 20:06:21 -07:00
Waleed
98be968b54 improvement(secrets): parallelize save mutations and add admin visibility for workspace secrets (#4032)
* improvement(secrets): parallelize save mutations and add admin visibility for workspace secrets

* fix(secrets): sequence workspace upsert/delete to avoid read-modify-write race

* fix(secrets): use Promise.allSettled to ensure credential invalidation after all mutations settle
2026-04-07 18:30:26 -07:00
Waleed
e0f5cf880a feat(slack): add subtype field and signature verification to Slack trigger (#4030)
* feat(slack): add subtype field and signature verification to Slack trigger

* fix(slack): guard against NaN timestamp and align null/empty-string convention
2026-04-07 18:13:26 -07:00
Theodore Li
0f602f79a4 fix(login): fix captcha headers for manual login (#4025)
* fix(signup): fix turnstile key loading

* fix(login): fix captcha header passing

* Catch user already exists, remove login form captcha

---------

Co-authored-by: Theodore Li <theo@sim.ai>
2026-04-07 18:32:33 -04:00
Theodore Li
d0d3581605 feat(posthog): Add tracking on mothership abort (#4023)
Co-authored-by: Theodore Li <theo@sim.ai>
2026-04-07 18:30:46 -04:00
170 changed files with 21845 additions and 3389 deletions

View File

@@ -4687,6 +4687,33 @@ export function CloudFormationIcon(props: SVGProps<SVGSVGElement>) {
)
}
export function AthenaIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
{...props}
viewBox='0 0 80 80'
version='1.1'
xmlns='http://www.w3.org/2000/svg'
xmlnsXlink='http://www.w3.org/1999/xlink'
>
<g
id='Icon-Architecture/64/Arch_Amazon-Athena_64'
stroke='none'
strokeWidth='1'
fill='none'
fillRule='evenodd'
transform='translate(40, 40) scale(1.25) translate(-40, -40)'
>
<path
d='M38.29505,27.2267312 C42.787319,27.2267312 45.2478437,28.2331825 45.6964751,28.7379193 C45.2478437,29.2426562 42.787319,30.2491074 38.29505,30.2491074 C33.8027811,30.2491074 31.3422564,29.2426562 30.893625,28.7379193 C31.3422564,28.2331825 33.8027811,27.2267312 38.29505,27.2267312 L38.29505,27.2267312 Z M37.7838882,35.2823712 C37.6191254,35.1977447 37.5029973,35.0294991 37.5029973,34.8300223 C37.5029973,34.5499487 37.7292981,34.3212556 38.0062188,34.3212556 C38.0866151,34.3212556 38.1600636,34.3444272 38.2285494,34.3796882 L37.7838882,35.2823712 Z M43.5674612,43.5908834 C43.4930201,43.6513309 43.322302,43.7681961 42.9709403,43.9092403 C42.6582879,44.0341652 42.2880677,44.1470006 41.8682202,44.2457316 C40.7525971,44.5076708 39.3808968,44.6517374 38.0052262,44.6517374 C34.9968155,44.6517374 32.9005556,44.0019265 32.4489466,43.5989431 L31.1159556,31.150783 C33.1596104,31.9869737 36.1700063,32.2640249 38.29505,32.2640249 C40.3843621,32.2640249 43.3292498,31.9950334 45.3719121,31.1910813 L44.5748967,36.6656121 C43.0731726,36.0994203 41.1992434,35.2773339 39.4235763,34.4129344 C39.2429327,33.786295 38.6801584,33.3248789 38.0062188,33.3248789 C37.1883598,33.3248789 36.5233532,34.0008837 36.5233532,34.8300223 C36.5233532,35.6611757 37.1883598,36.3361731 38.0062188,36.3361731 C38.1997655,36.3361731 38.3843793,36.2958747 38.5531123,36.2273675 C41.0344805,37.4524373 42.8835961,38.2382552 44.2751474,38.7228428 L43.5674612,43.5908834 Z M28.8718062,28.8467249 L30.4787403,43.8498003 C30.5918907,46.6344162 37.6995217,46.6666549 38.0052262,46.6666549 C39.5268012,46.6666549 41.0573091,46.5034466 42.3148665,46.2092686 C42.8299985,46.0883736 43.2964958,45.9453144 43.7004625,45.7831136 C44.8736534,45.3116229 45.4890327,44.6688642 45.5317122,43.8739793 L46.2006891,39.2759376 C46.6562683,39.3696313 47.0284735,39.4109371 47.3252452,39.4109371 C48.2592321,39.4109371 48.5053839,39.0281028 48.6751094,38.7641486 C48.853768,38.48609 48.9053804,38.1445615 48.8220064,37.8010181 C48.6314374,37.0111704 47.5168068,35.971473 46.7723963,35.3539008 L47.7133311,28.8850083 L47.7043982,28.8840008 C47.7083684,28.8346354 47.7242492,28.7882923 47.7242492,28.7379193 C47.7242492,25.9543109 41.7967568,25.2118138 38.29505,25.2118138 C34.7933433,25.2118138 28.8658509,25.9543109 28.8658509,28.7379193 C28.8658509,28.7751953 28.8787541,28.8084414 28.8807391,28.8457174 L28.8718062,28.8467249 Z M37.8355007,20.0596698 C46.4865427,20.0596698 53.5246954,27.2035597 53.5246954,35.98457 C53.5246954,44.7655803 46.4865427,51.9094701 37.8355007,51.9094701 C29.1834661,51.9094701 22.1453133,44.7655803 22.1453133,35.98457 C22.1453133,27.2035597 29.1834661,20.0596698 37.8355007,20.0596698 L37.8355007,20.0596698 Z M12.9850945,41.8348828 L12.9850945,43.8498003 L21.91802,43.8498003 L21.91802,43.7309201 C24.7735785,49.7494786 30.8261318,53.9243876 37.8355007,53.9243876 C47.5803298,53.9243876 55.50979,45.8768072 55.50979,35.98457 C55.50979,26.0923327 47.5803298,18.0447524 37.8355007,18.0447524 C30.253432,18.0447524 23.7909567,22.9248825 21.2857674,29.7453781 L12.9850945,29.7453781 L12.9850945,31.7602955 L20.6763434,31.7602955 C20.3666686,33.0568949 20.1850325,34.4018523 20.1701443,35.7901304 L11,35.7901304 L11,37.8050479 L20.2515331,37.8050479 C20.3914823,39.2044081 20.7061198,40.548358 21.1448257,41.8348828 L12.9850945,41.8348828 Z M67.0799136,66.035049 C65.8789314,67.2560889 63.7965672,67.2631412 62.5965775,66.046131 L51.9326496,55.220987 C53.6487638,53.9223727 55.1802643,52.3900279 56.4934043,50.6763406 L67.0918241,61.4853653 C67.688345,62.0918555 68.0168782,62.8998374 68.014902,63.7591997 C68.0139005,64.6205769 67.6823898,65.4275513 67.0799136,66.035049 L67.0799136,66.035049 Z M68.4972711,60.0628336 L57.6616325,49.0100039 C60.0635969,45.2562127 61.4650736,40.7851108 61.4650736,35.98457 C61.4650736,22.7586518 50.8646687,12 37.8355007,12 C28.4728022,12 19.9825528,17.6196048 16.2039254,26.316996 L18.0202869,27.1290077 C21.4812992,19.1630316 29.2588997,14.0149175 37.8355007,14.0149175 C49.7708816,14.0149175 59.4799791,23.8698788 59.4799791,35.98457 C59.4799791,48.0982537 49.7708816,57.9542225 37.8355007,57.9542225 C29.8623684,57.9542225 22.5572205,53.5244265 18.7686675,46.3936336 L17.0217843,47.3507194 C21.1557437,55.1343455 29.1318536,59.9691399 37.8355007,59.9691399 C42.3912926,59.9691399 46.6483279,58.6503765 50.2602074,56.3735197 L61.1941082,67.4716851 C62.1648195,68.4569797 63.4561235,69 64.8278238,69 C66.2074645,69 67.5067089,68.4529499 68.4813903,67.462618 C69.4580568,66.4773233 69.9980025,65.1635972 70,63.7622221 C70.0029653,62.3628619 69.4679823,61.0491357 68.4972711,60.0628336 L68.4972711,60.0628336 Z'
id='Amazon-Athena_Icon_64_Squid'
fill='currentColor'
/>
</g>
</svg>
)
}
export function CloudWatchIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg

View File

@@ -16,6 +16,7 @@ import {
ArxivIcon,
AsanaIcon,
AshbyIcon,
AthenaIcon,
AttioIcon,
AzureIcon,
BoxCompanyIcon,
@@ -205,6 +206,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
arxiv: ArxivIcon,
asana: AsanaIcon,
ashby: AshbyIcon,
athena: AthenaIcon,
attio: AttioIcon,
box: BoxCompanyIcon,
brandfetch: BrandfetchIcon,

View File

@@ -135,6 +135,21 @@ Use your own API keys for AI model providers instead of Sim's hosted keys to pay
When configured, workflows use your key instead of Sim's hosted keys. If removed, workflows automatically fall back to hosted keys with the multiplier.
## Voice Input
Voice input uses ElevenLabs Scribe v2 Realtime for speech-to-text transcription. It is available in the Mothership chat and in deployed chat voice mode.
| Context | Cost per session | Max duration |
|---------|-----------------|--------------|
| Mothership (workspace) | ~5 credits ($0.024) | 3 minutes |
| Deployed chat (voice mode) | ~2 credits ($0.008) | 1 minute |
Each voice session is billed when it starts. In deployed chat voice mode, each conversation turn (speak → agent responds → speak again) is a separate session. Multi-turn conversations are billed per turn.
<Callout type="info">
Voice input requires `ELEVENLABS_API_KEY` to be configured. When the key is not set, voice input controls are hidden.
</Callout>
## Plans
Sim has two paid plan tiers — **Pro** and **Max**. Either can be used individually or with a team. Team plans pool credits across all seats in the organization.

View File

@@ -0,0 +1,238 @@
---
title: Athena
description: Run SQL queries on data in Amazon S3 using AWS Athena
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="athena"
color="linear-gradient(45deg, #4D27A8 0%, #A166FF 100%)"
/>
{/* MANUAL-CONTENT-START:intro */}
[Amazon Athena](https://aws.amazon.com/athena/) is an interactive query service from AWS that makes it easy to analyze data directly in Amazon S3 using standard SQL. Athena is serverless, so there is no infrastructure to manage, and you pay only for the queries you run.
With Athena, you can:
- **Query data in S3**: Run SQL queries directly against data stored in Amazon S3 without loading it into a database
- **Support multiple formats**: Query CSV, JSON, Parquet, ORC, Avro, and other common data formats
- **Integrate with AWS Glue**: Use the AWS Glue Data Catalog to manage table metadata and schemas
- **Scale automatically**: Handle queries of any size without provisioning servers or clusters
- **Save and reuse queries**: Create named queries for frequently used SQL statements
In Sim, the Athena integration enables your agents to run SQL queries against data in S3, check query execution status, retrieve results, and manage saved queries — all within your agent workflows. Supported operations include:
- **Start Query**: Execute SQL queries against your S3 data
- **Get Query Execution**: Check the status and details of a running or completed query
- **Get Query Results**: Retrieve the results of a completed query
- **Stop Query**: Cancel a running query execution
- **List Query Executions**: View recent query execution IDs
- **Create Named Query**: Save a query for reuse
- **Get Named Query**: Retrieve details of a saved query
- **List Named Queries**: View all saved query IDs
This integration empowers Sim agents to automate data analysis tasks using AWS Athena, enabling workflows that query, monitor, and manage large-scale data in S3 without manual effort or infrastructure management.
{/* MANUAL-CONTENT-END */}
## Usage Instructions
Integrate AWS Athena into workflows. Execute SQL queries against data in S3, check query status, retrieve results, manage named queries, and list executions. Requires AWS access key and secret access key.
## Tools
### `athena_start_query`
Start an SQL query execution in AWS Athena
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) |
| `awsAccessKeyId` | string | Yes | AWS access key ID |
| `awsSecretAccessKey` | string | Yes | AWS secret access key |
| `queryString` | string | Yes | SQL query string to execute |
| `database` | string | No | Database name within the catalog |
| `catalog` | string | No | Data catalog name \(default: AwsDataCatalog\) |
| `outputLocation` | string | No | S3 output location for query results \(e.g., s3://bucket/path/\) |
| `workGroup` | string | No | Workgroup to execute the query in \(default: primary\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `queryExecutionId` | string | Unique ID of the started query execution |
### `athena_get_query_execution`
Get the status and details of an Athena query execution
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) |
| `awsAccessKeyId` | string | Yes | AWS access key ID |
| `awsSecretAccessKey` | string | Yes | AWS secret access key |
| `queryExecutionId` | string | Yes | Query execution ID to check |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `queryExecutionId` | string | Query execution ID |
| `query` | string | SQL query string |
| `state` | string | Query state \(QUEUED, RUNNING, SUCCEEDED, FAILED, CANCELLED\) |
| `stateChangeReason` | string | Reason for state change \(e.g., error message\) |
| `statementType` | string | Statement type \(DDL, DML, UTILITY\) |
| `database` | string | Database name |
| `catalog` | string | Data catalog name |
| `workGroup` | string | Workgroup name |
| `submissionDateTime` | number | Query submission time \(Unix epoch ms\) |
| `completionDateTime` | number | Query completion time \(Unix epoch ms\) |
| `dataScannedInBytes` | number | Amount of data scanned in bytes |
| `engineExecutionTimeInMillis` | number | Engine execution time in milliseconds |
| `queryPlanningTimeInMillis` | number | Query planning time in milliseconds |
| `queryQueueTimeInMillis` | number | Time the query spent in queue in milliseconds |
| `totalExecutionTimeInMillis` | number | Total execution time in milliseconds |
| `outputLocation` | string | S3 location of query results |
### `athena_get_query_results`
Retrieve the results of a completed Athena query execution
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) |
| `awsAccessKeyId` | string | Yes | AWS access key ID |
| `awsSecretAccessKey` | string | Yes | AWS secret access key |
| `queryExecutionId` | string | Yes | Query execution ID to get results for |
| `maxResults` | number | No | Maximum number of rows to return \(1-1000\) |
| `nextToken` | string | No | Pagination token from a previous request |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `columns` | array | Column metadata \(name and type\) |
| `rows` | array | Result rows as key-value objects |
| `nextToken` | string | Pagination token for next page of results |
| `updateCount` | number | Number of rows affected \(for INSERT/UPDATE statements\) |
### `athena_stop_query`
Stop a running Athena query execution
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) |
| `awsAccessKeyId` | string | Yes | AWS access key ID |
| `awsSecretAccessKey` | string | Yes | AWS secret access key |
| `queryExecutionId` | string | Yes | Query execution ID to stop |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `success` | boolean | Whether the query was successfully stopped |
### `athena_list_query_executions`
List recent Athena query execution IDs
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) |
| `awsAccessKeyId` | string | Yes | AWS access key ID |
| `awsSecretAccessKey` | string | Yes | AWS secret access key |
| `workGroup` | string | No | Workgroup to list executions for \(default: primary\) |
| `maxResults` | number | No | Maximum number of results \(0-50\) |
| `nextToken` | string | No | Pagination token from a previous request |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `queryExecutionIds` | array | List of query execution IDs |
| `nextToken` | string | Pagination token for next page |
### `athena_create_named_query`
Create a saved/named query in AWS Athena
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) |
| `awsAccessKeyId` | string | Yes | AWS access key ID |
| `awsSecretAccessKey` | string | Yes | AWS secret access key |
| `name` | string | Yes | Name for the saved query |
| `database` | string | Yes | Database the query runs against |
| `queryString` | string | Yes | SQL query string to save |
| `description` | string | No | Description of the named query |
| `workGroup` | string | No | Workgroup to create the named query in |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `namedQueryId` | string | ID of the created named query |
### `athena_get_named_query`
Get details of a saved/named query in AWS Athena
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) |
| `awsAccessKeyId` | string | Yes | AWS access key ID |
| `awsSecretAccessKey` | string | Yes | AWS secret access key |
| `namedQueryId` | string | Yes | Named query ID to retrieve |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `namedQueryId` | string | Named query ID |
| `name` | string | Name of the saved query |
| `description` | string | Query description |
| `database` | string | Database the query runs against |
| `queryString` | string | SQL query string |
| `workGroup` | string | Workgroup name |
### `athena_list_named_queries`
List saved/named query IDs in AWS Athena
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) |
| `awsAccessKeyId` | string | Yes | AWS access key ID |
| `awsSecretAccessKey` | string | Yes | AWS secret access key |
| `workGroup` | string | No | Workgroup to list named queries for |
| `maxResults` | number | No | Maximum number of results \(0-50\) |
| `nextToken` | string | No | Pagination token from a previous request |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `namedQueryIds` | array | List of named query IDs |
| `nextToken` | string | Pagination token for next page |

View File

@@ -13,6 +13,7 @@
"arxiv",
"asana",
"ashby",
"athena",
"attio",
"box",
"brandfetch",

View File

@@ -270,10 +270,8 @@ function SignupFormContent({
name: sanitizedName,
},
{
fetchOptions: {
headers: {
...(token ? { 'x-captcha-response': token } : {}),
},
headers: {
...(token ? { 'x-captcha-response': token } : {}),
},
onError: (ctx) => {
logger.error('Signup error:', ctx.error)
@@ -282,10 +280,7 @@ function SignupFormContent({
let errorCode = 'unknown'
if (ctx.error.code?.includes('USER_ALREADY_EXISTS')) {
errorCode = 'user_already_exists'
errorMessage.push(
'An account with this email already exists. Please sign in instead.'
)
setEmailError(errorMessage[0])
setEmailError('An account with this email already exists. Please sign in instead.')
} else if (
ctx.error.code?.includes('BAD_REQUEST') ||
ctx.error.message?.includes('Email and password sign up is not enabled')

View File

@@ -18,6 +18,7 @@ import {
xAIIcon,
} from '@/components/icons'
import { cn } from '@/lib/core/utils/cn'
import { workflowBorderColor } from '@/lib/workspaces/colors'
interface FeaturesPreviewProps {
activeTab: number
@@ -383,7 +384,7 @@ function MiniCardIcon({ variant, color }: { variant: CardVariant; color?: string
className='h-[7px] w-[7px] flex-shrink-0 rounded-[1.5px] border'
style={{
backgroundColor: c,
borderColor: `${c}60`,
borderColor: workflowBorderColor(c),
backgroundClip: 'padding-box',
}}
/>
@@ -470,7 +471,7 @@ function WorkflowCardBody({ color }: { color: string }) {
className='absolute top-2.5 left-[40px] h-[14px] w-[14px] rounded-[3px] border-[2px]'
style={{
backgroundColor: color,
borderColor: `${color}60`,
borderColor: workflowBorderColor(color),
backgroundClip: 'padding-box',
}}
/>
@@ -481,7 +482,7 @@ function WorkflowCardBody({ color }: { color: string }) {
className='absolute top-[36px] left-[68px] h-[14px] w-[14px] rounded-[3px] border-[2px]'
style={{
backgroundColor: color,
borderColor: `${color}60`,
borderColor: workflowBorderColor(color),
backgroundClip: 'padding-box',
opacity: 0.5,
}}
@@ -896,7 +897,7 @@ function MockLogDetailsSidebar({ selectedRow, onPrev, onNext }: MockLogDetailsSi
className='h-[10px] w-[10px] shrink-0 rounded-[3px] border-[1.5px]'
style={{
backgroundColor: color,
borderColor: `${color}60`,
borderColor: workflowBorderColor(color),
backgroundClip: 'padding-box',
}}
/>

View File

@@ -5,6 +5,7 @@ import { Download } from 'lucide-react'
import { ArrowUpDown, Badge, Library, ListFilter, Search } from '@/components/emcn'
import type { BadgeProps } from '@/components/emcn/components/badge/badge'
import { cn } from '@/lib/core/utils/cn'
import { workflowBorderColor } from '@/lib/workspaces/colors'
interface LogRow {
id: string
@@ -283,7 +284,7 @@ export function LandingPreviewLogs() {
className='h-[10px] w-[10px] flex-shrink-0 rounded-[3px] border-[1.5px]'
style={{
backgroundColor: log.workflowColor,
borderColor: `${log.workflowColor}60`,
borderColor: workflowBorderColor(log.workflowColor),
backgroundClip: 'padding-box',
}}
/>

View File

@@ -11,6 +11,7 @@ import {
Table,
} from '@/components/emcn/icons'
import { cn } from '@/lib/core/utils/cn'
import { workflowBorderColor } from '@/lib/workspaces/colors'
import type { PreviewWorkflow } from '@/app/(landing)/components/landing-preview/components/landing-preview-workflow/workflow-data'
export type SidebarView =
@@ -211,7 +212,7 @@ export function LandingPreviewSidebar({
className='h-[14px] w-[14px] flex-shrink-0 rounded-[4px] border-[2.5px]'
style={{
backgroundColor: workflow.color,
borderColor: `${workflow.color}60`,
borderColor: workflowBorderColor(workflow.color),
backgroundClip: 'padding-box',
}}
/>

View File

@@ -16,6 +16,7 @@ import {
ArxivIcon,
AsanaIcon,
AshbyIcon,
AthenaIcon,
AttioIcon,
AzureIcon,
BoxCompanyIcon,
@@ -205,6 +206,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
arxiv: ArxivIcon,
asana: AsanaIcon,
ashby: AshbyIcon,
athena: AthenaIcon,
attio: AttioIcon,
box: BoxCompanyIcon,
brandfetch: BrandfetchIcon,

View File

@@ -971,6 +971,57 @@
"integrationType": "hr",
"tags": ["hiring"]
},
{
"type": "athena",
"slug": "athena",
"name": "Athena",
"description": "Run SQL queries on data in Amazon S3 using AWS Athena",
"longDescription": "Integrate AWS Athena into workflows. Execute SQL queries against data in S3, check query status, retrieve results, manage named queries, and list executions. Requires AWS access key and secret access key.",
"bgColor": "linear-gradient(45deg, #4D27A8 0%, #A166FF 100%)",
"iconName": "AthenaIcon",
"docsUrl": "https://docs.sim.ai/tools/athena",
"operations": [
{
"name": "Start Query",
"description": "Start an SQL query execution in AWS Athena"
},
{
"name": "Get Query Execution",
"description": "Get the status and details of an Athena query execution"
},
{
"name": "Get Query Results",
"description": "Retrieve the results of a completed Athena query execution"
},
{
"name": "Stop Query",
"description": "Stop a running Athena query execution"
},
{
"name": "List Query Executions",
"description": "List recent Athena query execution IDs"
},
{
"name": "Create Named Query",
"description": "Create a saved/named query in AWS Athena"
},
{
"name": "Get Named Query",
"description": "Get details of a saved/named query in AWS Athena"
},
{
"name": "List Named Queries",
"description": "List saved/named query IDs in AWS Athena"
}
],
"operationCount": 8,
"triggers": [],
"triggerCount": 0,
"authType": "none",
"category": "tools",
"integrationType": "analytics",
"tags": ["cloud", "data-analytics"]
},
{
"type": "attio",
"slug": "attio",

View File

@@ -15,6 +15,7 @@ import {
import { type AuthResult, AuthType, checkHybridAuth } from '@/lib/auth/hybrid'
import { acquireLock, getRedisClient, releaseLock } from '@/lib/core/config/redis'
import { validateUrlWithDNS } from '@/lib/core/security/input-validation.server'
import { getClientIp } from '@/lib/core/utils/request'
import { SSE_HEADERS } from '@/lib/core/utils/sse'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { generateId } from '@/lib/core/utils/uuid'
@@ -52,10 +53,9 @@ function getCallerFingerprint(request: NextRequest, userId?: string | null): str
return `user:${userId}`
}
const forwardedFor = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
const realIp = request.headers.get('x-real-ip')?.trim()
const clientIp = getClientIp(request)
const userAgent = request.headers.get('user-agent')?.trim() || 'unknown'
return `public:${forwardedFor || realIp || 'unknown'}:${userAgent}`
return `public:${clientIp}:${userAgent}`
}
function hasCallerAccessToTask(

View File

@@ -3,7 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server'
import { env } from '@/lib/core/config/env'
import type { TokenBucketConfig } from '@/lib/core/rate-limiter'
import { RateLimiter } from '@/lib/core/rate-limiter'
import { generateRequestId } from '@/lib/core/utils/request'
import { generateRequestId, getClientIp } from '@/lib/core/utils/request'
import { getEmailDomain } from '@/lib/core/utils/urls'
import { sendEmail } from '@/lib/messaging/email/mailer'
import { getFromEmailAddress } from '@/lib/messaging/email/utils'
@@ -25,7 +25,7 @@ export async function POST(req: NextRequest) {
const requestId = generateRequestId()
try {
const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown'
const ip = getClientIp(req)
const storageKey = `public:demo-request:${ip}`
const { allowed, remaining, resetAt } = await rateLimiter.checkRateLimitDirect(

View File

@@ -4,7 +4,7 @@ import { z } from 'zod'
import { env } from '@/lib/core/config/env'
import type { TokenBucketConfig } from '@/lib/core/rate-limiter'
import { RateLimiter } from '@/lib/core/rate-limiter'
import { generateRequestId } from '@/lib/core/utils/request'
import { generateRequestId, getClientIp } from '@/lib/core/utils/request'
import { getEmailDomain } from '@/lib/core/utils/urls'
import { sendEmail } from '@/lib/messaging/email/mailer'
import {
@@ -37,7 +37,7 @@ export async function POST(req: NextRequest) {
const requestId = generateRequestId()
try {
const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown'
const ip = getClientIp(req)
const storageKey = `public:integration-request:${ip}`
const { allowed, remaining, resetAt } = await rateLimiter.checkRateLimitDirect(

View File

@@ -222,6 +222,13 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
}
if (parsed.data.status !== undefined) {
updates.status = parsed.data.status
if (parsed.data.status === 'active') {
updates.consecutiveFailures = 0
updates.lastSyncError = null
if (updates.nextSyncAt === undefined) {
updates.nextSyncAt = new Date()
}
}
}
await db

View File

@@ -0,0 +1,11 @@
import { NextResponse } from 'next/server'
import { hasSTTService } from '@/lib/speech/config'
/**
* Returns whether server-side STT is configured.
* Unauthenticated — the response is a single boolean,
* not sensitive data, and deployed chat visitors need it.
*/
export async function GET() {
return NextResponse.json({ sttAvailable: hasSTTService() })
}

View File

@@ -0,0 +1,171 @@
import { db } from '@sim/db'
import { chat } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { hasExceededCostLimit } from '@/lib/billing/core/subscription'
import { recordUsage } from '@/lib/billing/core/usage-log'
import { env } from '@/lib/core/config/env'
import { getCostMultiplier, isBillingEnabled } from '@/lib/core/config/feature-flags'
import { RateLimiter } from '@/lib/core/rate-limiter'
import { validateAuthToken } from '@/lib/core/security/deployment'
import { getClientIp } from '@/lib/core/utils/request'
const logger = createLogger('SpeechTokenAPI')
export const dynamic = 'force-dynamic'
const ELEVENLABS_TOKEN_URL = 'https://api.elevenlabs.io/v1/single-use-token/realtime_scribe'
const VOICE_SESSION_COST_PER_MIN = 0.008
const WORKSPACE_SESSION_MAX_MINUTES = 3
const CHAT_SESSION_MAX_MINUTES = 1
const STT_TOKEN_RATE_LIMIT = {
maxTokens: 30,
refillRate: 3,
refillIntervalMs: 72 * 1000,
} as const
const rateLimiter = new RateLimiter()
async function validateChatAuth(
request: NextRequest,
chatId: string
): Promise<{ valid: boolean; ownerId?: string }> {
try {
const chatResult = await db
.select({
id: chat.id,
userId: chat.userId,
isActive: chat.isActive,
authType: chat.authType,
password: chat.password,
})
.from(chat)
.where(eq(chat.id, chatId))
.limit(1)
if (chatResult.length === 0 || !chatResult[0].isActive) {
return { valid: false }
}
const chatData = chatResult[0]
if (chatData.authType === 'public') {
return { valid: true, ownerId: chatData.userId }
}
const cookieName = `chat_auth_${chatId}`
const authCookie = request.cookies.get(cookieName)
if (authCookie && validateAuthToken(authCookie.value, chatId, chatData.password)) {
return { valid: true, ownerId: chatData.userId }
}
return { valid: false }
} catch (error) {
logger.error('Error validating chat auth for STT:', error)
return { valid: false }
}
}
export async function POST(request: NextRequest) {
try {
const body = await request.json().catch(() => ({}))
const chatId = body?.chatId as string | undefined
let billingUserId: string | undefined
if (chatId) {
const chatAuth = await validateChatAuth(request, chatId)
if (!chatAuth.valid) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
billingUserId = chatAuth.ownerId
} else {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
billingUserId = session.user.id
}
if (isBillingEnabled) {
const rateLimitKey = chatId
? `stt-token:chat:${chatId}:${getClientIp(request)}`
: `stt-token:user:${billingUserId}`
const rateCheck = await rateLimiter.checkRateLimitDirect(rateLimitKey, STT_TOKEN_RATE_LIMIT)
if (!rateCheck.allowed) {
return NextResponse.json(
{ error: 'Voice input rate limit exceeded. Please try again later.' },
{
status: 429,
headers: {
'Retry-After': String(Math.ceil((rateCheck.retryAfterMs ?? 60000) / 1000)),
},
}
)
}
}
if (billingUserId && isBillingEnabled) {
const exceeded = await hasExceededCostLimit(billingUserId)
if (exceeded) {
return NextResponse.json(
{ error: 'Usage limit exceeded. Please upgrade your plan to continue.' },
{ status: 402 }
)
}
}
const apiKey = env.ELEVENLABS_API_KEY
if (!apiKey?.trim()) {
return NextResponse.json(
{ error: 'Speech-to-text service is not configured' },
{ status: 503 }
)
}
const response = await fetch(ELEVENLABS_TOKEN_URL, {
method: 'POST',
headers: { 'xi-api-key': apiKey },
})
if (!response.ok) {
const errBody = await response.json().catch(() => ({}))
const message =
errBody.detail || errBody.message || `Token request failed (${response.status})`
logger.error('ElevenLabs token request failed', { status: response.status, message })
return NextResponse.json({ error: message }, { status: 502 })
}
const data = await response.json()
if (billingUserId) {
const maxMinutes = chatId ? CHAT_SESSION_MAX_MINUTES : WORKSPACE_SESSION_MAX_MINUTES
const sessionCost = VOICE_SESSION_COST_PER_MIN * maxMinutes
await recordUsage({
userId: billingUserId,
entries: [
{
category: 'fixed',
source: 'voice-input',
description: `Voice input session (${maxMinutes} min)`,
cost: sessionCost * getCostMultiplier(),
},
],
}).catch((err) => {
logger.warn('Failed to record voice input usage, continuing:', err)
})
}
return NextResponse.json({ token: data.token })
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to generate speech token'
logger.error('Speech token error:', error)
return NextResponse.json({ error: message }, { status: 500 })
}
}

View File

@@ -0,0 +1,69 @@
import { CreateNamedQueryCommand } from '@aws-sdk/client-athena'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { createAthenaClient } from '@/app/api/tools/athena/utils'
const logger = createLogger('AthenaCreateNamedQuery')
const CreateNamedQuerySchema = z.object({
region: z.string().min(1, 'AWS region is required'),
accessKeyId: z.string().min(1, 'AWS access key ID is required'),
secretAccessKey: z.string().min(1, 'AWS secret access key is required'),
name: z.string().min(1, 'Query name is required'),
database: z.string().min(1, 'Database is required'),
queryString: z.string().min(1, 'Query string is required'),
description: z.string().optional(),
workGroup: z.string().optional(),
})
export async function POST(request: NextRequest) {
try {
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const data = CreateNamedQuerySchema.parse(body)
const client = createAthenaClient({
region: data.region,
accessKeyId: data.accessKeyId,
secretAccessKey: data.secretAccessKey,
})
const command = new CreateNamedQueryCommand({
Name: data.name,
Database: data.database,
QueryString: data.queryString,
...(data.description && { Description: data.description }),
...(data.workGroup && { WorkGroup: data.workGroup }),
})
const response = await client.send(command)
if (!response.NamedQueryId) {
throw new Error('No named query ID returned')
}
return NextResponse.json({
success: true,
output: {
namedQueryId: response.NamedQueryId,
},
})
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: error.errors[0]?.message ?? 'Invalid request' },
{ status: 400 }
)
}
const errorMessage =
error instanceof Error ? error.message : 'Failed to create Athena named query'
logger.error('CreateNamedQuery failed', { error: errorMessage })
return NextResponse.json({ error: errorMessage }, { status: 500 })
}
}

View File

@@ -0,0 +1,66 @@
import { GetNamedQueryCommand } from '@aws-sdk/client-athena'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { createAthenaClient } from '@/app/api/tools/athena/utils'
const logger = createLogger('AthenaGetNamedQuery')
const GetNamedQuerySchema = z.object({
region: z.string().min(1, 'AWS region is required'),
accessKeyId: z.string().min(1, 'AWS access key ID is required'),
secretAccessKey: z.string().min(1, 'AWS secret access key is required'),
namedQueryId: z.string().min(1, 'Named query ID is required'),
})
export async function POST(request: NextRequest) {
try {
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const data = GetNamedQuerySchema.parse(body)
const client = createAthenaClient({
region: data.region,
accessKeyId: data.accessKeyId,
secretAccessKey: data.secretAccessKey,
})
const command = new GetNamedQueryCommand({
NamedQueryId: data.namedQueryId,
})
const response = await client.send(command)
const namedQuery = response.NamedQuery
if (!namedQuery) {
throw new Error('No named query data returned')
}
return NextResponse.json({
success: true,
output: {
namedQueryId: namedQuery.NamedQueryId ?? data.namedQueryId,
name: namedQuery.Name ?? '',
description: namedQuery.Description ?? null,
database: namedQuery.Database ?? '',
queryString: namedQuery.QueryString ?? '',
workGroup: namedQuery.WorkGroup ?? null,
},
})
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: error.errors[0]?.message ?? 'Invalid request' },
{ status: 400 }
)
}
const errorMessage = error instanceof Error ? error.message : 'Failed to get Athena named query'
logger.error('GetNamedQuery failed', { error: errorMessage })
return NextResponse.json({ error: errorMessage }, { status: 500 })
}
}

View File

@@ -0,0 +1,77 @@
import { GetQueryExecutionCommand } from '@aws-sdk/client-athena'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { createAthenaClient } from '@/app/api/tools/athena/utils'
const logger = createLogger('AthenaGetQueryExecution')
const GetQueryExecutionSchema = z.object({
region: z.string().min(1, 'AWS region is required'),
accessKeyId: z.string().min(1, 'AWS access key ID is required'),
secretAccessKey: z.string().min(1, 'AWS secret access key is required'),
queryExecutionId: z.string().min(1, 'Query execution ID is required'),
})
export async function POST(request: NextRequest) {
try {
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const data = GetQueryExecutionSchema.parse(body)
const client = createAthenaClient({
region: data.region,
accessKeyId: data.accessKeyId,
secretAccessKey: data.secretAccessKey,
})
const command = new GetQueryExecutionCommand({
QueryExecutionId: data.queryExecutionId,
})
const response = await client.send(command)
const execution = response.QueryExecution
if (!execution) {
throw new Error('No query execution data returned')
}
return NextResponse.json({
success: true,
output: {
queryExecutionId: execution.QueryExecutionId ?? data.queryExecutionId,
query: execution.Query ?? '',
state: execution.Status?.State ?? 'UNKNOWN',
stateChangeReason: execution.Status?.StateChangeReason ?? null,
statementType: execution.StatementType ?? null,
database: execution.QueryExecutionContext?.Database ?? null,
catalog: execution.QueryExecutionContext?.Catalog ?? null,
workGroup: execution.WorkGroup ?? null,
submissionDateTime: execution.Status?.SubmissionDateTime?.getTime() ?? null,
completionDateTime: execution.Status?.CompletionDateTime?.getTime() ?? null,
dataScannedInBytes: execution.Statistics?.DataScannedInBytes ?? null,
engineExecutionTimeInMillis: execution.Statistics?.EngineExecutionTimeInMillis ?? null,
queryPlanningTimeInMillis: execution.Statistics?.QueryPlanningTimeInMillis ?? null,
queryQueueTimeInMillis: execution.Statistics?.QueryQueueTimeInMillis ?? null,
totalExecutionTimeInMillis: execution.Statistics?.TotalExecutionTimeInMillis ?? null,
outputLocation: execution.ResultConfiguration?.OutputLocation ?? null,
},
})
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: error.errors[0]?.message ?? 'Invalid request' },
{ status: 400 }
)
}
const errorMessage =
error instanceof Error ? error.message : 'Failed to get Athena query execution'
logger.error('GetQueryExecution failed', { error: errorMessage })
return NextResponse.json({ error: errorMessage }, { status: 500 })
}
}

View File

@@ -0,0 +1,88 @@
import { GetQueryResultsCommand } from '@aws-sdk/client-athena'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { createAthenaClient } from '@/app/api/tools/athena/utils'
const logger = createLogger('AthenaGetQueryResults')
const GetQueryResultsSchema = z.object({
region: z.string().min(1, 'AWS region is required'),
accessKeyId: z.string().min(1, 'AWS access key ID is required'),
secretAccessKey: z.string().min(1, 'AWS secret access key is required'),
queryExecutionId: z.string().min(1, 'Query execution ID is required'),
maxResults: z.preprocess(
(v) => (v === '' || v === undefined || v === null ? undefined : v),
z.number({ coerce: true }).int().positive().max(999).optional()
),
nextToken: z.string().optional(),
})
export async function POST(request: NextRequest) {
try {
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const data = GetQueryResultsSchema.parse(body)
const client = createAthenaClient({
region: data.region,
accessKeyId: data.accessKeyId,
secretAccessKey: data.secretAccessKey,
})
const isFirstPage = !data.nextToken
const adjustedMaxResults =
data.maxResults !== undefined && isFirstPage ? data.maxResults + 1 : data.maxResults
const command = new GetQueryResultsCommand({
QueryExecutionId: data.queryExecutionId,
...(adjustedMaxResults !== undefined && { MaxResults: adjustedMaxResults }),
...(data.nextToken && { NextToken: data.nextToken }),
})
const response = await client.send(command)
const columnInfo = response.ResultSet?.ResultSetMetadata?.ColumnInfo ?? []
const columns = columnInfo.map((col) => ({
name: col.Name ?? '',
type: col.Type ?? 'varchar',
}))
const rawRows = response.ResultSet?.Rows ?? []
const dataRows = data.nextToken ? rawRows : rawRows.slice(1)
const rows = dataRows.map((row) => {
const record: Record<string, string> = {}
const rowData = row.Data ?? []
for (let i = 0; i < columns.length; i++) {
record[columns[i].name] = rowData[i]?.VarCharValue ?? ''
}
return record
})
return NextResponse.json({
success: true,
output: {
columns,
rows,
nextToken: response.NextToken ?? null,
updateCount: response.UpdateCount ?? null,
},
})
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: error.errors[0]?.message ?? 'Invalid request' },
{ status: 400 }
)
}
const errorMessage =
error instanceof Error ? error.message : 'Failed to get Athena query results'
logger.error('GetQueryResults failed', { error: errorMessage })
return NextResponse.json({ error: errorMessage }, { status: 500 })
}
}

View File

@@ -0,0 +1,65 @@
import { ListNamedQueriesCommand } from '@aws-sdk/client-athena'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { createAthenaClient } from '@/app/api/tools/athena/utils'
const logger = createLogger('AthenaListNamedQueries')
const ListNamedQueriesSchema = z.object({
region: z.string().min(1, 'AWS region is required'),
accessKeyId: z.string().min(1, 'AWS access key ID is required'),
secretAccessKey: z.string().min(1, 'AWS secret access key is required'),
workGroup: z.string().optional(),
maxResults: z.preprocess(
(v) => (v === '' || v === undefined || v === null ? undefined : v),
z.number({ coerce: true }).int().min(0).max(50).optional()
),
nextToken: z.string().optional(),
})
export async function POST(request: NextRequest) {
try {
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const data = ListNamedQueriesSchema.parse(body)
const client = createAthenaClient({
region: data.region,
accessKeyId: data.accessKeyId,
secretAccessKey: data.secretAccessKey,
})
const command = new ListNamedQueriesCommand({
...(data.workGroup && { WorkGroup: data.workGroup }),
...(data.maxResults !== undefined && { MaxResults: data.maxResults }),
...(data.nextToken && { NextToken: data.nextToken }),
})
const response = await client.send(command)
return NextResponse.json({
success: true,
output: {
namedQueryIds: response.NamedQueryIds ?? [],
nextToken: response.NextToken ?? null,
},
})
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: error.errors[0]?.message ?? 'Invalid request' },
{ status: 400 }
)
}
const errorMessage =
error instanceof Error ? error.message : 'Failed to list Athena named queries'
logger.error('ListNamedQueries failed', { error: errorMessage })
return NextResponse.json({ error: errorMessage }, { status: 500 })
}
}

View File

@@ -0,0 +1,65 @@
import { ListQueryExecutionsCommand } from '@aws-sdk/client-athena'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { createAthenaClient } from '@/app/api/tools/athena/utils'
const logger = createLogger('AthenaListQueryExecutions')
const ListQueryExecutionsSchema = z.object({
region: z.string().min(1, 'AWS region is required'),
accessKeyId: z.string().min(1, 'AWS access key ID is required'),
secretAccessKey: z.string().min(1, 'AWS secret access key is required'),
workGroup: z.string().optional(),
maxResults: z.preprocess(
(v) => (v === '' || v === undefined || v === null ? undefined : v),
z.number({ coerce: true }).int().min(0).max(50).optional()
),
nextToken: z.string().optional(),
})
export async function POST(request: NextRequest) {
try {
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const data = ListQueryExecutionsSchema.parse(body)
const client = createAthenaClient({
region: data.region,
accessKeyId: data.accessKeyId,
secretAccessKey: data.secretAccessKey,
})
const command = new ListQueryExecutionsCommand({
...(data.workGroup && { WorkGroup: data.workGroup }),
...(data.maxResults !== undefined && { MaxResults: data.maxResults }),
...(data.nextToken && { NextToken: data.nextToken }),
})
const response = await client.send(command)
return NextResponse.json({
success: true,
output: {
queryExecutionIds: response.QueryExecutionIds ?? [],
nextToken: response.NextToken ?? null,
},
})
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: error.errors[0]?.message ?? 'Invalid request' },
{ status: 400 }
)
}
const errorMessage =
error instanceof Error ? error.message : 'Failed to list Athena query executions'
logger.error('ListQueryExecutions failed', { error: errorMessage })
return NextResponse.json({ error: errorMessage }, { status: 500 })
}
}

View File

@@ -0,0 +1,80 @@
import { StartQueryExecutionCommand } from '@aws-sdk/client-athena'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { createAthenaClient } from '@/app/api/tools/athena/utils'
const logger = createLogger('AthenaStartQuery')
const StartQuerySchema = z.object({
region: z.string().min(1, 'AWS region is required'),
accessKeyId: z.string().min(1, 'AWS access key ID is required'),
secretAccessKey: z.string().min(1, 'AWS secret access key is required'),
queryString: z.string().min(1, 'Query string is required'),
database: z.string().optional(),
catalog: z.string().optional(),
outputLocation: z.string().optional(),
workGroup: z.string().optional(),
})
export async function POST(request: NextRequest) {
try {
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const data = StartQuerySchema.parse(body)
const client = createAthenaClient({
region: data.region,
accessKeyId: data.accessKeyId,
secretAccessKey: data.secretAccessKey,
})
const command = new StartQueryExecutionCommand({
QueryString: data.queryString,
...(data.database || data.catalog
? {
QueryExecutionContext: {
...(data.database && { Database: data.database }),
...(data.catalog && { Catalog: data.catalog }),
},
}
: {}),
...(data.outputLocation
? {
ResultConfiguration: {
OutputLocation: data.outputLocation,
},
}
: {}),
...(data.workGroup && { WorkGroup: data.workGroup }),
})
const response = await client.send(command)
if (!response.QueryExecutionId) {
throw new Error('No query execution ID returned')
}
return NextResponse.json({
success: true,
output: {
queryExecutionId: response.QueryExecutionId,
},
})
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: error.errors[0]?.message ?? 'Invalid request' },
{ status: 400 }
)
}
const errorMessage = error instanceof Error ? error.message : 'Failed to start Athena query'
logger.error('StartQuery failed', { error: errorMessage })
return NextResponse.json({ error: errorMessage }, { status: 500 })
}
}

View File

@@ -0,0 +1,56 @@
import { StopQueryExecutionCommand } from '@aws-sdk/client-athena'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { createAthenaClient } from '@/app/api/tools/athena/utils'
const logger = createLogger('AthenaStopQuery')
const StopQuerySchema = z.object({
region: z.string().min(1, 'AWS region is required'),
accessKeyId: z.string().min(1, 'AWS access key ID is required'),
secretAccessKey: z.string().min(1, 'AWS secret access key is required'),
queryExecutionId: z.string().min(1, 'Query execution ID is required'),
})
export async function POST(request: NextRequest) {
try {
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const data = StopQuerySchema.parse(body)
const client = createAthenaClient({
region: data.region,
accessKeyId: data.accessKeyId,
secretAccessKey: data.secretAccessKey,
})
const command = new StopQueryExecutionCommand({
QueryExecutionId: data.queryExecutionId,
})
await client.send(command)
return NextResponse.json({
success: true,
output: {
success: true,
},
})
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: error.errors[0]?.message ?? 'Invalid request' },
{ status: 400 }
)
}
const errorMessage = error instanceof Error ? error.message : 'Failed to stop Athena query'
logger.error('StopQuery failed', { error: errorMessage })
return NextResponse.json({ error: errorMessage }, { status: 500 })
}
}

View File

@@ -0,0 +1,17 @@
import { AthenaClient } from '@aws-sdk/client-athena'
interface AwsCredentials {
region: string
accessKeyId: string
secretAccessKey: string
}
export function createAthenaClient(config: AwsCredentials): AthenaClient {
return new AthenaClient({
region: config.region,
credentials: {
accessKeyId: config.accessKeyId,
secretAccessKey: config.secretAccessKey,
},
})
}

View File

@@ -53,6 +53,12 @@ export async function POST(request: NextRequest) {
},
})
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: error.errors[0]?.message ?? 'Invalid request' },
{ status: 400 }
)
}
const errorMessage =
error instanceof Error ? error.message : 'Failed to describe stack drift detection status'
logger.error('DescribeStackDriftDetectionStatus failed', { error: errorMessage })

View File

@@ -70,6 +70,12 @@ export async function POST(request: NextRequest) {
output: { events },
})
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: error.errors[0]?.message ?? 'Invalid request' },
{ status: 400 }
)
}
const errorMessage =
error instanceof Error ? error.message : 'Failed to describe CloudFormation stack events'
logger.error('DescribeStackEvents failed', { error: errorMessage })

View File

@@ -78,6 +78,12 @@ export async function POST(request: NextRequest) {
output: { stacks },
})
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: error.errors[0]?.message ?? 'Invalid request' },
{ status: 400 }
)
}
const errorMessage =
error instanceof Error ? error.message : 'Failed to describe CloudFormation stacks'
logger.error('DescribeStacks failed', { error: errorMessage })

View File

@@ -48,6 +48,12 @@ export async function POST(request: NextRequest) {
},
})
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: error.errors[0]?.message ?? 'Invalid request' },
{ status: 400 }
)
}
const errorMessage =
error instanceof Error ? error.message : 'Failed to detect CloudFormation stack drift'
logger.error('DetectStackDrift failed', { error: errorMessage })

View File

@@ -45,6 +45,12 @@ export async function POST(request: NextRequest) {
},
})
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: error.errors[0]?.message ?? 'Invalid request' },
{ status: 400 }
)
}
const errorMessage =
error instanceof Error ? error.message : 'Failed to get CloudFormation template'
logger.error('GetTemplate failed', { error: errorMessage })

View File

@@ -67,6 +67,12 @@ export async function POST(request: NextRequest) {
output: { resources },
})
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: error.errors[0]?.message ?? 'Invalid request' },
{ status: 400 }
)
}
const errorMessage =
error instanceof Error ? error.message : 'Failed to list CloudFormation stack resources'
logger.error('ListStackResources failed', { error: errorMessage })

View File

@@ -53,6 +53,12 @@ export async function POST(request: NextRequest) {
},
})
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: error.errors[0]?.message ?? 'Invalid request' },
{ status: 400 }
)
}
const errorMessage =
error instanceof Error ? error.message : 'Failed to validate CloudFormation template'
logger.error('ValidateTemplate failed', { error: errorMessage })

View File

@@ -88,6 +88,12 @@ export async function POST(request: NextRequest) {
output: { alarms: [...metricAlarms, ...compositeAlarms] },
})
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: error.errors[0]?.message ?? 'Invalid request' },
{ status: 400 }
)
}
const errorMessage =
error instanceof Error ? error.message : 'Failed to describe CloudWatch alarms'
logger.error('DescribeAlarms failed', { error: errorMessage })

View File

@@ -54,6 +54,12 @@ export async function POST(request: NextRequest) {
output: { logGroups },
})
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: error.errors[0]?.message ?? 'Invalid request' },
{ status: 400 }
)
}
const errorMessage =
error instanceof Error ? error.message : 'Failed to describe CloudWatch log groups'
logger.error('DescribeLogGroups failed', { error: errorMessage })

View File

@@ -44,6 +44,12 @@ export async function POST(request: NextRequest) {
output: { logStreams: result.logStreams },
})
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: error.errors[0]?.message ?? 'Invalid request' },
{ status: 400 }
)
}
const errorMessage =
error instanceof Error ? error.message : 'Failed to describe CloudWatch log streams'
logger.error('DescribeLogStreams failed', { error: errorMessage })

View File

@@ -52,6 +52,12 @@ export async function POST(request: NextRequest) {
output: { events: result.events },
})
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: error.errors[0]?.message ?? 'Invalid request' },
{ status: 400 }
)
}
const errorMessage =
error instanceof Error ? error.message : 'Failed to get CloudWatch log events'
logger.error('GetLogEvents failed', { error: errorMessage })

View File

@@ -89,6 +89,12 @@ export async function POST(request: NextRequest) {
},
})
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: error.errors[0]?.message ?? 'Invalid request' },
{ status: 400 }
)
}
const errorMessage =
error instanceof Error ? error.message : 'Failed to get CloudWatch metric statistics'
logger.error('GetMetricStatistics failed', { error: errorMessage })

View File

@@ -62,6 +62,12 @@ export async function POST(request: NextRequest) {
output: { metrics },
})
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: error.errors[0]?.message ?? 'Invalid request' },
{ status: 400 }
)
}
const errorMessage =
error instanceof Error ? error.message : 'Failed to list CloudWatch metrics'
logger.error('ListMetrics failed', { error: errorMessage })

View File

@@ -63,6 +63,12 @@ export async function POST(request: NextRequest) {
},
})
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: error.errors[0]?.message ?? 'Invalid request' },
{ status: 400 }
)
}
const errorMessage =
error instanceof Error ? error.message : 'CloudWatch Log Insights query failed'
logger.error('QueryLogs failed', { error: errorMessage })

View File

@@ -41,6 +41,12 @@ export async function POST(request: NextRequest) {
message: 'Item deleted successfully',
})
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: error.errors[0]?.message ?? 'Invalid request' },
{ status: 400 }
)
}
const errorMessage = error instanceof Error ? error.message : 'DynamoDB delete failed'
return NextResponse.json({ error: errorMessage }, { status: 500 })
}

View File

@@ -48,6 +48,12 @@ export async function POST(request: NextRequest) {
item: result.item,
})
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: error.errors[0]?.message ?? 'Invalid request' },
{ status: 400 }
)
}
const errorMessage = error instanceof Error ? error.message : 'DynamoDB get failed'
return NextResponse.json({ error: errorMessage }, { status: 500 })
}

View File

@@ -36,6 +36,12 @@ export async function POST(request: NextRequest) {
item: validatedData.item,
})
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: error.errors[0]?.message ?? 'Invalid request' },
{ status: 400 }
)
}
const errorMessage = error instanceof Error ? error.message : 'DynamoDB put failed'
return NextResponse.json({ error: errorMessage }, { status: 500 })
}

View File

@@ -51,6 +51,12 @@ export async function POST(request: NextRequest) {
count: result.count,
})
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: error.errors[0]?.message ?? 'Invalid request' },
{ status: 400 }
)
}
const errorMessage = error instanceof Error ? error.message : 'DynamoDB query failed'
return NextResponse.json({ error: errorMessage }, { status: 500 })
}

View File

@@ -45,6 +45,12 @@ export async function POST(request: NextRequest) {
count: result.count,
})
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: error.errors[0]?.message ?? 'Invalid request' },
{ status: 400 }
)
}
const errorMessage = error instanceof Error ? error.message : 'DynamoDB scan failed'
return NextResponse.json({ error: errorMessage }, { status: 500 })
}

View File

@@ -50,6 +50,12 @@ export async function POST(request: NextRequest) {
item: result.attributes,
})
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: error.errors[0]?.message ?? 'Invalid request' },
{ status: 400 }
)
}
const errorMessage = error instanceof Error ? error.message : 'DynamoDB update failed'
return NextResponse.json({ error: errorMessage }, { status: 500 })
}

View File

@@ -240,6 +240,12 @@ export async function POST(request: NextRequest) {
},
})
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ success: false, error: error.errors[0]?.message ?? 'Invalid request' },
{ status: 400 }
)
}
logger.error(`[${requestId}] Error downloading Google Drive file:`, error)
return NextResponse.json(
{

View File

@@ -165,6 +165,12 @@ export async function POST(request: NextRequest) {
},
})
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ success: false, error: error.errors[0]?.message ?? 'Invalid request' },
{ status: 400 }
)
}
logger.error(`[${requestId}] Error downloading OneDrive file:`, error)
return NextResponse.json(
{

View File

@@ -176,6 +176,12 @@ export async function POST(request: NextRequest) {
},
})
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ success: false, error: error.errors[0]?.message ?? 'Invalid request' },
{ status: 400 }
)
}
logger.error(`[${requestId}] Error creating Outlook draft:`, error)
return NextResponse.json(
{

View File

@@ -189,6 +189,12 @@ export async function POST(request: NextRequest) {
},
})
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ success: false, error: error.errors[0]?.message ?? 'Invalid request' },
{ status: 400 }
)
}
logger.error(`[${requestId}] Error sending Outlook email:`, error)
return NextResponse.json(
{

View File

@@ -158,6 +158,12 @@ export async function POST(request: NextRequest) {
},
})
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ success: false, error: error.errors[0]?.message ?? 'Invalid request' },
{ status: 400 }
)
}
logger.error(`[${requestId}] Error downloading Slack file:`, error)
return NextResponse.json(
{

View File

@@ -84,6 +84,12 @@ export async function POST(request: NextRequest) {
},
})
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ success: false, error: error.errors[0]?.message ?? 'Invalid request' },
{ status: 400 }
)
}
logger.error(`[${requestId}] Error sending ephemeral message:`, error)
return NextResponse.json(
{

View File

@@ -77,6 +77,12 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ success: true, output: result.output })
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ success: false, error: error.errors[0]?.message ?? 'Invalid request' },
{ status: 400 }
)
}
logger.error(`[${requestId}] Error sending Slack message:`, error)
return NextResponse.json(
{

View File

@@ -3,31 +3,36 @@ import { type NextRequest, NextResponse } from 'next/server'
import { verifyCronAuth } from '@/lib/auth/internal'
import { acquireLock, releaseLock } from '@/lib/core/config/redis'
import { generateShortId } from '@/lib/core/utils/uuid'
import { pollRssWebhooks } from '@/lib/webhooks/rss-polling-service'
import { pollProvider, VALID_POLLING_PROVIDERS } from '@/lib/webhooks/polling'
const logger = createLogger('RssPollingAPI')
const logger = createLogger('PollingAPI')
/** Lock TTL in seconds — must match maxDuration so the lock auto-expires if the function times out. */
const LOCK_TTL_SECONDS = 180
export const dynamic = 'force-dynamic'
export const maxDuration = 180 // Allow up to 3 minutes for polling to complete
export const maxDuration = 180
const LOCK_KEY = 'rss-polling-lock'
const LOCK_TTL_SECONDS = 180 // Same as maxDuration (3 min)
export async function GET(request: NextRequest) {
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ provider: string }> }
) {
const { provider } = await params
const requestId = generateShortId()
logger.info(`RSS webhook polling triggered (${requestId})`)
const LOCK_KEY = `${provider}-polling-lock`
let lockValue: string | undefined
try {
const authError = verifyCronAuth(request, 'RSS webhook polling')
if (authError) {
return authError
const authError = verifyCronAuth(request, `${provider} webhook polling`)
if (authError) return authError
if (!VALID_POLLING_PROVIDERS.has(provider)) {
return NextResponse.json({ error: `Unknown polling provider: ${provider}` }, { status: 404 })
}
lockValue = requestId
const locked = await acquireLock(LOCK_KEY, lockValue, LOCK_TTL_SECONDS)
if (!locked) {
return NextResponse.json(
{
@@ -40,21 +45,21 @@ export async function GET(request: NextRequest) {
)
}
const results = await pollRssWebhooks()
const results = await pollProvider(provider)
return NextResponse.json({
success: true,
message: 'RSS polling completed',
message: `${provider} polling completed`,
requestId,
status: 'completed',
...results,
})
} catch (error) {
logger.error(`Error during RSS polling (${requestId}):`, error)
logger.error(`Error during ${provider} polling (${requestId}):`, error)
return NextResponse.json(
{
success: false,
message: 'RSS polling failed',
message: `${provider} polling failed`,
error: error instanceof Error ? error.message : 'Unknown error',
requestId,
},

View File

@@ -1,68 +0,0 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { verifyCronAuth } from '@/lib/auth/internal'
import { acquireLock, releaseLock } from '@/lib/core/config/redis'
import { generateShortId } from '@/lib/core/utils/uuid'
import { pollGmailWebhooks } from '@/lib/webhooks/gmail-polling-service'
const logger = createLogger('GmailPollingAPI')
export const dynamic = 'force-dynamic'
export const maxDuration = 180 // Allow up to 3 minutes for polling to complete
const LOCK_KEY = 'gmail-polling-lock'
const LOCK_TTL_SECONDS = 180 // Same as maxDuration (3 min)
export async function GET(request: NextRequest) {
const requestId = generateShortId()
logger.info(`Gmail webhook polling triggered (${requestId})`)
let lockValue: string | undefined
try {
const authError = verifyCronAuth(request, 'Gmail webhook polling')
if (authError) {
return authError
}
lockValue = requestId // unique value to identify the holder
const locked = await acquireLock(LOCK_KEY, lockValue, LOCK_TTL_SECONDS)
if (!locked) {
return NextResponse.json(
{
success: true,
message: 'Polling already in progress skipped',
requestId,
status: 'skip',
},
{ status: 202 }
)
}
const results = await pollGmailWebhooks()
return NextResponse.json({
success: true,
message: 'Gmail polling completed',
requestId,
status: 'completed',
...results,
})
} catch (error) {
logger.error(`Error during Gmail polling (${requestId}):`, error)
return NextResponse.json(
{
success: false,
message: 'Gmail polling failed',
error: error instanceof Error ? error.message : 'Unknown error',
requestId,
},
{ status: 500 }
)
} finally {
if (lockValue) {
await releaseLock(LOCK_KEY, lockValue).catch(() => {})
}
}
}

View File

@@ -1,68 +0,0 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { verifyCronAuth } from '@/lib/auth/internal'
import { acquireLock, releaseLock } from '@/lib/core/config/redis'
import { generateShortId } from '@/lib/core/utils/uuid'
import { pollImapWebhooks } from '@/lib/webhooks/imap-polling-service'
const logger = createLogger('ImapPollingAPI')
export const dynamic = 'force-dynamic'
export const maxDuration = 180 // Allow up to 3 minutes for polling to complete
const LOCK_KEY = 'imap-polling-lock'
const LOCK_TTL_SECONDS = 180 // Same as maxDuration (3 min)
export async function GET(request: NextRequest) {
const requestId = generateShortId()
logger.info(`IMAP webhook polling triggered (${requestId})`)
let lockValue: string | undefined
try {
const authError = verifyCronAuth(request, 'IMAP webhook polling')
if (authError) {
return authError
}
lockValue = requestId // unique value to identify the holder
const locked = await acquireLock(LOCK_KEY, lockValue, LOCK_TTL_SECONDS)
if (!locked) {
return NextResponse.json(
{
success: true,
message: 'Polling already in progress skipped',
requestId,
status: 'skip',
},
{ status: 202 }
)
}
const results = await pollImapWebhooks()
return NextResponse.json({
success: true,
message: 'IMAP polling completed',
requestId,
status: 'completed',
...results,
})
} catch (error) {
logger.error(`Error during IMAP polling (${requestId}):`, error)
return NextResponse.json(
{
success: false,
message: 'IMAP polling failed',
error: error instanceof Error ? error.message : 'Unknown error',
requestId,
},
{ status: 500 }
)
} finally {
if (lockValue) {
await releaseLock(LOCK_KEY, lockValue).catch(() => {})
}
}
}

View File

@@ -1,68 +0,0 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { verifyCronAuth } from '@/lib/auth/internal'
import { acquireLock, releaseLock } from '@/lib/core/config/redis'
import { generateShortId } from '@/lib/core/utils/uuid'
import { pollOutlookWebhooks } from '@/lib/webhooks/outlook-polling-service'
const logger = createLogger('OutlookPollingAPI')
export const dynamic = 'force-dynamic'
export const maxDuration = 180 // Allow up to 3 minutes for polling to complete
const LOCK_KEY = 'outlook-polling-lock'
const LOCK_TTL_SECONDS = 180 // Same as maxDuration (3 min)
export async function GET(request: NextRequest) {
const requestId = generateShortId()
logger.info(`Outlook webhook polling triggered (${requestId})`)
let lockValue: string | undefined
try {
const authError = verifyCronAuth(request, 'Outlook webhook polling')
if (authError) {
return authError
}
lockValue = requestId // unique value to identify the holder
const locked = await acquireLock(LOCK_KEY, lockValue, LOCK_TTL_SECONDS)
if (!locked) {
return NextResponse.json(
{
success: true,
message: 'Polling already in progress skipped',
requestId,
status: 'skip',
},
{ status: 202 }
)
}
const results = await pollOutlookWebhooks()
return NextResponse.json({
success: true,
message: 'Outlook polling completed',
requestId,
status: 'completed',
...results,
})
} catch (error) {
logger.error(`Error during Outlook polling (${requestId}):`, error)
return NextResponse.json(
{
success: false,
message: 'Outlook polling failed',
error: error instanceof Error ? error.message : 'Unknown error',
requestId,
},
{ status: 500 }
)
} finally {
if (lockValue) {
await releaseLock(LOCK_KEY, lockValue).catch(() => {})
}
}
}

View File

@@ -127,6 +127,14 @@ export default function ChatClient({ identifier }: { identifier: string }) {
const [authRequired, setAuthRequired] = useState<'password' | 'email' | 'sso' | null>(null)
const [isVoiceFirstMode, setIsVoiceFirstMode] = useState(false)
const [sttAvailable, setSttAvailable] = useState(false)
useEffect(() => {
fetch('/api/settings/voice')
.then((r) => (r.ok ? r.json() : { sttAvailable: false }))
.then((data) => setSttAvailable(data.sttAvailable === true))
.catch(() => setSttAvailable(false))
}, [])
const { isStreamingResponse, abortControllerRef, stopStreaming, handleStreamedResponse } =
useChatStreaming()
const audioContextRef = useRef<AudioContext | null>(null)
@@ -443,8 +451,9 @@ export default function ChatClient({ identifier }: { identifier: string }) {
}, [isStreamingResponse, stopStreaming, setMessages, stopAudio])
const handleVoiceStart = useCallback(() => {
if (!sttAvailable) return
setIsVoiceFirstMode(true)
}, [])
}, [sttAvailable])
const handleExitVoiceMode = useCallback(() => {
setIsVoiceFirstMode(false)
@@ -494,6 +503,7 @@ export default function ChatClient({ identifier }: { identifier: string }) {
isStreaming={isStreamingResponse}
isPlayingAudio={isPlayingAudio}
audioContextRef={audioContextRef}
chatId={chatConfig?.id}
messages={messages.map((msg) => ({
content: typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content),
type: msg.type,
@@ -529,6 +539,7 @@ export default function ChatClient({ identifier }: { identifier: string }) {
isStreaming={isStreamingResponse}
onStopStreaming={() => stopStreaming(setMessages)}
onVoiceStart={handleVoiceStart}
sttAvailable={sttAvailable}
/>
</div>
</div>

View File

@@ -14,14 +14,6 @@ const logger = createLogger('ChatInput')
const MAX_TEXTAREA_HEIGHT = 200
const IS_STT_AVAILABLE =
typeof window !== 'undefined' &&
!!(
(window as Window & { SpeechRecognition?: unknown; webkitSpeechRecognition?: unknown })
.SpeechRecognition ||
(window as Window & { webkitSpeechRecognition?: unknown }).webkitSpeechRecognition
)
interface AttachedFile {
id: string
name: string
@@ -37,7 +29,15 @@ export const ChatInput: React.FC<{
onStopStreaming?: () => void
onVoiceStart?: () => void
voiceOnly?: boolean
}> = ({ onSubmit, isStreaming = false, onStopStreaming, onVoiceStart, voiceOnly = false }) => {
sttAvailable?: boolean
}> = ({
onSubmit,
isStreaming = false,
onStopStreaming,
onVoiceStart,
voiceOnly = false,
sttAvailable = false,
}) => {
const fileInputRef = useRef<HTMLInputElement>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const [inputValue, setInputValue] = useState('')
@@ -142,7 +142,7 @@ export const ChatInput: React.FC<{
return (
<Tooltip.Provider>
<div className='flex items-center justify-center'>
{IS_STT_AVAILABLE && (
{sttAvailable && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<div>
@@ -295,7 +295,7 @@ export const ChatInput: React.FC<{
{/* Right: mic + send */}
<div className='flex items-center gap-1.5'>
{IS_STT_AVAILABLE && (
{sttAvailable && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<button

View File

@@ -1,41 +1,9 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { useCallback } from 'react'
import { motion } from 'framer-motion'
import { Mic } from 'lucide-react'
interface SpeechRecognitionEvent extends Event {
resultIndex: number
results: SpeechRecognitionResultList
}
interface SpeechRecognitionErrorEvent extends Event {
error: string
message?: string
}
interface SpeechRecognition extends EventTarget {
continuous: boolean
interimResults: boolean
lang: string
start(): void
stop(): void
abort(): void
onstart: ((this: SpeechRecognition, ev: Event) => any) | null
onend: ((this: SpeechRecognition, ev: Event) => any) | null
onresult: ((this: SpeechRecognition, ev: SpeechRecognitionEvent) => any) | null
onerror: ((this: SpeechRecognition, ev: SpeechRecognitionErrorEvent) => any) | null
}
interface SpeechRecognitionStatic {
new (): SpeechRecognition
}
type WindowWithSpeech = Window & {
SpeechRecognition?: SpeechRecognitionStatic
webkitSpeechRecognition?: SpeechRecognitionStatic
}
interface VoiceInputProps {
onVoiceStart: () => void
isListening?: boolean
@@ -51,24 +19,11 @@ export function VoiceInput({
large = false,
minimal = false,
}: VoiceInputProps) {
const [isSupported, setIsSupported] = useState(false)
// Check if speech recognition is supported
useEffect(() => {
const w = window as WindowWithSpeech
const SpeechRecognitionCtor = w.SpeechRecognition || w.webkitSpeechRecognition
setIsSupported(!!SpeechRecognitionCtor)
}, [])
const handleVoiceClick = useCallback(() => {
if (disabled) return
onVoiceStart()
}, [disabled, onVoiceStart])
if (!isSupported) {
return null
}
if (minimal) {
return (
<button
@@ -88,7 +43,6 @@ export function VoiceInput({
if (large) {
return (
<div className='flex flex-col items-center'>
{/* Large Voice Button */}
<motion.button
type='button'
onClick={handleVoiceClick}
@@ -110,7 +64,6 @@ export function VoiceInput({
return (
<div className='flex items-center'>
{/* Voice Button - Now matches send button styling */}
<motion.button
type='button'
onClick={handleVoiceClick}

View File

@@ -2,6 +2,8 @@ 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'
import { CopyCodeButton } from '@/components/ui/copy-code-button'
import { extractTextContent } from '@/lib/core/utils/react-node-text'
export function LinkWithPreview({ href, children }: { href: string; children: React.ReactNode }) {
return (
@@ -102,6 +104,10 @@ function createCustomComponents(LinkComponent: typeof LinkWithPreview) {
<span className='font-sans text-gray-400 text-xs'>
{codeProps.className?.replace('language-', '') || 'code'}
</span>
<CopyCodeButton
code={extractTextContent(codeContent)}
className='text-gray-400 hover:bg-gray-700 hover:text-gray-200'
/>
</div>
<pre className='overflow-x-auto p-4 font-mono text-gray-200 dark:text-gray-100'>
{codeContent}

View File

@@ -6,6 +6,13 @@ import { Mic, MicOff, Phone } from 'lucide-react'
import dynamic from 'next/dynamic'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/core/utils/cn'
import { arrayBufferToBase64, floatTo16BitPCM } from '@/lib/speech/audio'
import {
CHUNK_SEND_INTERVAL_MS,
ELEVENLABS_WS_URL,
MAX_CHAT_SESSION_MS,
SAMPLE_RATE,
} from '@/lib/speech/config'
const ParticlesVisualization = dynamic(
() =>
@@ -17,38 +24,6 @@ const ParticlesVisualization = dynamic(
const logger = createLogger('VoiceInterface')
interface SpeechRecognitionEvent extends Event {
resultIndex: number
results: SpeechRecognitionResultList
}
interface SpeechRecognitionErrorEvent extends Event {
error: string
message?: string
}
interface SpeechRecognition extends EventTarget {
continuous: boolean
interimResults: boolean
lang: string
start(): void
stop(): void
abort(): void
onstart: ((this: SpeechRecognition, ev: Event) => any) | null
onend: ((this: SpeechRecognition, ev: Event) => any) | null
onresult: ((this: SpeechRecognition, ev: SpeechRecognitionEvent) => any) | null
onerror: ((this: SpeechRecognition, ev: SpeechRecognitionErrorEvent) => any) | null
}
interface SpeechRecognitionStatic {
new (): SpeechRecognition
}
type WindowWithSpeech = Window & {
SpeechRecognition?: SpeechRecognitionStatic
webkitSpeechRecognition?: SpeechRecognitionStatic
}
interface VoiceInterfaceProps {
onCallEnd?: () => void
onVoiceTranscript?: (transcript: string) => void
@@ -60,6 +35,7 @@ interface VoiceInterfaceProps {
audioContextRef?: RefObject<AudioContext | null>
messages?: Array<{ content: string; type: 'user' | 'assistant' }>
className?: string
chatId?: string
}
export function VoiceInterface({
@@ -73,6 +49,7 @@ export function VoiceInterface({
audioContextRef: sharedAudioContextRef,
messages = [],
className,
chatId,
}: VoiceInterfaceProps) {
const [state, setState] = useState<'idle' | 'listening' | 'agent_speaking'>('idle')
const [isInitialized, setIsInitialized] = useState(false)
@@ -91,79 +68,177 @@ export function VoiceInterface({
currentStateRef.current = next
}, [])
const recognitionRef = useRef<SpeechRecognition | null>(null)
const mediaStreamRef = useRef<MediaStream | null>(null)
const audioContextRef = useRef<AudioContext | null>(null)
const analyserRef = useRef<AnalyserNode | null>(null)
const animationFrameRef = useRef<number | null>(null)
const isMutedRef = useRef(false)
const responseTimeoutRef = useRef<NodeJS.Timeout | null>(null)
const isSupported =
typeof window !== 'undefined' &&
!!(
(window as WindowWithSpeech).SpeechRecognition ||
(window as WindowWithSpeech).webkitSpeechRecognition
)
const wsRef = useRef<WebSocket | null>(null)
const processorRef = useRef<ScriptProcessorNode | null>(null)
const pcmBufferRef = useRef<Float32Array[]>([])
const sendIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
const sessionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const committedTextRef = useRef('')
const lastPartialRef = useRef('')
const onVoiceTranscriptRef = useRef(onVoiceTranscript)
onVoiceTranscriptRef.current = onVoiceTranscript
const updateIsMuted = useCallback((next: boolean) => {
setIsMuted(next)
isMutedRef.current = next
}, [])
const setResponseTimeout = useCallback(() => {
if (responseTimeoutRef.current) {
clearTimeout(responseTimeoutRef.current)
const stopSendingAudio = useCallback(() => {
if (sessionTimerRef.current) {
clearTimeout(sessionTimerRef.current)
sessionTimerRef.current = null
}
responseTimeoutRef.current = setTimeout(() => {
if (currentStateRef.current === 'listening') {
updateState('idle')
}
}, 5000)
if (sendIntervalRef.current) {
clearInterval(sendIntervalRef.current)
sendIntervalRef.current = null
}
pcmBufferRef.current = []
}, [])
const clearResponseTimeout = useCallback(() => {
if (responseTimeoutRef.current) {
clearTimeout(responseTimeoutRef.current)
responseTimeoutRef.current = null
const flushAudioBuffer = useCallback(() => {
const ws = wsRef.current
if (!ws || ws.readyState !== WebSocket.OPEN) return
const chunks = pcmBufferRef.current
if (chunks.length === 0) return
pcmBufferRef.current = []
let totalLength = 0
for (const chunk of chunks) totalLength += chunk.length
const merged = new Float32Array(totalLength)
let offset = 0
for (const chunk of chunks) {
merged.set(chunk, offset)
offset += chunk.length
}
const pcm16 = floatTo16BitPCM(merged)
ws.send(
JSON.stringify({
message_type: 'input_audio_chunk',
audio_base_64: arrayBufferToBase64(pcm16),
sample_rate: SAMPLE_RATE,
commit: false,
})
)
}, [])
useEffect(() => {
if (isPlayingAudio && state !== 'agent_speaking') {
clearResponseTimeout()
updateState('agent_speaking')
setCurrentTranscript('')
const startSendingAudio = useCallback(() => {
if (sendIntervalRef.current) return
pcmBufferRef.current = []
sendIntervalRef.current = setInterval(flushAudioBuffer, CHUNK_SEND_INTERVAL_MS)
}, [flushAudioBuffer])
updateIsMuted(true)
if (mediaStreamRef.current) {
mediaStreamRef.current.getAudioTracks().forEach((track) => {
track.enabled = false
})
const closeWebSocket = useCallback(() => {
stopSendingAudio()
if (wsRef.current) {
if (
wsRef.current.readyState === WebSocket.OPEN ||
wsRef.current.readyState === WebSocket.CONNECTING
) {
wsRef.current.close()
}
wsRef.current = null
}
}, [stopSendingAudio])
const connectWebSocket = useCallback(async (): Promise<boolean> => {
try {
const body: Record<string, string> = {}
if (chatId) body.chatId = chatId
const tokenResponse = await fetch('/api/speech/token', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (!tokenResponse.ok) {
logger.error('Failed to get STT token', { status: tokenResponse.status })
return false
}
if (recognitionRef.current) {
try {
recognitionRef.current.abort()
} catch (error) {
logger.debug('Error aborting speech recognition:', error)
const { token } = await tokenResponse.json()
const params = new URLSearchParams({
token,
model_id: 'scribe_v2_realtime',
audio_format: 'pcm_16000',
commit_strategy: 'vad',
vad_silence_threshold_secs: '1.0',
})
const ws = new WebSocket(`${ELEVENLABS_WS_URL}?${params.toString()}`)
wsRef.current = ws
committedTextRef.current = ''
return new Promise<boolean>((resolve) => {
ws.onopen = () => resolve(true)
ws.onerror = () => {
logger.error('STT WebSocket connection error')
resolve(false)
}
}
} else if (!isPlayingAudio && state === 'agent_speaking') {
updateState('idle')
setCurrentTranscript('')
updateIsMuted(false)
if (mediaStreamRef.current) {
mediaStreamRef.current.getAudioTracks().forEach((track) => {
track.enabled = true
})
}
ws.onmessage = (event) => {
if (isCallEndedRef.current) return
try {
const msg = JSON.parse(event.data)
if (msg.message_type === 'partial_transcript') {
if (msg.text) {
lastPartialRef.current = msg.text
setCurrentTranscript(msg.text)
}
} else if (
msg.message_type === 'committed_transcript' ||
msg.message_type === 'committed_transcript_with_timestamps'
) {
const finalText = msg.text || lastPartialRef.current
lastPartialRef.current = ''
if (finalText) {
committedTextRef.current = committedTextRef.current
? `${committedTextRef.current} ${finalText}`
: finalText
setCurrentTranscript('')
onVoiceTranscriptRef.current?.(finalText)
}
} else if (
msg.message_type === 'error' ||
msg.message_type === 'auth_error' ||
msg.message_type === 'quota_exceeded'
) {
logger.error('ElevenLabs STT error', { type: msg.message_type, error: msg.error })
}
} catch {
// Ignore non-JSON messages
}
}
ws.onclose = () => {
wsRef.current = null
if (currentStateRef.current === 'listening' && !isCallEndedRef.current) {
stopSendingAudio()
updateState('idle')
}
}
})
} catch (error) {
logger.error('Failed to connect STT WebSocket', error)
return false
}
}, [isPlayingAudio, state, clearResponseTimeout, updateState, updateIsMuted])
}, [chatId])
const setupAudio = useCallback(async () => {
const setupAudioPipeline = useCallback(async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
@@ -171,33 +246,40 @@ export function VoiceInterface({
noiseSuppression: true,
autoGainControl: true,
channelCount: 1,
sampleRate: SAMPLE_RATE,
},
})
setPermissionStatus('granted')
mediaStreamRef.current = stream
if (!audioContextRef.current) {
const AudioContext = window.AudioContext || window.webkitAudioContext
audioContextRef.current = new AudioContext()
const ac = new AudioContext({ sampleRate: SAMPLE_RATE })
audioContextRef.current = ac
if (ac.state === 'suspended') {
await ac.resume()
}
const audioContext = audioContextRef.current
if (audioContext.state === 'suspended') {
await audioContext.resume()
}
const source = ac.createMediaStreamSource(stream)
const source = audioContext.createMediaStreamSource(stream)
const analyser = audioContext.createAnalyser()
const analyser = ac.createAnalyser()
analyser.fftSize = 256
analyser.smoothingTimeConstant = 0.8
source.connect(analyser)
analyserRef.current = analyser
const processor = ac.createScriptProcessor(4096, 1, 1)
processor.onaudioprocess = (e) => {
if (!isMutedRef.current && currentStateRef.current === 'listening') {
pcmBufferRef.current.push(new Float32Array(e.inputBuffer.getChannelData(0)))
}
}
source.connect(processor)
processor.connect(ac.destination)
processorRef.current = processor
const updateVisualization = () => {
if (!analyserRef.current) return
const bufferLength = analyserRef.current.frequencyBinCount
const dataArray = new Uint8Array(bufferLength)
analyserRef.current.getByteFrequencyData(dataArray)
@@ -212,143 +294,57 @@ export function VoiceInterface({
setAudioLevels(levels)
animationFrameRef.current = requestAnimationFrame(updateVisualization)
}
updateVisualization()
setIsInitialized(true)
return true
} catch (error) {
logger.error('Error setting up audio:', error)
logger.error('Error setting up audio pipeline:', error)
setPermissionStatus('denied')
return false
}
}, [])
const setupSpeechRecognition = useCallback(() => {
if (!isSupported) return
const startListening = useCallback(async () => {
if (currentStateRef.current !== 'idle' || isMutedRef.current || isCallEndedRef.current) return
const w = window as WindowWithSpeech
const SpeechRecognition = w.SpeechRecognition || w.webkitSpeechRecognition
if (!SpeechRecognition) return
const recognition = new SpeechRecognition()
recognition.continuous = true
recognition.interimResults = true
recognition.lang = 'en-US'
recognition.onstart = () => {}
recognition.onresult = (event: SpeechRecognitionEvent) => {
const currentState = currentStateRef.current
if (isMutedRef.current || currentState !== 'listening') {
return
}
let finalTranscript = ''
let interimTranscript = ''
for (let i = event.resultIndex; i < event.results.length; i++) {
const result = event.results[i]
const transcript = result[0].transcript
if (result.isFinal) {
finalTranscript += transcript
} else {
interimTranscript += transcript
}
}
setCurrentTranscript(interimTranscript || finalTranscript)
if (finalTranscript.trim()) {
setCurrentTranscript('')
if (recognitionRef.current) {
try {
recognitionRef.current.stop()
} catch (error) {
// Ignore
}
}
setResponseTimeout()
onVoiceTranscript?.(finalTranscript)
}
}
recognition.onend = () => {
if (isCallEndedRef.current) return
const currentState = currentStateRef.current
if (currentState === 'listening' && !isMutedRef.current) {
setTimeout(() => {
if (isCallEndedRef.current) return
if (
recognitionRef.current &&
currentStateRef.current === 'listening' &&
!isMutedRef.current
) {
try {
recognitionRef.current.start()
} catch (error) {
logger.debug('Error restarting speech recognition:', error)
}
}
}, 1000)
}
}
recognition.onerror = (event: SpeechRecognitionErrorEvent) => {
if (event.error === 'aborted') {
return
}
if (event.error === 'not-allowed') {
setPermissionStatus('denied')
}
}
recognitionRef.current = recognition
}, [isSupported, onVoiceTranscript, setResponseTimeout])
const startListening = useCallback(() => {
if (!isInitialized || isMuted || state !== 'idle') {
return
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) {
const connected = await connectWebSocket()
if (!connected || isCallEndedRef.current) return
}
updateState('listening')
setCurrentTranscript('')
startSendingAudio()
if (recognitionRef.current) {
try {
recognitionRef.current.start()
} catch (error) {
logger.error('Error starting recognition:', error)
}
}
}, [isInitialized, isMuted, state, updateState])
sessionTimerRef.current = setTimeout(() => {
logger.info('Voice session reached max duration, stopping')
stopSendingAudio()
closeWebSocket()
updateState('idle')
}, MAX_CHAT_SESSION_MS)
}, [connectWebSocket, updateState, startSendingAudio, stopSendingAudio, closeWebSocket])
const stopListening = useCallback(() => {
stopSendingAudio()
updateState('idle')
setCurrentTranscript('')
}, [updateState, stopSendingAudio])
if (recognitionRef.current) {
try {
recognitionRef.current.stop()
} catch (error) {
// Ignore
useEffect(() => {
if (isPlayingAudio && state === 'listening') {
stopSendingAudio()
closeWebSocket()
updateState('agent_speaking')
setCurrentTranscript('')
updateIsMuted(true)
if (mediaStreamRef.current) {
mediaStreamRef.current.getAudioTracks().forEach((track) => {
track.enabled = false
})
}
}
}, [updateState])
const handleInterrupt = useCallback(() => {
if (state === 'agent_speaking') {
onInterrupt?.()
updateState('listening')
} else if (!isPlayingAudio && state === 'agent_speaking') {
updateState('idle')
setCurrentTranscript('')
updateIsMuted(false)
@@ -357,36 +353,57 @@ export function VoiceInterface({
track.enabled = true
})
}
}
}, [isPlayingAudio, state, updateState, updateIsMuted, stopSendingAudio, closeWebSocket])
if (recognitionRef.current) {
try {
recognitionRef.current.start()
} catch (error) {
logger.error('Could not start recognition after interrupt:', error)
}
const handleInterrupt = useCallback(() => {
if (state === 'agent_speaking') {
onInterrupt?.()
updateIsMuted(false)
if (mediaStreamRef.current) {
mediaStreamRef.current.getAudioTracks().forEach((track) => {
track.enabled = true
})
}
updateState('idle')
setCurrentTranscript('')
}
}, [state, onInterrupt, updateState, updateIsMuted])
const handleCallEnd = useCallback(() => {
isCallEndedRef.current = true
stopSendingAudio()
closeWebSocket()
updateState('idle')
setCurrentTranscript('')
updateIsMuted(false)
if (recognitionRef.current) {
try {
recognitionRef.current.abort()
} catch (error) {
logger.error('Error stopping speech recognition:', error)
}
if (processorRef.current) {
processorRef.current.disconnect()
processorRef.current = null
}
if (mediaStreamRef.current) {
mediaStreamRef.current.getTracks().forEach((track) => track.stop())
mediaStreamRef.current = null
}
if (audioContextRef.current && audioContextRef.current.state !== 'closed') {
audioContextRef.current.close().catch(() => {})
audioContextRef.current = null
}
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current)
animationFrameRef.current = null
}
clearResponseTimeout()
onInterrupt?.()
onCallEnd?.()
}, [onCallEnd, onInterrupt, clearResponseTimeout, updateState, updateIsMuted])
}, [onCallEnd, onInterrupt, updateState, updateIsMuted, stopSendingAudio, closeWebSocket])
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
@@ -423,11 +440,25 @@ export function VoiceInterface({
}, [isMuted, state, handleInterrupt, stopListening, startListening, updateIsMuted])
useEffect(() => {
if (isSupported) {
setupSpeechRecognition()
setupAudio()
isCallEndedRef.current = false
let cancelled = false
async function init() {
const audioOk = await setupAudioPipeline()
if (!audioOk || cancelled) return
const wsOk = await connectWebSocket()
if (!wsOk || cancelled) return
setIsInitialized(true)
}
}, [isSupported, setupSpeechRecognition, setupAudio])
init()
return () => {
cancelled = true
}
}, [setupAudioPipeline, connectWebSocket])
useEffect(() => {
if (isInitialized && !isMuted && state === 'idle') {
@@ -439,13 +470,16 @@ export function VoiceInterface({
return () => {
isCallEndedRef.current = true
if (recognitionRef.current) {
try {
recognitionRef.current.abort()
} catch (_e) {
// Ignore
}
recognitionRef.current = null
stopSendingAudio()
if (wsRef.current) {
wsRef.current.close()
wsRef.current = null
}
if (processorRef.current) {
processorRef.current.disconnect()
processorRef.current = null
}
if (mediaStreamRef.current) {
@@ -462,13 +496,8 @@ export function VoiceInterface({
cancelAnimationFrame(animationFrameRef.current)
animationFrameRef.current = null
}
if (responseTimeoutRef.current) {
clearTimeout(responseTimeoutRef.current)
responseTimeoutRef.current = null
}
}
}, [])
}, [stopSendingAudio])
const getStatusText = () => {
switch (state) {

View File

@@ -0,0 +1,45 @@
import { Blimp, Database, Folder as FolderIcon, Table as TableIcon } from '@/components/emcn/icons'
import { getDocumentIcon } from '@/components/icons/document-icons'
import { cn } from '@/lib/core/utils/cn'
import { workflowBorderColor } from '@/lib/workspaces/colors'
import type { ChatMessageContext } from '@/app/workspace/[workspaceId]/home/types'
interface ContextMentionIconProps {
context: ChatMessageContext
/** Only used when context.kind is 'workflow' or 'current_workflow'; ignored otherwise. */
workflowColor?: string | null
/** Applied to every icon element. Include sizing and positional classes (e.g. h-[12px] w-[12px]). */
className: string
}
/** Renders the icon for a context mention chip. Returns null when no icon applies. */
export function ContextMentionIcon({ context, workflowColor, className }: ContextMentionIconProps) {
switch (context.kind) {
case 'workflow':
case 'current_workflow':
return workflowColor ? (
<span
className={cn('rounded-[3px] border-[2px]', className)}
style={{
backgroundColor: workflowColor,
borderColor: workflowBorderColor(workflowColor),
backgroundClip: 'padding-box',
}}
/>
) : null
case 'knowledge':
return <Database className={className} />
case 'table':
return <TableIcon className={className} />
case 'file': {
const FileDocIcon = getDocumentIcon('', context.label)
return <FileDocIcon className={className} />
}
case 'folder':
return <FolderIcon className={className} />
case 'past_chat':
return <Blimp className={className} />
default:
return null
}
}

View File

@@ -1,4 +1,5 @@
export { ChatMessageAttachments } from './chat-message-attachments'
export { ContextMentionIcon } from './context-mention-icon'
export {
assistantMessageHasRenderableContent,
MessageContent,

View File

@@ -9,7 +9,9 @@ import 'prismjs/components/prism-css'
import 'prismjs/components/prism-markup'
import '@/components/emcn/components/code/code.css'
import { Checkbox, highlight, languages } from '@/components/emcn'
import { CopyCodeButton } from '@/components/ui/copy-code-button'
import { cn } from '@/lib/core/utils/cn'
import { extractTextContent } from '@/lib/core/utils/react-node-text'
import {
PendingTagIndicator,
parseSpecialTags,
@@ -33,16 +35,6 @@ const LANG_ALIASES: Record<string, string> = {
py: 'python',
}
function extractTextContent(node: React.ReactNode): string {
if (typeof node === 'string') return node
if (typeof node === 'number') return String(node)
if (!node) return ''
if (Array.isArray(node)) return node.map(extractTextContent).join('')
if (isValidElement(node))
return extractTextContent((node.props as { children?: React.ReactNode }).children)
return ''
}
const PROSE_CLASSES = cn(
'prose prose-base dark:prose-invert max-w-none',
'font-[family-name:var(--font-inter)] antialiased break-words font-[430] tracking-[0]',
@@ -125,11 +117,13 @@ const MARKDOWN_COMPONENTS: React.ComponentProps<typeof ReactMarkdown>['component
return (
<div className='not-prose my-6 overflow-hidden rounded-lg border border-[var(--divider)]'>
{language && (
<div className='border-[var(--divider)] border-b bg-[var(--surface-4)] px-4 py-2 text-[var(--text-tertiary)] text-xs dark:bg-[var(--surface-4)]'>
{language}
</div>
)}
<div className='flex items-center justify-between border-[var(--divider)] border-b bg-[var(--surface-4)] px-4 py-2 dark:bg-[var(--surface-4)]'>
<span className='text-[var(--text-tertiary)] text-xs'>{language || 'code'}</span>
<CopyCodeButton
code={codeString}
className='text-[var(--text-tertiary)] hover:bg-[var(--surface-5)] hover:text-[var(--text-secondary)]'
/>
</div>
<div className='code-editor-theme bg-[var(--surface-5)] dark:bg-[var(--code-bg)]'>
<pre
className='m-0 overflow-x-auto whitespace-pre p-4 font-[430] font-mono text-[var(--text-primary)] text-small leading-[21px]'

View File

@@ -37,6 +37,7 @@ interface MothershipChatProps {
userId?: string
chatId?: string
onContextAdd?: (context: ChatContext) => void
onContextRemove?: (context: ChatContext) => void
editValue?: string
onEditValueConsumed?: () => void
layout?: 'mothership-view' | 'copilot-view'
@@ -83,6 +84,7 @@ export function MothershipChat({
userId,
chatId,
onContextAdd,
onContextRemove,
editValue,
onEditValueConsumed,
layout = 'mothership-view',
@@ -207,6 +209,7 @@ export function MothershipChat({
isInitialView={false}
userId={userId}
onContextAdd={onContextAdd}
onContextRemove={onContextRemove}
editValue={editValue}
onEditValueConsumed={onEditValueConsumed}
onEnterWhileEmpty={handleEnterWhileEmpty}

View File

@@ -27,6 +27,7 @@ import type {
import { useFolders } from '@/hooks/queries/folders'
import { useKnowledgeBasesQuery } from '@/hooks/queries/kb/knowledge'
import { useTablesList } from '@/hooks/queries/tables'
import { useTasks } from '@/hooks/queries/tasks'
import { useWorkflows } from '@/hooks/queries/workflows'
import { useWorkspaceFiles } from '@/hooks/queries/workspace-files'
@@ -53,6 +54,7 @@ export function useAvailableResources(
const { data: files = [] } = useWorkspaceFiles(workspaceId)
const { data: knowledgeBases } = useKnowledgeBasesQuery(workspaceId)
const { data: folders = [] } = useFolders(workspaceId)
const { data: tasks = [] } = useTasks(workspaceId)
return useMemo(
() => [
@@ -97,8 +99,16 @@ export function useAvailableResources(
isOpen: existingKeys.has(`knowledgebase:${kb.id}`),
})),
},
{
type: 'task' as const,
items: tasks.map((t) => ({
id: t.id,
name: t.name,
isOpen: existingKeys.has(`task:${t.id}`),
})),
},
],
[workflows, folders, tables, files, knowledgeBases, existingKeys]
[workflows, folders, tables, files, knowledgeBases, tasks, existingKeys]
)
}

View File

@@ -22,6 +22,7 @@ import {
getFileExtension,
getMimeTypeFromExtension,
} from '@/lib/uploads/utils/file-utils'
import { workflowBorderColor } from '@/lib/workspaces/colors'
import {
FileViewer,
type PreviewMode,
@@ -514,7 +515,7 @@ function EmbeddedFolder({ workspaceId, folderId }: EmbeddedFolderProps) {
className='h-[12px] w-[12px] flex-shrink-0 rounded-[3px] border-[2px]'
style={{
backgroundColor: w.color,
borderColor: `${w.color}60`,
borderColor: workflowBorderColor(w.color),
backgroundClip: 'padding-box',
}}
/>

View File

@@ -4,6 +4,7 @@ import { type ElementType, type ReactNode, useMemo } from 'react'
import type { QueryClient } from '@tanstack/react-query'
import { useParams } from 'next/navigation'
import {
Blimp,
Database,
File as FileIcon,
Folder as FolderIcon,
@@ -13,12 +14,14 @@ import {
import { WorkflowIcon } from '@/components/icons'
import { getDocumentIcon } from '@/components/icons/document-icons'
import { cn } from '@/lib/core/utils/cn'
import { workflowBorderColor } from '@/lib/workspaces/colors'
import type {
MothershipResource,
MothershipResourceType,
} from '@/app/workspace/[workspaceId]/home/types'
import { knowledgeKeys } from '@/hooks/queries/kb/knowledge'
import { tableKeys } from '@/hooks/queries/tables'
import { taskKeys } from '@/hooks/queries/tasks'
import { folderKeys } from '@/hooks/queries/utils/folder-keys'
import { invalidateWorkflowLists } from '@/hooks/queries/utils/invalidate-workflow-lists'
import { useWorkflows } from '@/hooks/queries/workflows'
@@ -48,7 +51,7 @@ function WorkflowTabSquare({ workflowId, className }: { workflowId: string; clas
className={cn('flex-shrink-0 rounded-[3px] border-[2px]', className)}
style={{
backgroundColor: color,
borderColor: `${color}60`,
borderColor: workflowBorderColor(color),
backgroundClip: 'padding-box',
}}
/>
@@ -63,7 +66,7 @@ function WorkflowDropdownItem({ item }: DropdownItemRenderProps) {
className='h-[14px] w-[14px] flex-shrink-0 rounded-[3px] border-[2px]'
style={{
backgroundColor: color,
borderColor: `${color}60`,
borderColor: workflowBorderColor(color),
backgroundClip: 'padding-box',
}}
/>
@@ -151,6 +154,15 @@ export const RESOURCE_REGISTRY: Record<MothershipResourceType, ResourceTypeConfi
),
renderDropdownItem: (props) => <IconDropdownItem {...props} icon={FolderIcon} />,
},
task: {
type: 'task',
label: 'Tasks',
icon: Blimp,
renderTabIcon: (_resource, className) => (
<Blimp className={cn(className, 'text-[var(--text-icon)]')} />
),
renderDropdownItem: (props) => <IconDropdownItem {...props} icon={Blimp} />,
},
} as const
export const RESOURCE_TYPES = Object.values(RESOURCE_REGISTRY)
@@ -185,6 +197,9 @@ const RESOURCE_INVALIDATORS: Record<
folder: (qc) => {
qc.invalidateQueries({ queryKey: folderKeys.lists() })
},
task: (qc, wId) => {
qc.invalidateQueries({ queryKey: taskKeys.list(wId) })
},
}
/**

View File

@@ -10,6 +10,7 @@ import {
import { Button, Tooltip } from '@/components/emcn'
import { Columns3, Eye, PanelLeft, Pencil } from '@/components/emcn/icons'
import { isEphemeralResource } from '@/lib/copilot/resource-extraction'
import { SIM_RESOURCE_DRAG_TYPE } from '@/lib/copilot/resource-types'
import { cn } from '@/lib/core/utils/cn'
import type { PreviewMode } from '@/app/workspace/[workspaceId]/files/components/file-viewer'
import { AddResourceDropdown } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown'
@@ -164,7 +165,7 @@ export function ResourceTabs({
const resource = resources[idx]
if (resource) {
e.dataTransfer.setData(
'application/x-sim-resource',
SIM_RESOURCE_DRAG_TYPE,
JSON.stringify({ type: resource.type, id: resource.id, title: resource.title })
)
}

View File

@@ -89,6 +89,8 @@ export function mapResourceToContext(resource: MothershipResource): ChatContext
return { kind: 'file', fileId: resource.id, label: resource.title }
case 'folder':
return { kind: 'folder', folderId: resource.id, label: resource.title }
case 'task':
return { kind: 'past_chat', chatId: resource.id, label: resource.title }
default:
return { kind: 'docs', label: resource.title }
}

View File

@@ -81,7 +81,7 @@ export const PlusMenuDropdown = React.memo(
e.preventDefault()
const firstItem = contentRef.current?.querySelector<HTMLElement>('[role="menuitem"]')
firstItem?.focus()
} else if (e.key === 'Enter') {
} else if (e.key === 'Enter' || e.key === 'Tab') {
e.preventDefault()
const first = filteredItemsRef.current?.[0]
if (first) handleSelect({ type: first.type, id: first.item.id, title: first.item.name })
@@ -99,6 +99,12 @@ export const PlusMenuDropdown = React.memo(
e.preventDefault()
searchRef.current?.focus()
}
} else if (e.key === 'Tab') {
const focused = document.activeElement as HTMLElement | null
if (focused?.getAttribute('role') === 'menuitem') {
e.preventDefault()
focused.click()
}
}
}, [])

View File

@@ -3,19 +3,13 @@
import type React from 'react'
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { useParams } from 'next/navigation'
import { Database, Folder as FolderIcon, Table as TableIcon } from '@/components/emcn/icons'
import { getDocumentIcon } from '@/components/icons/document-icons'
import { useSession } from '@/lib/auth/auth-client'
import { SIM_RESOURCE_DRAG_TYPE, SIM_RESOURCES_DRAG_TYPE } from '@/lib/copilot/resource-types'
import { cn } from '@/lib/core/utils/cn'
import { CHAT_ACCEPT_ATTRIBUTE } from '@/lib/uploads/utils/validation'
import { ContextMentionIcon } from '@/app/workspace/[workspaceId]/home/components/context-mention-icon'
import { useAvailableResources } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown'
import type {
PlusMenuHandle,
SpeechRecognitionErrorEvent,
SpeechRecognitionEvent,
SpeechRecognitionInstance,
WindowWithSpeech,
} from '@/app/workspace/[workspaceId]/home/components/user-input/components'
import type { PlusMenuHandle } from '@/app/workspace/[workspaceId]/home/components/user-input/components'
import {
AnimatedPlaceholderEffect,
AttachedFilesList,
@@ -27,7 +21,6 @@ import {
OVERLAY_CLASSES,
PlusMenuDropdown,
SendButton,
SPEECH_RECOGNITION_LANG,
TEXTAREA_BASE_CLASSES,
} from '@/app/workspace/[workspaceId]/home/components/user-input/components'
import type {
@@ -46,6 +39,7 @@ import {
extractContextTokens,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils'
import { useWorkflowMap } from '@/hooks/queries/workflows'
import { useSpeechToText } from '@/hooks/use-speech-to-text'
import type { ChatContext } from '@/stores/panel'
export type { FileAttachmentForApi } from '@/app/workspace/[workspaceId]/home/types'
@@ -108,6 +102,7 @@ interface UserInputProps {
isInitialView?: boolean
userId?: string
onContextAdd?: (context: ChatContext) => void
onContextRemove?: (context: ChatContext) => void
onEnterWhileEmpty?: () => boolean
}
@@ -121,6 +116,7 @@ export function UserInput({
isInitialView = true,
userId,
onContextAdd,
onContextRemove,
onEnterWhileEmpty,
}: UserInputProps) {
const { workspaceId } = useParams<{ workspaceId: string }>()
@@ -170,6 +166,37 @@ export function UserInput({
[addContext, onContextAdd]
)
const onContextRemoveRef = useRef(onContextRemove)
onContextRemoveRef.current = onContextRemove
const prevSelectedContextsRef = useRef<ChatContext[]>([])
useEffect(() => {
const prev = prevSelectedContextsRef.current
const curr = contextManagement.selectedContexts
const contextId = (ctx: ChatContext): string => {
switch (ctx.kind) {
case 'workflow':
case 'current_workflow':
return `${ctx.kind}:${ctx.workflowId}`
case 'knowledge':
return `knowledge:${ctx.knowledgeId ?? ''}`
case 'table':
return `table:${ctx.tableId}`
case 'file':
return `file:${ctx.fileId}`
case 'folder':
return `folder:${ctx.folderId}`
case 'past_chat':
return `past_chat:${ctx.chatId}`
default:
return `${ctx.kind}:${ctx.label}`
}
}
const removed = prev.filter((p) => !curr.some((c) => contextId(c) === contextId(p)))
if (removed.length > 0) removed.forEach((ctx) => onContextRemoveRef.current?.(ctx))
prevSelectedContextsRef.current = curr
}, [contextManagement.selectedContexts])
const existingResourceKeys = useMemo(() => {
const keys = new Set<string>()
for (const ctx of contextManagement.selectedContexts) {
@@ -178,6 +205,7 @@ export function UserInput({
if (ctx.kind === 'table' && ctx.tableId) keys.add(`table:${ctx.tableId}`)
if (ctx.kind === 'file' && ctx.fileId) keys.add(`file:${ctx.fileId}`)
if (ctx.kind === 'folder' && ctx.folderId) keys.add(`folder:${ctx.folderId}`)
if (ctx.kind === 'past_chat' && ctx.chatId) keys.add(`task:${ctx.chatId}`)
}
return keys
}, [contextManagement.selectedContexts])
@@ -201,10 +229,29 @@ export function UserInput({
const canSubmit = (value.trim().length > 0 || hasFiles) && !isSending
const [isListening, setIsListening] = useState(false)
const recognitionRef = useRef<SpeechRecognitionInstance | null>(null)
const prefixRef = useRef('')
const valueRef = useRef(value)
const sttPrefixRef = useRef('')
const handleTranscript = useCallback((text: string) => {
const prefix = sttPrefixRef.current
const newVal = prefix ? `${prefix} ${text}` : text
setValue(newVal)
valueRef.current = newVal
}, [])
const {
isListening,
isSupported: isSttSupported,
toggleListening: rawToggle,
resetTranscript,
} = useSpeechToText({ onTranscript: handleTranscript })
const toggleListening = useCallback(() => {
if (!isListening) {
sttPrefixRef.current = valueRef.current
}
rawToggle()
}, [isListening, rawToggle])
const filesRef = useRef(files)
filesRef.current = files
@@ -215,12 +262,6 @@ export function UserInput({
const isSendingRef = useRef(isSending)
isSendingRef.current = isSending
useEffect(() => {
return () => {
recognitionRef.current?.abort()
}
}, [])
useEffect(() => {
valueRef.current = value
}, [value])
@@ -247,15 +288,17 @@ export function UserInput({
if (textarea) {
const currentValue = valueRef.current
const insertAt = atInsertPosRef.current ?? textarea.selectionStart ?? currentValue.length
atInsertPosRef.current = null
const needsSpaceBefore = insertAt > 0 && !/\s/.test(currentValue.charAt(insertAt - 1))
const insertText = `${needsSpaceBefore ? ' ' : ''}@${resource.title} `
const before = currentValue.slice(0, insertAt)
const after = currentValue.slice(insertAt)
const newValue = `${before}${insertText}${after}`
const newPos = before.length + insertText.length
pendingCursorRef.current = newPos
setValue(`${before}${insertText}${after}`)
// Eagerly sync refs so successive drop-handler iterations see the updated position
valueRef.current = newValue
atInsertPosRef.current = newPos
setValue(newValue)
}
const context = mapResourceToContext(resource)
@@ -281,7 +324,10 @@ export function UserInput({
}, [])
const handleContainerDragOver = useCallback((e: React.DragEvent) => {
if (e.dataTransfer.types.includes('application/x-sim-resource')) {
if (
e.dataTransfer.types.includes(SIM_RESOURCE_DRAG_TYPE) ||
e.dataTransfer.types.includes(SIM_RESOURCES_DRAG_TYPE)
) {
e.preventDefault()
e.stopPropagation()
e.dataTransfer.dropEffect = 'copy'
@@ -292,13 +338,30 @@ export function UserInput({
const handleContainerDrop = useCallback(
(e: React.DragEvent) => {
const resourceJson = e.dataTransfer.getData('application/x-sim-resource')
const resourcesJson = e.dataTransfer.getData(SIM_RESOURCES_DRAG_TYPE)
if (resourcesJson) {
e.preventDefault()
e.stopPropagation()
try {
const resources = JSON.parse(resourcesJson) as MothershipResource[]
for (const resource of resources) {
handleResourceSelect(resource)
}
// Reset after batch so the next non-drop insert uses the cursor position
atInsertPosRef.current = null
} catch {
// Invalid JSON — ignore
}
return
}
const resourceJson = e.dataTransfer.getData(SIM_RESOURCE_DRAG_TYPE)
if (resourceJson) {
e.preventDefault()
e.stopPropagation()
try {
const resource = JSON.parse(resourceJson) as MothershipResource
handleResourceSelect(resource)
atInsertPosRef.current = null
} catch {
// Invalid JSON — ignore
}
@@ -310,11 +373,17 @@ export function UserInput({
)
const handleDragEnter = useCallback((e: React.DragEvent) => {
filesRef.current.handleDragEnter(e)
const isResourceDrag =
e.dataTransfer.types.includes(SIM_RESOURCE_DRAG_TYPE) ||
e.dataTransfer.types.includes(SIM_RESOURCES_DRAG_TYPE)
if (!isResourceDrag) filesRef.current.handleDragEnter(e)
}, [])
const handleDragLeave = useCallback((e: React.DragEvent) => {
filesRef.current.handleDragLeave(e)
const isResourceDrag =
e.dataTransfer.types.includes(SIM_RESOURCE_DRAG_TYPE) ||
e.dataTransfer.types.includes(SIM_RESOURCES_DRAG_TYPE)
if (!isResourceDrag) filesRef.current.handleDragLeave(e)
}, [])
const handleFileChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
@@ -342,84 +411,6 @@ export function UserInput({
[textareaRef]
)
const startRecognition = useCallback((): boolean => {
const w = window as WindowWithSpeech
const SpeechRecognitionAPI = w.SpeechRecognition || w.webkitSpeechRecognition
if (!SpeechRecognitionAPI) return false
const recognition = new SpeechRecognitionAPI()
recognition.continuous = true
recognition.interimResults = true
recognition.lang = SPEECH_RECOGNITION_LANG
recognition.onresult = (event: SpeechRecognitionEvent) => {
let transcript = ''
for (let i = 0; i < event.results.length; i++) {
transcript += event.results[i][0].transcript
}
const prefix = prefixRef.current
const newVal = prefix ? `${prefix} ${transcript}` : transcript
setValue(newVal)
valueRef.current = newVal
}
recognition.onend = () => {
if (recognitionRef.current === recognition) {
prefixRef.current = valueRef.current
try {
recognition.start()
} catch {
recognitionRef.current = null
setIsListening(false)
}
}
}
recognition.onerror = (e: SpeechRecognitionErrorEvent) => {
if (recognitionRef.current !== recognition) return
if (e.error === 'aborted' || e.error === 'not-allowed') {
recognitionRef.current = null
setIsListening(false)
}
}
recognitionRef.current = recognition
try {
recognition.start()
return true
} catch {
recognitionRef.current = null
return false
}
}, [])
const restartRecognition = useCallback(
(newPrefix: string) => {
if (!recognitionRef.current) return
prefixRef.current = newPrefix
recognitionRef.current.abort()
recognitionRef.current = null
if (!startRecognition()) {
setIsListening(false)
}
},
[startRecognition]
)
const toggleListening = useCallback(() => {
if (isListening) {
recognitionRef.current?.stop()
recognitionRef.current = null
setIsListening(false)
return
}
prefixRef.current = valueRef.current
if (startRecognition()) {
setIsListening(true)
}
}, [isListening, startRecognition])
const handleSubmit = useCallback(() => {
const currentFiles = filesRef.current
const currentContext = contextRef.current
@@ -441,14 +432,16 @@ export function UserInput({
currentContext.selectedContexts.length > 0 ? currentContext.selectedContexts : undefined
)
setValue('')
restartRecognition('')
valueRef.current = ''
sttPrefixRef.current = ''
resetTranscript()
currentFiles.clearAttachedFiles()
currentContext.clearContexts()
if (textareaRef.current) {
textareaRef.current.style.height = 'auto'
}
}, [onSubmit, restartRecognition, textareaRef])
}, [onSubmit, textareaRef, resetTranscript])
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
@@ -527,32 +520,27 @@ export function UserInput({
[handleSubmit, mentionTokensWithContext, value, textareaRef]
)
const handleInputChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newValue = e.target.value
const caret = e.target.selectionStart ?? newValue.length
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newValue = e.target.value
const caret = e.target.selectionStart ?? newValue.length
if (
caret > 0 &&
newValue.charAt(caret - 1) === '@' &&
(caret === 1 || /\s/.test(newValue.charAt(caret - 2)))
) {
const before = newValue.slice(0, caret - 1)
const after = newValue.slice(caret)
const adjusted = `${before}${after}`
setValue(adjusted)
atInsertPosRef.current = caret - 1
const anchor = getCaretAnchor(e.target, caret - 1)
plusMenuRef.current?.open(anchor)
restartRecognition(adjusted)
return
}
if (
caret > 0 &&
newValue.charAt(caret - 1) === '@' &&
(caret === 1 || /\s/.test(newValue.charAt(caret - 2)))
) {
const before = newValue.slice(0, caret - 1)
const after = newValue.slice(caret)
const adjusted = `${before}${after}`
setValue(adjusted)
atInsertPosRef.current = caret - 1
const anchor = getCaretAnchor(e.target, caret - 1)
plusMenuRef.current?.open(anchor)
return
}
setValue(newValue)
restartRecognition(newValue)
},
[restartRecognition]
)
setValue(newValue)
}, [])
const handleSelectAdjust = useCallback(() => {
const textarea = textareaRef.current
@@ -643,42 +631,17 @@ export function UserInput({
: range.token
const matchingCtx = contexts.find((c) => c.label === mentionLabel)
let mentionIconNode: React.ReactNode = null
if (matchingCtx) {
const iconClasses = 'absolute inset-0 m-auto h-[12px] w-[12px] text-[var(--text-icon)]'
switch (matchingCtx.kind) {
case 'workflow':
case 'current_workflow': {
const wfId = (matchingCtx as { workflowId: string }).workflowId
const wfColor = workflowsById[wfId]?.color ?? '#888'
mentionIconNode = (
<div
className='absolute inset-0 m-auto h-[12px] w-[12px] rounded-[3px] border-[2px]'
style={{
backgroundColor: wfColor,
borderColor: `${wfColor}60`,
backgroundClip: 'padding-box',
}}
/>
)
break
}
case 'knowledge':
mentionIconNode = <Database className={iconClasses} />
break
case 'table':
mentionIconNode = <TableIcon className={iconClasses} />
break
case 'file': {
const FileDocIcon = getDocumentIcon('', mentionLabel)
mentionIconNode = <FileDocIcon className={iconClasses} />
break
}
case 'folder':
mentionIconNode = <FolderIcon className={iconClasses} />
break
}
}
const wfId =
matchingCtx?.kind === 'workflow' || matchingCtx?.kind === 'current_workflow'
? matchingCtx.workflowId
: undefined
const mentionIconNode = matchingCtx ? (
<ContextMentionIcon
context={matchingCtx}
workflowColor={wfId ? (workflowsById[wfId]?.color ?? null) : null}
className='absolute inset-0 m-auto h-[12px] w-[12px] text-[var(--text-icon)]'
/>
) : null
elements.push(
<span
@@ -766,7 +729,7 @@ export function UserInput({
/>
</div>
<div className='flex items-center gap-1.5'>
<MicButton isListening={isListening} onToggle={toggleListening} />
{isSttSupported && <MicButton isListening={isListening} onToggle={toggleListening} />}
<SendButton
isSending={isSending}
canSubmit={canSubmit}

View File

@@ -2,8 +2,7 @@
import { useMemo } from 'react'
import { useParams } from 'next/navigation'
import { Database, Folder as FolderIcon, Table as TableIcon } from '@/components/emcn/icons'
import { getDocumentIcon } from '@/components/icons/document-icons'
import { ContextMentionIcon } from '@/app/workspace/[workspaceId]/home/components/context-mention-icon'
import type { ChatMessageContext } from '@/app/workspace/[workspaceId]/home/types'
import { useWorkflows } from '@/hooks/queries/workflows'
@@ -53,42 +52,13 @@ function MentionHighlight({ context }: { context: ChatMessageContext }) {
return (workflowList ?? []).find((w) => w.id === context.workflowId)?.color ?? null
}, [workflowList, context.kind, context.workflowId])
let icon: React.ReactNode = null
const iconClasses = 'h-[12px] w-[12px] flex-shrink-0 text-[var(--text-icon)]'
switch (context.kind) {
case 'workflow':
case 'current_workflow':
icon = workflowColor ? (
<span
className='inline-block h-[12px] w-[12px] flex-shrink-0 rounded-[3px] border-[2px]'
style={{
backgroundColor: workflowColor,
borderColor: `${workflowColor}60`,
backgroundClip: 'padding-box',
}}
/>
) : null
break
case 'knowledge':
icon = <Database className={iconClasses} />
break
case 'table':
icon = <TableIcon className={iconClasses} />
break
case 'file': {
const FileDocIcon = getDocumentIcon('', context.label)
icon = <FileDocIcon className={iconClasses} />
break
}
case 'folder':
icon = <FolderIcon className={iconClasses} />
break
}
return (
<span className='inline-flex items-baseline gap-1 rounded-[5px] bg-[var(--surface-5)] px-[5px]'>
{icon && <span className='relative top-0.5 flex-shrink-0'>{icon}</span>}
<ContextMentionIcon
context={context}
workflowColor={workflowColor}
className='relative top-0.5 h-[12px] w-[12px] flex-shrink-0 text-[var(--text-icon)]'
/>
{context.label}
</span>
)

View File

@@ -17,7 +17,7 @@ import { useChatHistory, useMarkTaskRead } from '@/hooks/queries/tasks'
import type { ChatContext } from '@/stores/panel'
import { MothershipChat, MothershipView, TemplatePrompts, UserInput } from './components'
import { getMothershipUseChatOptions, useChat, useMothershipResize } from './hooks'
import type { FileAttachmentForApi, MothershipResource, MothershipResourceType } from './types'
import type { FileAttachmentForApi, MothershipResourceType } from './types'
const logger = createLogger('Home')
@@ -223,6 +223,14 @@ export function Home({ chatId }: HomeProps = {}) {
posthogRef.current = posthog
}, [posthog])
const handleStopGeneration = useCallback(() => {
captureEvent(posthogRef.current, 'task_generation_aborted', {
workspace_id: workspaceId,
view: 'mothership',
})
stopGeneration()
}, [stopGeneration, workspaceId])
const handleSubmit = useCallback(
(text: string, fileAttachments?: FileAttachmentForApi[], contexts?: ChatContext[]) => {
const trimmed = text.trim()
@@ -253,51 +261,42 @@ export function Home({ chatId }: HomeProps = {}) {
return () => window.removeEventListener('mothership-send-message', handler)
}, [sendMessage])
const handleContextAdd = useCallback(
(context: ChatContext) => {
let resourceType: MothershipResourceType | null = null
let resourceId: string | null = null
const resourceTitle: string = context.label
const resolveResourceFromContext = useCallback(
(context: ChatContext): { type: MothershipResourceType; id: string } | null => {
switch (context.kind) {
case 'workflow':
case 'current_workflow':
resourceType = 'workflow'
resourceId = context.workflowId
break
return context.workflowId ? { type: 'workflow', id: context.workflowId } : null
case 'knowledge':
if (context.knowledgeId) {
resourceType = 'knowledgebase'
resourceId = context.knowledgeId
}
break
return context.knowledgeId ? { type: 'knowledgebase', id: context.knowledgeId } : null
case 'table':
if (context.tableId) {
resourceType = 'table'
resourceId = context.tableId
}
break
return context.tableId ? { type: 'table', id: context.tableId } : null
case 'file':
if (context.fileId) {
resourceType = 'file'
resourceId = context.fileId
}
break
return context.fileId ? { type: 'file', id: context.fileId } : null
default:
break
return null
}
},
[]
)
if (resourceType && resourceId) {
const resource: MothershipResource = {
type: resourceType,
id: resourceId,
title: resourceTitle,
}
addResource(resource)
const handleContextAdd = useCallback(
(context: ChatContext) => {
const resolved = resolveResourceFromContext(context)
if (resolved) {
addResource({ ...resolved, title: context.label })
handleResourceEvent()
}
},
[addResource, handleResourceEvent]
[resolveResourceFromContext, addResource, handleResourceEvent]
)
const handleContextRemove = useCallback(
(context: ChatContext) => {
const resolved = resolveResourceFromContext(context)
if (resolved) removeResource(resolved.type, resolved.id)
},
[resolveResourceFromContext, removeResource]
)
const hasMessages = messages.length > 0
@@ -334,9 +333,10 @@ export function Home({ chatId }: HomeProps = {}) {
defaultValue={initialPrompt}
onSubmit={handleSubmit}
isSending={isSending}
onStopGeneration={stopGeneration}
onStopGeneration={handleStopGeneration}
userId={session?.user?.id}
onContextAdd={handleContextAdd}
onContextRemove={handleContextRemove}
/>
</div>
</div>
@@ -359,7 +359,7 @@ export function Home({ chatId }: HomeProps = {}) {
isSending={isSending}
isReconnecting={isReconnecting}
onSubmit={handleSubmit}
onStopGeneration={stopGeneration}
onStopGeneration={handleStopGeneration}
messageQueue={messageQueue}
onRemoveQueuedMessage={removeFromQueue}
onSendQueuedMessage={sendNow}
@@ -367,6 +367,7 @@ export function Home({ chatId }: HomeProps = {}) {
userId={session?.user?.id}
chatId={resolvedChatId}
onContextAdd={handleContextAdd}
onContextRemove={handleContextRemove}
editValue={editingInputValue}
onEditValueConsumed={clearEditingValue}
animateInput={isInputEntering}

View File

@@ -6,6 +6,9 @@ export type {
MothershipResourceType,
} from '@/lib/copilot/resource-types'
/** Union of all valid context kind strings, derived from {@link ChatContext}. */
export type ChatContextKind = ChatContext['kind']
export interface FileAttachmentForApi {
id: string
key: string
@@ -260,13 +263,14 @@ export interface ChatMessageAttachment {
}
export interface ChatMessageContext {
kind: string
kind: ChatContextKind
label: string
workflowId?: string
knowledgeId?: string
tableId?: string
fileId?: string
folderId?: string
chatId?: string
}
export interface ChatMessage {

View File

@@ -2,9 +2,10 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { format, formatDistanceToNow } from 'date-fns'
import { format, formatDistanceToNow, isPast } from 'date-fns'
import {
AlertCircle,
AlertTriangle,
CheckCircle2,
ChevronDown,
Loader2,
@@ -66,6 +67,7 @@ const STATUS_CONFIG = {
syncing: { label: 'Syncing', variant: 'amber' as const },
error: { label: 'Error', variant: 'red' as const },
paused: { label: 'Paused', variant: 'gray' as const },
disabled: { label: 'Disabled', variant: 'amber' as const },
} as const
export function ConnectorsSection({
@@ -159,7 +161,10 @@ export function ConnectorsSection({
knowledgeBaseId,
connectorId: connector.id,
updates: {
status: connector.status === 'paused' ? 'active' : 'paused',
status:
connector.status === 'paused' || connector.status === 'disabled'
? 'active'
: 'paused',
},
},
{
@@ -352,7 +357,12 @@ function ConnectorCard({
<div className='rounded-lg border border-[var(--border-1)]'>
<div className='flex items-center justify-between px-3 py-2.5'>
<div className='flex items-center gap-2.5'>
{Icon && <Icon className='h-5 w-5 flex-shrink-0' />}
<div className='relative flex-shrink-0'>
{Icon && <Icon className='h-5 w-5' />}
{connector.status === 'disabled' && (
<AlertTriangle className='-right-1 -top-1 absolute h-3 w-3 text-amber-500' />
)}
</div>
<div className='flex flex-col gap-0.5'>
<div className='flex items-center gap-2'>
<span className='flex items-center gap-1.5 font-medium text-[var(--text-primary)] text-small'>
@@ -380,7 +390,9 @@ function ConnectorCard({
<span>·</span>
<span>
Next sync:{' '}
{formatDistanceToNow(new Date(connector.nextSyncAt), { addSuffix: true })}
{isPast(new Date(connector.nextSyncAt))
? 'pending'
: formatDistanceToNow(new Date(connector.nextSyncAt), { addSuffix: true })}
</span>
</>
)}
@@ -405,7 +417,12 @@ function ConnectorCard({
variant='ghost'
className='h-7 w-7 p-0'
onClick={onSync}
disabled={connector.status === 'syncing' || isSyncPending || syncCooldown}
disabled={
connector.status === 'syncing' ||
connector.status === 'disabled' ||
isSyncPending ||
syncCooldown
}
>
<RefreshCw
className={cn(
@@ -439,7 +456,7 @@ function ConnectorCard({
>
{isUpdating ? (
<Loader2 className='h-3.5 w-3.5 animate-spin' />
) : connector.status === 'paused' ? (
) : connector.status === 'paused' || connector.status === 'disabled' ? (
<Play className='h-3.5 w-3.5' />
) : (
<Pause className='h-3.5 w-3.5' />
@@ -447,7 +464,9 @@ function ConnectorCard({
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
{connector.status === 'paused' ? 'Resume' : 'Pause'}
{connector.status === 'paused' || connector.status === 'disabled'
? 'Resume'
: 'Pause'}
</Tooltip.Content>
</Tooltip.Root>
@@ -479,7 +498,46 @@ function ConnectorCard({
</div>
</div>
{missingScopes.length > 0 && (
{connector.status === 'disabled' && (
<div className='border-[var(--border-1)] border-t px-3 py-2'>
<div className='flex flex-col gap-1 rounded-sm border border-amber-200 bg-amber-50 px-2 py-1.5 dark:border-amber-900 dark:bg-amber-950'>
<div className='flex items-center gap-1.5 font-medium text-amber-800 text-caption dark:text-amber-200'>
<AlertTriangle className='h-3 w-3 flex-shrink-0' />
Connector disabled after repeated sync failures
</div>
<p className='text-amber-700 text-micro dark:text-amber-300'>
Syncing has been paused due to {connector.consecutiveFailures} consecutive failures.
{serviceId
? ' Reconnect your account to resume syncing.'
: ' Use the resume button to re-enable syncing.'}
</p>
{canEdit && serviceId && providerId && (
<Button
variant='active'
onClick={() => {
if (connector.credentialId) {
writeOAuthReturnContext({
origin: 'kb-connectors',
knowledgeBaseId,
displayName: connectorDef?.name ?? connector.connectorType,
providerId: providerId!,
preCount: credentials?.length ?? 0,
workspaceId,
requestedAt: Date.now(),
})
}
setShowOAuthModal(true)
}}
className='w-full px-2 py-1 font-medium text-caption'
>
Reconnect
</Button>
)}
</div>
</div>
)}
{missingScopes.length > 0 && connector.status !== 'disabled' && (
<div className='border-[var(--border-1)] border-t px-3 py-2'>
<div className='flex flex-col gap-1 rounded-sm border bg-[var(--surface-2)] px-2 py-1.5'>
<div className='flex items-center font-medium text-caption'>

View File

@@ -1,6 +1,7 @@
import { memo } from 'react'
import { useParams } from 'next/navigation'
import { cn } from '@/lib/core/utils/cn'
import { workflowBorderColor } from '@/lib/workspaces/colors'
import {
DELETED_WORKFLOW_COLOR,
DELETED_WORKFLOW_LABEL,
@@ -93,7 +94,7 @@ function WorkflowsListInner({
className='h-[10px] w-[10px] flex-shrink-0 rounded-[3px] border-[1.5px]'
style={{
backgroundColor: workflowColor,
borderColor: `${workflowColor}60`,
borderColor: workflowBorderColor(workflowColor),
backgroundClip: 'padding-box',
}}
/>

View File

@@ -20,6 +20,7 @@ import { BASE_EXECUTION_CHARGE } from '@/lib/billing/constants'
import { cn } from '@/lib/core/utils/cn'
import { formatDuration } from '@/lib/core/utils/formatting'
import { filterHiddenOutputKeys } from '@/lib/logs/execution/trace-spans/trace-spans'
import { workflowBorderColor } from '@/lib/workspaces/colors'
import {
ExecutionSnapshot,
FileCards,
@@ -431,7 +432,7 @@ export const LogDetails = memo(function LogDetails({
className='h-[10px] w-[10px] flex-shrink-0 rounded-[3px] border-[1.5px]'
style={{
backgroundColor: c,
borderColor: c ? `${c}60` : undefined,
borderColor: c ? workflowBorderColor(c) : undefined,
backgroundClip: 'padding-box',
}}
/>

View File

@@ -8,6 +8,7 @@ import { Badge, buttonVariants } from '@/components/emcn'
import { dollarsToCredits } from '@/lib/billing/credits/conversion'
import { cn } from '@/lib/core/utils/cn'
import { formatDuration } from '@/lib/core/utils/formatting'
import { workflowBorderColor } from '@/lib/workspaces/colors'
import {
DELETED_WORKFLOW_COLOR,
DELETED_WORKFLOW_LABEL,
@@ -90,7 +91,7 @@ const LogRow = memo(
className='h-[10px] w-[10px] flex-shrink-0 rounded-[3px] border-[1.5px]'
style={{
backgroundColor: workflowColor,
borderColor: `${workflowColor}60`,
borderColor: workflowBorderColor(workflowColor),
backgroundClip: 'padding-box',
}}
/>

View File

@@ -20,6 +20,7 @@ import { cn } from '@/lib/core/utils/cn'
import { hasActiveFilters } from '@/lib/logs/filters'
import { getTriggerOptions } from '@/lib/logs/get-trigger-options'
import { captureEvent } from '@/lib/posthog/client'
import { workflowBorderColor } from '@/lib/workspaces/colors'
import { type LogStatus, STATUS_CONFIG } from '@/app/workspace/[workspaceId]/logs/utils'
import { getBlock } from '@/blocks/registry'
import { useFolderMap } from '@/hooks/queries/folders'
@@ -124,7 +125,7 @@ function getColorIcon(
width: 10,
height: 10,
...(withRing && {
borderColor: `${color}60`,
borderColor: workflowBorderColor(color),
backgroundClip: 'padding-box' as const,
}),
}}
@@ -604,7 +605,7 @@ export const LogsToolbar = memo(function LogsToolbar({
className='h-[8px] w-[8px] flex-shrink-0 rounded-xs border-[1.5px]'
style={{
backgroundColor: selectedWorkflow.color,
borderColor: `${selectedWorkflow.color}60`,
borderColor: workflowBorderColor(selectedWorkflow.color),
backgroundClip: 'padding-box',
}}
/>
@@ -735,7 +736,7 @@ export const LogsToolbar = memo(function LogsToolbar({
className='h-[8px] w-[8px] flex-shrink-0 rounded-xs border-[1.5px]'
style={{
backgroundColor: selectedWorkflow.color,
borderColor: `${selectedWorkflow.color}60`,
borderColor: workflowBorderColor(selectedWorkflow.color),
backgroundClip: 'padding-box',
}}
/>

View File

@@ -33,6 +33,7 @@ import {
type TriggerData,
type WorkflowData,
} from '@/lib/logs/search-suggestions'
import { workflowBorderColor } from '@/lib/workspaces/colors'
import type {
FilterTag,
HeaderAction,
@@ -157,7 +158,7 @@ function getColorIcon(
width: 10,
height: 10,
...(withRing && {
borderColor: `${color}60`,
borderColor: workflowBorderColor(color),
backgroundClip: 'padding-box' as const,
}),
}}
@@ -742,7 +743,7 @@ export default function Logs() {
className='h-[10px] w-[10px] rounded-[3px] border-[1.5px]'
style={{
backgroundColor: workflowColor,
borderColor: `${workflowColor}60`,
borderColor: workflowBorderColor(workflowColor),
backgroundClip: 'padding-box',
}}
/>
@@ -1441,7 +1442,7 @@ function LogsFilterPanel({ searchQuery, onSearchQueryChange }: LogsFilterPanelPr
className='h-[8px] w-[8px] flex-shrink-0 rounded-xs border-[1.5px]'
style={{
backgroundColor: selectedWorkflow.color,
borderColor: `${selectedWorkflow.color}60`,
borderColor: workflowBorderColor(selectedWorkflow.color),
backgroundClip: 'padding-box',
}}
/>

View File

@@ -227,123 +227,128 @@ export function Admin() {
<div
key={u.id}
className={cn(
'flex items-center gap-3 px-3 py-2 text-small',
'flex flex-col gap-2 px-3 py-2 text-small',
'border-[var(--border-secondary)] border-b last:border-b-0'
)}
>
<span className='w-[200px] truncate text-[var(--text-primary)]'>
{u.name || '—'}
</span>
<span className='flex-1 truncate text-[var(--text-secondary)]'>{u.email}</span>
<span className='w-[80px]'>
<Badge variant={u.role === 'admin' ? 'blue' : 'gray'}>{u.role || 'user'}</Badge>
</span>
<span className='w-[80px]'>
{u.banned ? (
<Badge variant='red'>Banned</Badge>
) : (
<Badge variant='green'>Active</Badge>
)}
</span>
<span className='flex w-[250px] justify-end gap-1'>
{u.id !== session?.user?.id && (
<>
<Button
variant='active'
className='h-[28px] px-2 text-[12px]'
onClick={() => handleImpersonate(u.id)}
disabled={pendingUserIds.has(u.id)}
>
{impersonatingUserId === u.id ||
(impersonateUser.isPending &&
(impersonateUser.variables as { userId?: string } | undefined)
?.userId === u.id)
? 'Switching...'
: 'Impersonate'}
</Button>
<Button
variant='active'
className='h-[28px] px-2 text-[12px]'
onClick={() => {
setUserRole.reset()
setUserRole.mutate({
userId: u.id,
role: u.role === 'admin' ? 'user' : 'admin',
})
}}
disabled={pendingUserIds.has(u.id)}
>
{u.role === 'admin' ? 'Demote' : 'Promote'}
</Button>
{u.banned ? (
<div className='flex items-center gap-3'>
<span className='w-[200px] truncate text-[var(--text-primary)]'>
{u.name || '—'}
</span>
<span className='flex-1 truncate text-[var(--text-secondary)]'>{u.email}</span>
<span className='w-[80px]'>
<Badge variant={u.role === 'admin' ? 'blue' : 'gray'}>
{u.role || 'user'}
</Badge>
</span>
<span className='w-[80px]'>
{u.banned ? (
<Badge variant='red'>Banned</Badge>
) : (
<Badge variant='green'>Active</Badge>
)}
</span>
<span className='flex w-[250px] justify-end gap-1'>
{u.id !== session?.user?.id && (
<>
<Button
variant='active'
className='h-[28px] px-2 text-caption'
className='h-[28px] px-2 text-[12px]'
onClick={() => handleImpersonate(u.id)}
disabled={pendingUserIds.has(u.id)}
>
{impersonatingUserId === u.id ||
(impersonateUser.isPending &&
(impersonateUser.variables as { userId?: string } | undefined)
?.userId === u.id)
? 'Switching...'
: 'Impersonate'}
</Button>
<Button
variant='active'
className='h-[28px] px-2 text-[12px]'
onClick={() => {
unbanUser.reset()
unbanUser.mutate({ userId: u.id })
setUserRole.reset()
setUserRole.mutate({
userId: u.id,
role: u.role === 'admin' ? 'user' : 'admin',
})
}}
disabled={pendingUserIds.has(u.id)}
>
Unban
{u.role === 'admin' ? 'Demote' : 'Promote'}
</Button>
) : banUserId === u.id ? (
<div className='flex gap-1'>
<EmcnInput
value={banReason}
onChange={(e) => setBanReason(e.target.value)}
placeholder='Reason (optional)'
className='h-[28px] w-[120px] text-caption'
/>
<Button
variant='primary'
className='h-[28px] px-2 text-caption'
onClick={() => {
banUser.reset()
banUser.mutate(
{
userId: u.id,
...(banReason.trim() ? { banReason: banReason.trim() } : {}),
},
{
onSuccess: () => {
setBanUserId(null)
setBanReason('')
},
}
)
}}
disabled={pendingUserIds.has(u.id)}
>
Confirm
</Button>
{u.banned ? (
<Button
variant='active'
className='h-[28px] px-2 text-caption'
onClick={() => {
unbanUser.reset()
unbanUser.mutate({ userId: u.id })
}}
disabled={pendingUserIds.has(u.id)}
>
Unban
</Button>
) : (
<Button
variant='active'
className={cn(
'h-[28px] px-2 text-caption',
banUserId === u.id
? 'text-[var(--text-primary)]'
: 'text-[var(--text-error)]'
)}
onClick={() => {
if (banUserId === u.id) {
setBanUserId(null)
setBanReason('')
} else {
setBanUserId(u.id)
setBanReason('')
}
}}
disabled={pendingUserIds.has(u.id)}
>
{banUserId === u.id ? 'Cancel' : 'Ban'}
</Button>
)}
</>
)}
</span>
</div>
{banUserId === u.id && !u.banned && (
<div className='flex items-center gap-2 pl-[200px]'>
<EmcnInput
value={banReason}
onChange={(e) => setBanReason(e.target.value)}
placeholder='Reason (optional)'
className='h-[28px] flex-1 text-caption'
/>
<Button
variant='primary'
className='h-[28px] px-3 text-caption'
onClick={() => {
banUser.reset()
banUser.mutate(
{
userId: u.id,
...(banReason.trim() ? { banReason: banReason.trim() } : {}),
},
{
onSuccess: () => {
setBanUserId(null)
setBanReason('')
}}
>
Cancel
</Button>
</div>
) : (
<Button
variant='active'
className='h-[28px] px-2 text-[var(--text-error)] text-caption'
onClick={() => {
setBanUserId(u.id)
setBanReason('')
}}
disabled={pendingUserIds.has(u.id)}
>
Ban
</Button>
)}
</>
)}
</span>
},
}
)
}}
disabled={pendingUserIds.has(u.id)}
>
Confirm Ban
</Button>
</div>
)}
</div>
))}
</div>

View File

@@ -2,6 +2,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useQueryClient } from '@tanstack/react-query'
import { Check, Clipboard, Key, Search } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import {
@@ -42,6 +43,7 @@ import {
useWorkspaceCredentials,
type WorkspaceCredential,
type WorkspaceCredentialRole,
workspaceCredentialKeys,
} from '@/hooks/queries/credentials'
import {
usePersonalEnvironment,
@@ -125,9 +127,11 @@ interface WorkspaceVariableRowProps {
renamingKey: string | null
pendingKeyValue: string
hasCredential: boolean
canEdit: boolean
onRenameStart: (key: string) => void
onPendingKeyChange: (value: string) => void
onRenameEnd: (key: string, value: string) => void
onValueChange: (key: string, value: string) => void
onDelete: (key: string) => void
onViewDetails: (envKey: string) => void
}
@@ -138,12 +142,16 @@ function WorkspaceVariableRow({
renamingKey,
pendingKeyValue,
hasCredential,
canEdit,
onRenameStart,
onPendingKeyChange,
onRenameEnd,
onValueChange,
onDelete,
onViewDetails,
}: WorkspaceVariableRowProps) {
const [valueFocused, setValueFocused] = useState(false)
return (
<div className='contents'>
<EmcnInput
@@ -158,13 +166,27 @@ function WorkspaceVariableRow({
autoCapitalize='off'
spellCheck='false'
readOnly
onFocus={(e) => e.target.removeAttribute('readOnly')}
onFocus={(e) => {
if (canEdit) e.target.removeAttribute('readOnly')
}}
className='h-9'
/>
<div />
<EmcnInput
value={value ? '\u2022'.repeat(value.length) : ''}
value={canEdit ? value : value ? '\u2022'.repeat(value.length) : ''}
type={canEdit && !valueFocused ? 'password' : 'text'}
onChange={(e) => onValueChange(envKey, e.target.value)}
readOnly
onFocus={(e) => {
if (canEdit) {
setValueFocused(true)
e.target.removeAttribute('readOnly')
}
}}
onBlur={() => {
if (canEdit) setValueFocused(false)
}}
name={`workspace_env_value_${envKey}_${Math.random()}`}
autoComplete='off'
autoCorrect='off'
autoCapitalize='off'
@@ -179,14 +201,18 @@ function WorkspaceVariableRow({
>
Details
</Button>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button variant='ghost' onClick={() => onDelete(envKey)} className='h-9 w-9'>
<Trash />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>Delete secret</Tooltip.Content>
</Tooltip.Root>
{canEdit ? (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button variant='ghost' onClick={() => onDelete(envKey)} className='h-9 w-9'>
<Trash />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>Delete secret</Tooltip.Content>
</Tooltip.Root>
) : (
<div />
)}
</div>
)
}
@@ -298,6 +324,14 @@ export function CredentialsManager() {
)
const { data: workspacePermissions } = useWorkspacePermissionsQuery(workspaceId || null)
const queryClient = useQueryClient()
const isWorkspaceAdmin = useMemo(() => {
const userId = session?.user?.id
if (!userId || !workspacePermissions?.users) return false
const currentUser = workspacePermissions.users.find((user) => user.userId === userId)
return currentUser?.permissionType === 'admin'
}, [session?.user?.id, workspacePermissions?.users])
const isLoading = isPersonalLoading || isWorkspaceLoading
const variables = useMemo(() => personalEnvData || {}, [personalEnvData])
@@ -767,6 +801,10 @@ export function CredentialsManager() {
[pendingKeyValue, renamingKey]
)
const handleWorkspaceValueChange = useCallback((key: string, value: string) => {
setWorkspaceVars((prev) => ({ ...prev, [key]: value }))
}, [])
const handleDeleteWorkspaceVar = useCallback((key: string) => {
setWorkspaceVars((prev) => {
const next = { ...prev }
@@ -923,6 +961,7 @@ export function CredentialsManager() {
const prevInitialVars = [...initialVarsRef.current]
const prevInitialWorkspaceVars = { ...initialWorkspaceVarsRef.current }
const mutations: Promise<unknown>[] = []
try {
setShowUnsavedChanges(false)
@@ -944,8 +983,6 @@ export function CredentialsManager() {
.filter((v) => v.key && v.value)
.reduce<Record<string, string>>((acc, { key, value }) => ({ ...acc, [key]: value }), {})
await savePersonalMutation.mutateAsync({ variables: validVariables })
const before = prevInitialWorkspaceVars
const after = mergedWorkspaceVars
const toUpsert: Record<string, string> = {}
@@ -961,14 +998,37 @@ export function CredentialsManager() {
if (!(k in after)) toDelete.push(k)
}
if (workspaceId) {
if (Object.keys(toUpsert).length) {
await upsertWorkspaceMutation.mutateAsync({ workspaceId, variables: toUpsert })
}
if (toDelete.length) {
await removeWorkspaceMutation.mutateAsync({ workspaceId, keys: toDelete })
const personalChanged = (() => {
const initialMap = new Map(
prevInitialVars.filter((v) => v.key && v.value).map((v) => [v.key, v.value])
)
const currentKeys = Object.keys(validVariables)
if (initialMap.size !== currentKeys.length) return true
for (const [key, value] of Object.entries(validVariables)) {
if (initialMap.get(key) !== value) return true
}
return false
})()
if (personalChanged) {
mutations.push(savePersonalMutation.mutateAsync({ variables: validVariables }))
}
if (workspaceId && (Object.keys(toUpsert).length || toDelete.length)) {
mutations.push(
(async () => {
if (Object.keys(toUpsert).length) {
await upsertWorkspaceMutation.mutateAsync({ workspaceId, variables: toUpsert })
}
if (toDelete.length) {
await removeWorkspaceMutation.mutateAsync({ workspaceId, keys: toDelete })
}
})()
)
}
const results = await Promise.allSettled(mutations)
const firstFailure = results.find((r): r is PromiseRejectedResult => r.status === 'rejected')
if (firstFailure) throw firstFailure.reason
setWorkspaceVars(mergedWorkspaceVars)
setNewWorkspaceRows([createEmptyEnvVar()])
@@ -977,17 +1037,13 @@ export function CredentialsManager() {
initialVarsRef.current = prevInitialVars
initialWorkspaceVarsRef.current = prevInitialWorkspaceVars
logger.error('Failed to save environment variables:', error)
} finally {
if (mutations.length > 0) {
queryClient.invalidateQueries({ queryKey: workspaceCredentialKeys.lists() })
}
}
}, [
isListSaving,
envVars,
workspaceVars,
newWorkspaceRows,
workspaceId,
savePersonalMutation,
upsertWorkspaceMutation,
removeWorkspaceMutation,
])
// eslint-disable-next-line react-hooks/exhaustive-deps -- mutation objects and queryClient are stable (TanStack Query v5)
}, [isListSaving, envVars, workspaceVars, newWorkspaceRows, workspaceId])
const handleDiscardAndNavigate = useCallback(() => {
shouldBlockNavRef.current = false
@@ -1494,24 +1550,27 @@ export function CredentialsManager() {
renamingKey={renamingKey}
pendingKeyValue={pendingKeyValue}
hasCredential={envKeyToCredential.has(key)}
canEdit={isWorkspaceAdmin}
onRenameStart={setRenamingKey}
onPendingKeyChange={setPendingKeyValue}
onRenameEnd={handleWorkspaceKeyRename}
onValueChange={handleWorkspaceValueChange}
onDelete={handleDeleteWorkspaceVar}
onViewDetails={(envKey) => handleViewDetails(envKey, 'env_workspace')}
/>
))}
{(searchTerm.trim()
? filteredNewWorkspaceRows
: newWorkspaceRows.map((row, index) => ({ row, originalIndex: index }))
).map(({ row, originalIndex }) => (
<NewWorkspaceVariableRow
key={row.id || originalIndex}
envVar={row}
index={originalIndex}
onUpdate={updateNewWorkspaceRow}
/>
))}
{isWorkspaceAdmin &&
(searchTerm.trim()
? filteredNewWorkspaceRows
: newWorkspaceRows.map((row, index) => ({ row, originalIndex: index }))
).map(({ row, originalIndex }) => (
<NewWorkspaceVariableRow
key={row.id || originalIndex}
envVar={row}
index={originalIndex}
onUpdate={updateNewWorkspaceRow}
/>
))}
<div className={`${COL_SPAN_ALL} h-[8px]`} />
</>
)}

View File

@@ -6,6 +6,7 @@ import { useParams, useRouter } from 'next/navigation'
import { Button, Combobox, SModalTabs, SModalTabsList, SModalTabsTrigger } from '@/components/emcn'
import { Input } from '@/components/ui'
import { formatDate } from '@/lib/core/utils/formatting'
import { workflowBorderColor } from '@/lib/workspaces/colors'
import { RESOURCE_REGISTRY } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry'
import type { MothershipResourceType } from '@/app/workspace/[workspaceId]/home/types'
import { DeletedItemSkeleton } from '@/app/workspace/[workspaceId]/settings/components/recently-deleted/deleted-item-skeleton'
@@ -97,7 +98,7 @@ function ResourceIcon({ resource }: { resource: DeletedResource }) {
className='h-[14px] w-[14px] shrink-0 rounded-[3px] border-[2px]'
style={{
backgroundColor: color,
borderColor: `${color}60`,
borderColor: workflowBorderColor(color),
backgroundClip: 'padding-box',
}}
/>

View File

@@ -4,6 +4,7 @@ import { memo, useCallback, useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { History, Plus, Square } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import { usePostHog } from 'posthog-js/react'
import { useShallow } from 'zustand/react/shallow'
import {
BubbleChatClose,
@@ -33,6 +34,7 @@ import {
import { Lock, Unlock, Upload } from '@/components/emcn/icons'
import { VariableIcon } from '@/components/icons'
import { useSession } from '@/lib/auth/auth-client'
import { captureEvent } from '@/lib/posthog/client'
import { generateWorkflowJson } from '@/lib/workflows/operations/import-export'
import { ConversationListItem } from '@/app/workspace/[workspaceId]/components'
import { MothershipChat } from '@/app/workspace/[workspaceId]/home/components'
@@ -101,6 +103,9 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel
const params = useParams()
const workspaceId = propWorkspaceId ?? (params.workspaceId as string)
const posthog = usePostHog()
const posthogRef = useRef(posthog)
const panelRef = useRef<HTMLElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const { activeTab, setActiveTab, panelWidth, _hasHydrated, setHasHydrated } = usePanelStore(
@@ -264,6 +269,10 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel
loadCopilotChats()
}, [loadCopilotChats])
useEffect(() => {
posthogRef.current = posthog
}, [posthog])
const handleCopilotSelectChat = useCallback((chat: { id: string; title: string | null }) => {
setCopilotChatId(chat.id)
setCopilotChatTitle(chat.title)
@@ -394,6 +403,14 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel
[copilotEditQueuedMessage]
)
const handleCopilotStopGeneration = useCallback(() => {
captureEvent(posthogRef.current, 'task_generation_aborted', {
workspace_id: workspaceId,
view: 'copilot',
})
copilotStopGeneration()
}, [copilotStopGeneration, workspaceId])
const handleCopilotSubmit = useCallback(
(text: string, fileAttachments?: FileAttachmentForApi[], contexts?: ChatContext[]) => {
const trimmed = text.trim()
@@ -833,7 +850,7 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel
isSending={copilotIsSending}
isReconnecting={copilotIsReconnecting}
onSubmit={handleCopilotSubmit}
onStopGeneration={copilotStopGeneration}
onStopGeneration={handleCopilotStopGeneration}
messageQueue={copilotMessageQueue}
onRemoveQueuedMessage={copilotRemoveFromQueue}
onSendQueuedMessage={copilotSendNow}

View File

@@ -14,6 +14,7 @@ import {
} from '@/components/emcn'
import { Pencil, SquareArrowUpRight } from '@/components/emcn/icons'
import { cn } from '@/lib/core/utils/cn'
import { workflowBorderColor } from '@/lib/workspaces/colors'
import { ConversationListItem } from '@/app/workspace/[workspaceId]/components'
import type { useHoverMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
import type { FolderTreeNode } from '@/stores/folders/types'
@@ -131,7 +132,7 @@ function WorkflowColorSwatch({ color }: { color: string }) {
className='h-[16px] w-[16px] flex-shrink-0 rounded-sm border-[2.5px]'
style={{
backgroundColor: color,
borderColor: `${color}60`,
borderColor: workflowBorderColor(color),
backgroundClip: 'padding-box',
}}
/>

View File

@@ -5,6 +5,7 @@ import { memo } from 'react'
import { Command } from 'cmdk'
import { Blimp } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import { workflowBorderColor } from '@/lib/workspaces/colors'
import type { CommandItemProps } from '../utils'
import { COMMAND_ITEM_CLASSNAME } from '../utils'
@@ -64,7 +65,7 @@ export const MemoizedWorkflowItem = memo(
className='h-[14px] w-[14px] flex-shrink-0 rounded-sm border-[2px]'
style={{
backgroundColor: color,
borderColor: `${color}60`,
borderColor: workflowBorderColor(color),
backgroundClip: 'padding-box',
}}
/>

View File

@@ -5,6 +5,7 @@ import { createLogger } from '@sim/logger'
import clsx from 'clsx'
import { ChevronRight, Folder, FolderOpen, MoreHorizontal } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import { SIM_RESOURCES_DRAG_TYPE } from '@/lib/copilot/resource-types'
import { generateId } from '@/lib/core/utils/uuid'
import { getNextWorkflowColor } from '@/lib/workflows/colors'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
@@ -18,6 +19,10 @@ import {
useSidebarDragContext,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
import { SIDEBAR_SCROLL_EVENT } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar'
import {
buildDragResources,
createSidebarDragGhost,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/utils'
import {
useCanDelete,
useDeleteFolder,
@@ -136,6 +141,7 @@ export function FolderItem({
})
const isEditingRef = useRef(false)
const dragGhostRef = useRef<HTMLElement | null>(null)
const handleCreateWorkflowInFolder = useCallback(() => {
const name = generateCreativeWorkflowName()
@@ -196,10 +202,24 @@ export function FolderItem({
}
e.dataTransfer.setData('sidebar-selection', JSON.stringify(selection))
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.effectAllowed = 'copyMove'
const resources = buildDragResources(selection, workspaceId)
if (resources.length > 0) {
e.dataTransfer.setData(SIM_RESOURCES_DRAG_TYPE, JSON.stringify(resources))
}
const total = selection.folderIds.length + selection.workflowIds.length
const ghostLabel = total > 1 ? `${folder.name} +${total - 1} more` : folder.name
const icon = total === 1 ? { kind: 'folder' as const } : undefined
const ghost = createSidebarDragGhost(ghostLabel, icon)
void ghost.offsetHeight
e.dataTransfer.setDragImage(ghost, ghost.offsetWidth / 2, ghost.offsetHeight / 2)
dragGhostRef.current = ghost
onDragStartProp?.()
},
[folder.id, onDragStartProp]
[folder.id, folder.name, workspaceId, onDragStartProp]
)
const {
@@ -212,6 +232,10 @@ export function FolderItem({
})
const handleDragEnd = useCallback(() => {
if (dragGhostRef.current) {
dragGhostRef.current.remove()
dragGhostRef.current = null
}
handleDragEndBase()
onDragEndProp?.()
}, [handleDragEndBase, onDragEndProp])

View File

@@ -5,6 +5,8 @@ import clsx from 'clsx'
import { MoreHorizontal } from 'lucide-react'
import Link from 'next/link'
import { useParams } from 'next/navigation'
import { SIM_RESOURCES_DRAG_TYPE } from '@/lib/copilot/resource-types'
import { workflowBorderColor } from '@/lib/workspaces/colors'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { getWorkflowLockToggleIds } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils'
import { ContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu'
@@ -16,6 +18,10 @@ import {
useItemRename,
useSidebarDragContext,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
import {
buildDragResources,
createSidebarDragGhost,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/utils'
import {
useCanDelete,
useDeleteSelection,
@@ -198,6 +204,7 @@ export function WorkflowItem({
}, [isActiveWorkflow, isWorkflowLocked])
const isEditingRef = useRef(false)
const dragGhostRef = useRef<HTMLElement | null>(null)
const {
isOpen: isContextMenuOpen,
@@ -337,10 +344,25 @@ export function WorkflowItem({
}
e.dataTransfer.setData('sidebar-selection', JSON.stringify(selection))
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.effectAllowed = 'copyMove'
const resources = buildDragResources(selection, workspaceId)
if (resources.length > 0) {
e.dataTransfer.setData(SIM_RESOURCES_DRAG_TYPE, JSON.stringify(resources))
}
const total = selection.workflowIds.length + selection.folderIds.length
const ghostLabel = total > 1 ? `${workflow.name} +${total - 1} more` : workflow.name
const icon = total === 1 ? { kind: 'workflow' as const, color: workflow.color } : undefined
const ghost = createSidebarDragGhost(ghostLabel, icon)
// Force reflow so the browser can capture the rendered element
void ghost.offsetHeight
e.dataTransfer.setDragImage(ghost, ghost.offsetWidth / 2, ghost.offsetHeight / 2)
dragGhostRef.current = ghost
onDragStartProp?.()
},
[workflow.id, onDragStartProp]
[workflow.id, workflow.name, workflow.color, workspaceId, onDragStartProp]
)
const {
@@ -353,6 +375,10 @@ export function WorkflowItem({
})
const handleDragEnd = useCallback(() => {
if (dragGhostRef.current) {
dragGhostRef.current.remove()
dragGhostRef.current = null
}
handleDragEndBase()
onDragEndProp?.()
}, [handleDragEndBase, onDragEndProp])
@@ -414,7 +440,7 @@ export function WorkflowItem({
className='h-[16px] w-[16px] flex-shrink-0 rounded-sm border-[2.5px]'
style={{
backgroundColor: workflow.color,
borderColor: `${workflow.color}60`,
borderColor: workflowBorderColor(workflow.color),
backgroundClip: 'padding-box',
}}
/>

View File

@@ -37,6 +37,7 @@ import {
Wordmark,
} from '@/components/emcn/icons'
import { useSession } from '@/lib/auth/auth-client'
import { SIM_RESOURCES_DRAG_TYPE } from '@/lib/copilot/resource-types'
import { cn } from '@/lib/core/utils/cn'
import { isMacPlatform } from '@/lib/core/utils/platform'
import { buildFolderTree } from '@/lib/folders/tree'
@@ -72,7 +73,10 @@ import {
useWorkflowOperations,
useWorkspaceManagement,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
import { groupWorkflowsByFolder } from '@/app/workspace/[workspaceId]/w/components/sidebar/utils'
import {
createSidebarDragGhost,
groupWorkflowsByFolder,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/utils'
import {
useDuplicateWorkspace,
useExportWorkspace,
@@ -159,6 +163,30 @@ const SidebarTaskItem = memo(function SidebarTaskItem({
onMorePointerDown: () => void
onMoreClick: (e: React.MouseEvent<HTMLButtonElement>, taskId: string) => void
}) {
const dragGhostRef = useRef<HTMLElement | null>(null)
const handleDragStart = useCallback(
(e: React.DragEvent) => {
e.dataTransfer.effectAllowed = 'copyMove'
e.dataTransfer.setData(
SIM_RESOURCES_DRAG_TYPE,
JSON.stringify([{ type: 'task', id: task.id, title: task.name }])
)
const ghost = createSidebarDragGhost(task.name, { kind: 'task' })
void ghost.offsetHeight
e.dataTransfer.setDragImage(ghost, ghost.offsetWidth / 2, ghost.offsetHeight / 2)
dragGhostRef.current = ghost
},
[task.id, task.name]
)
const handleDragEnd = useCallback(() => {
if (dragGhostRef.current) {
dragGhostRef.current.remove()
dragGhostRef.current = null
}
}, [])
return (
<SidebarTooltip label={task.name} enabled={showCollapsedTooltips}>
<Link
@@ -182,6 +210,9 @@ const SidebarTaskItem = memo(function SidebarTaskItem({
}
}}
onContextMenu={task.id !== 'new' ? (e) => onContextMenu(e, task.id) : undefined}
draggable={task.id !== 'new'}
onDragStart={task.id !== 'new' ? handleDragStart : undefined}
onDragEnd={task.id !== 'new' ? handleDragEnd : undefined}
>
<Blimp className='h-[16px] w-[16px] flex-shrink-0 text-[var(--text-icon)]' />
<div className='min-w-0 flex-1 truncate font-base text-[var(--text-body)]'>{task.name}</div>

View File

@@ -1,5 +1,96 @@
import type { MothershipResource } from '@/lib/copilot/resource-types'
import { workflowBorderColor } from '@/lib/workspaces/colors'
import { getFolderMap } from '@/hooks/queries/utils/folder-cache'
import { getWorkflows } from '@/hooks/queries/utils/workflow-cache'
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
/**
* Builds a `MothershipResource` array from a sidebar drag selection so it can
* be set as `application/x-sim-resources` drag data and dropped into the chat.
*/
export function buildDragResources(
selection: { workflowIds: string[]; folderIds: string[] },
workspaceId: string
): MothershipResource[] {
const allWorkflows = getWorkflows(workspaceId)
const workflowMap = Object.fromEntries(allWorkflows.map((w) => [w.id, w]))
const folderMap = getFolderMap(workspaceId)
return [
...selection.workflowIds.map((id) => ({
type: 'workflow' as const,
id,
title: workflowMap[id]?.name ?? id,
})),
...selection.folderIds.map((id) => ({
type: 'folder' as const,
id,
title: folderMap[id]?.name ?? id,
})),
]
}
export type SidebarDragGhostIcon =
| { kind: 'workflow'; color: string }
| { kind: 'folder' }
| { kind: 'task' }
const FOLDER_SVG = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/></svg>`
const BLIMP_SVG = `<svg width="14" height="14" viewBox="1.25 1.25 18 18" fill="currentColor" stroke="currentColor" stroke-width="0.75" stroke-linejoin="round" aria-hidden="true"><path transform="translate(20.5, 0) scale(-1, 1)" d="M18.24 9.18C18.16 8.94 18 8.74 17.83 8.56L17.83 8.56C17.67 8.4 17.49 8.25 17.3 8.11V5.48C17.3 5.32 17.24 5.17 17.14 5.06C17.06 4.95 16.93 4.89 16.79 4.89H15.93C15.61 4.89 15.32 5.11 15.19 5.44L14.68 6.77C14.05 6.51 13.23 6.22 12.15 6C11.04 5.77 9.66 5.61 7.9 5.61C5.97 5.61 4.56 6.13 3.61 6.89C3.14 7.28 2.78 7.72 2.54 8.19C2.29 8.66 2.18 9.15 2.18 9.63C2.18 10.1 2.29 10.59 2.52 11.06C2.87 11.76 3.48 12.41 4.34 12.89C4.91 13.2 5.61 13.44 6.43 13.56L6.8 14.78C6.94 15.27 7.33 15.59 7.78 15.59H10.56C11.06 15.59 11.48 15.18 11.58 14.61L11.81 13.29C12.31 13.2 12.75 13.09 13.14 12.99C13.74 12.82 14.24 12.64 14.67 12.48L15.19 13.82C15.32 14.16 15.61 14.38 15.93 14.38H16.79C16.93 14.38 17.06 14.31 17.14 14.2C17.24 14.1 17.29 13.95 17.3 13.79V11.15C17.33 11.12 17.37 11.09 17.42 11.07L17.4 11.07L17.42 11.07C17.65 10.89 17.87 10.69 18.04 10.46C18.12 10.35 18.19 10.22 18.24 10.08C18.29 9.94 18.32 9.79 18.32 9.63C18.32 9.47 18.29 9.32 18.24 9.18Z"/></svg>`
/**
* Creates a lightweight drag ghost pill showing an icon and label for the item(s) being dragged.
* Append to `document.body`, pass to `e.dataTransfer.setDragImage`, then remove on dragend.
*/
export function createSidebarDragGhost(label: string, icon?: SidebarDragGhostIcon): HTMLElement {
const ghost = document.createElement('div')
ghost.style.cssText = `
position: fixed;
top: -500px;
left: 0;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
background: var(--surface-active);
border: 1px solid rgba(255,255,255,0.08);
border-radius: 8px;
font-family: system-ui, -apple-system, sans-serif;
font-size: 13px;
color: var(--text-body);
white-space: nowrap;
pointer-events: none;
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
z-index: 9999;
`
if (icon) {
if (icon.kind === 'workflow') {
const square = document.createElement('div')
square.style.cssText = `
width: 14px; height: 14px; flex-shrink: 0;
border-radius: 3px; border: 2px solid ${workflowBorderColor(icon.color)};
background: ${icon.color}; background-clip: padding-box;
`
ghost.appendChild(square)
} else {
const iconWrapper = document.createElement('div')
iconWrapper.style.cssText =
'display: flex; align-items: center; flex-shrink: 0; color: var(--text-icon);'
iconWrapper.innerHTML = icon.kind === 'folder' ? FOLDER_SVG : BLIMP_SVG
ghost.appendChild(iconWrapper)
}
}
const text = document.createElement('span')
text.style.cssText = 'max-width: 200px; overflow: hidden; text-overflow: ellipsis;'
text.textContent = label
ghost.appendChild(text)
document.body.appendChild(ghost)
return ghost
}
export function compareByOrder<T extends { sortOrder: number; createdAt?: Date; id: string }>(
a: T,
b: T

View File

@@ -166,7 +166,6 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
const positionUpdateTimeouts = useRef<Map<string, number>>(new Map())
const isRejoiningRef = useRef<boolean>(false)
const pendingPositionUpdates = useRef<Map<string, any>>(new Map())
const deletedWorkflowIdRef = useRef<string | null>(null)
const generateSocketToken = async (): Promise<string> => {
const res = await fetch('/api/auth/socket-token', {
@@ -372,7 +371,6 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
socketInstance.on('workflow-deleted', (data) => {
logger.warn(`Workflow ${data.workflowId} has been deleted`)
deletedWorkflowIdRef.current = data.workflowId
setCurrentWorkflowId((current) => {
if (current === data.workflowId) {
setPresenceUsers([])
@@ -502,11 +500,7 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
if (error?.type === 'SESSION_ERROR') {
const workflowId = urlWorkflowIdRef.current
if (
workflowId &&
!isRejoiningRef.current &&
deletedWorkflowIdRef.current !== workflowId
) {
if (workflowId && !isRejoiningRef.current) {
isRejoiningRef.current = true
logger.info(`Session expired, rejoining workflow: ${workflowId}`)
socketInstance.emit('join-workflow', {
@@ -558,25 +552,13 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
const hydrationPhase = useWorkflowRegistryStore((s) => s.hydration.phase)
useEffect(() => {
if (!socket || !isConnected || !urlWorkflowId) {
if (!urlWorkflowId) {
deletedWorkflowIdRef.current = null
}
return
}
if (!socket || !isConnected || !urlWorkflowId) return
if (hydrationPhase === 'creating') return
// Skip if already in the correct room
if (currentWorkflowId === urlWorkflowId) return
// Prevent rejoining a workflow that was just deleted. The URL param may
// still reference the old workflow while router.push() propagates.
if (deletedWorkflowIdRef.current === urlWorkflowId) {
return
}
deletedWorkflowIdRef.current = null
logger.info(
`URL workflow changed from ${currentWorkflowId} to ${urlWorkflowId}, switching rooms`
)

View File

@@ -0,0 +1,469 @@
import { AthenaIcon } from '@/components/icons'
import type { BlockConfig } from '@/blocks/types'
import { IntegrationType } from '@/blocks/types'
import type {
AthenaCreateNamedQueryResponse,
AthenaGetNamedQueryResponse,
AthenaGetQueryExecutionResponse,
AthenaGetQueryResultsResponse,
AthenaListNamedQueriesResponse,
AthenaListQueryExecutionsResponse,
AthenaStartQueryResponse,
AthenaStopQueryResponse,
} from '@/tools/athena/types'
export const AthenaBlock: BlockConfig<
| AthenaStartQueryResponse
| AthenaGetQueryExecutionResponse
| AthenaGetQueryResultsResponse
| AthenaStopQueryResponse
| AthenaListQueryExecutionsResponse
| AthenaCreateNamedQueryResponse
| AthenaGetNamedQueryResponse
| AthenaListNamedQueriesResponse
> = {
type: 'athena',
name: 'Athena',
description: 'Run SQL queries on data in Amazon S3 using AWS Athena',
longDescription:
'Integrate AWS Athena into workflows. Execute SQL queries against data in S3, check query status, retrieve results, manage named queries, and list executions. Requires AWS access key and secret access key.',
docsLink: 'https://docs.sim.ai/tools/athena',
category: 'tools',
integrationType: IntegrationType.Analytics,
tags: ['cloud', 'data-analytics'],
bgColor: 'linear-gradient(45deg, #4D27A8 0%, #A166FF 100%)',
icon: AthenaIcon,
subBlocks: [
{
id: 'operation',
title: 'Operation',
type: 'dropdown',
options: [
{ label: 'Start Query', id: 'start_query' },
{ label: 'Get Query Execution', id: 'get_query_execution' },
{ label: 'Get Query Results', id: 'get_query_results' },
{ label: 'Stop Query', id: 'stop_query' },
{ label: 'List Query Executions', id: 'list_query_executions' },
{ label: 'Create Named Query', id: 'create_named_query' },
{ label: 'Get Named Query', id: 'get_named_query' },
{ label: 'List Named Queries', id: 'list_named_queries' },
],
value: () => 'start_query',
},
{
id: 'awsRegion',
title: 'AWS Region',
type: 'short-input',
placeholder: 'us-east-1',
required: true,
},
{
id: 'awsAccessKeyId',
title: 'AWS Access Key ID',
type: 'short-input',
placeholder: 'AKIA...',
password: true,
required: true,
},
{
id: 'awsSecretAccessKey',
title: 'AWS Secret Access Key',
type: 'short-input',
placeholder: 'Your secret access key',
password: true,
required: true,
},
{
id: 'queryString',
title: 'SQL Query',
type: 'code',
placeholder: 'SELECT * FROM my_table LIMIT 10',
condition: { field: 'operation', value: ['start_query', 'create_named_query'] },
required: { field: 'operation', value: ['start_query', 'create_named_query'] },
wandConfig: {
enabled: true,
prompt: `Generate an SQL query for AWS Athena based on the user's description.
Athena uses Trino/Presto SQL syntax. Common patterns:
- SELECT * FROM "database"."table" LIMIT 10
- SELECT column1, COUNT(*) FROM table GROUP BY column1
- SELECT * FROM table WHERE date_column > DATE '2024-01-01'
- CREATE TABLE new_table AS SELECT ... FROM source_table
- SELECT * FROM table WHERE column IN ('value1', 'value2')
Return ONLY the SQL query — no explanations, no markdown code blocks.`,
placeholder: 'Describe what data you want to query...',
},
},
{
id: 'database',
title: 'Database',
type: 'short-input',
placeholder: 'my_database',
condition: { field: 'operation', value: ['start_query', 'create_named_query'] },
required: { field: 'operation', value: 'create_named_query' },
},
{
id: 'catalog',
title: 'Data Catalog',
type: 'short-input',
placeholder: 'AwsDataCatalog',
condition: { field: 'operation', value: 'start_query' },
mode: 'advanced',
},
{
id: 'outputLocation',
title: 'Output Location (S3)',
type: 'short-input',
placeholder: 's3://my-bucket/athena-results/',
condition: { field: 'operation', value: 'start_query' },
mode: 'advanced',
},
{
id: 'workGroup',
title: 'Workgroup',
type: 'short-input',
placeholder: 'primary',
condition: {
field: 'operation',
value: ['start_query', 'list_query_executions', 'create_named_query', 'list_named_queries'],
},
mode: 'advanced',
},
{
id: 'queryExecutionId',
title: 'Query Execution ID',
type: 'short-input',
placeholder: 'e.g., a1b2c3d4-5678-90ab-cdef-example11111',
condition: {
field: 'operation',
value: ['get_query_execution', 'get_query_results', 'stop_query'],
},
required: {
field: 'operation',
value: ['get_query_execution', 'get_query_results', 'stop_query'],
},
},
{
id: 'namedQueryId',
title: 'Named Query ID',
type: 'short-input',
placeholder: 'e.g., a1b2c3d4-5678-90ab-cdef-example11111',
condition: { field: 'operation', value: 'get_named_query' },
required: { field: 'operation', value: 'get_named_query' },
},
{
id: 'queryName',
title: 'Query Name',
type: 'short-input',
placeholder: 'My Saved Query',
condition: { field: 'operation', value: 'create_named_query' },
required: { field: 'operation', value: 'create_named_query' },
},
{
id: 'queryDescription',
title: 'Description',
type: 'short-input',
placeholder: 'Description of what this query does',
condition: { field: 'operation', value: 'create_named_query' },
mode: 'advanced',
},
{
id: 'maxResults',
title: 'Max Results',
type: 'short-input',
placeholder: '50',
condition: {
field: 'operation',
value: ['get_query_results', 'list_query_executions', 'list_named_queries'],
},
mode: 'advanced',
},
{
id: 'nextToken',
title: 'Pagination Token',
type: 'short-input',
placeholder: 'Token from previous request',
condition: {
field: 'operation',
value: ['get_query_results', 'list_query_executions', 'list_named_queries'],
},
mode: 'advanced',
},
],
tools: {
access: [
'athena_start_query',
'athena_get_query_execution',
'athena_get_query_results',
'athena_stop_query',
'athena_list_query_executions',
'athena_create_named_query',
'athena_get_named_query',
'athena_list_named_queries',
],
config: {
tool: (params) => {
switch (params.operation) {
case 'start_query':
return 'athena_start_query'
case 'get_query_execution':
return 'athena_get_query_execution'
case 'get_query_results':
return 'athena_get_query_results'
case 'stop_query':
return 'athena_stop_query'
case 'list_query_executions':
return 'athena_list_query_executions'
case 'create_named_query':
return 'athena_create_named_query'
case 'get_named_query':
return 'athena_get_named_query'
case 'list_named_queries':
return 'athena_list_named_queries'
default:
throw new Error(`Invalid Athena operation: ${params.operation}`)
}
},
params: (params) => {
const { operation, maxResults, ...rest } = params
const awsRegion = rest.awsRegion
const awsAccessKeyId = rest.awsAccessKeyId
const awsSecretAccessKey = rest.awsSecretAccessKey
const parsedMaxResults = maxResults ? Number.parseInt(String(maxResults), 10) : undefined
switch (operation) {
case 'start_query':
return {
awsRegion,
awsAccessKeyId,
awsSecretAccessKey,
queryString: rest.queryString,
...(rest.database && { database: rest.database }),
...(rest.catalog && { catalog: rest.catalog }),
...(rest.outputLocation && { outputLocation: rest.outputLocation }),
...(rest.workGroup && { workGroup: rest.workGroup }),
}
case 'get_query_execution':
if (!rest.queryExecutionId) {
throw new Error('Query execution ID is required')
}
return {
awsRegion,
awsAccessKeyId,
awsSecretAccessKey,
queryExecutionId: rest.queryExecutionId,
}
case 'get_query_results':
if (!rest.queryExecutionId) {
throw new Error('Query execution ID is required')
}
return {
awsRegion,
awsAccessKeyId,
awsSecretAccessKey,
queryExecutionId: rest.queryExecutionId,
...(parsedMaxResults !== undefined && { maxResults: parsedMaxResults }),
...(rest.nextToken && { nextToken: rest.nextToken }),
}
case 'stop_query':
if (!rest.queryExecutionId) {
throw new Error('Query execution ID is required')
}
return {
awsRegion,
awsAccessKeyId,
awsSecretAccessKey,
queryExecutionId: rest.queryExecutionId,
}
case 'list_query_executions':
return {
awsRegion,
awsAccessKeyId,
awsSecretAccessKey,
...(rest.workGroup && { workGroup: rest.workGroup }),
...(parsedMaxResults !== undefined && { maxResults: parsedMaxResults }),
...(rest.nextToken && { nextToken: rest.nextToken }),
}
case 'create_named_query': {
if (!rest.queryName) {
throw new Error('Query name is required')
}
if (!rest.database) {
throw new Error('Database is required')
}
if (!rest.queryString) {
throw new Error('SQL query string is required')
}
return {
awsRegion,
awsAccessKeyId,
awsSecretAccessKey,
name: rest.queryName,
database: rest.database,
queryString: rest.queryString,
...(rest.queryDescription && { description: rest.queryDescription }),
...(rest.workGroup && { workGroup: rest.workGroup }),
}
}
case 'get_named_query':
if (!rest.namedQueryId) {
throw new Error('Named query ID is required')
}
return {
awsRegion,
awsAccessKeyId,
awsSecretAccessKey,
namedQueryId: rest.namedQueryId,
}
case 'list_named_queries':
return {
awsRegion,
awsAccessKeyId,
awsSecretAccessKey,
...(rest.workGroup && { workGroup: rest.workGroup }),
...(parsedMaxResults !== undefined && { maxResults: parsedMaxResults }),
...(rest.nextToken && { nextToken: rest.nextToken }),
}
default:
throw new Error(`Invalid Athena operation: ${operation}`)
}
},
},
},
inputs: {
operation: { type: 'string', description: 'Athena operation to perform' },
awsRegion: { type: 'string', description: 'AWS region' },
awsAccessKeyId: { type: 'string', description: 'AWS access key ID' },
awsSecretAccessKey: { type: 'string', description: 'AWS secret access key' },
queryString: { type: 'string', description: 'SQL query string' },
database: { type: 'string', description: 'Database name' },
catalog: { type: 'string', description: 'Data catalog name' },
outputLocation: { type: 'string', description: 'S3 output location for results' },
workGroup: { type: 'string', description: 'Athena workgroup name' },
queryExecutionId: { type: 'string', description: 'Query execution ID' },
namedQueryId: { type: 'string', description: 'Named query ID' },
queryName: { type: 'string', description: 'Name for a saved query' },
queryDescription: { type: 'string', description: 'Description for a saved query' },
maxResults: { type: 'number', description: 'Maximum number of results' },
nextToken: { type: 'string', description: 'Pagination token' },
},
outputs: {
queryExecutionId: {
type: 'string',
description: 'Query execution ID',
},
query: {
type: 'string',
description: 'SQL query string',
},
state: {
type: 'string',
description: 'Query state (QUEUED, RUNNING, SUCCEEDED, FAILED, CANCELLED)',
},
stateChangeReason: {
type: 'string',
description: 'Reason for state change',
},
statementType: {
type: 'string',
description: 'Statement type (DDL, DML, UTILITY)',
},
database: {
type: 'string',
description: 'Database name',
},
catalog: {
type: 'string',
description: 'Data catalog name',
},
workGroup: {
type: 'string',
description: 'Workgroup name',
},
submissionDateTime: {
type: 'number',
description: 'Query submission time (Unix epoch ms)',
},
completionDateTime: {
type: 'number',
description: 'Query completion time (Unix epoch ms)',
},
dataScannedInBytes: {
type: 'number',
description: 'Data scanned in bytes',
},
engineExecutionTimeInMillis: {
type: 'number',
description: 'Engine execution time in ms',
},
queryPlanningTimeInMillis: {
type: 'number',
description: 'Query planning time in ms',
},
queryQueueTimeInMillis: {
type: 'number',
description: 'Time spent in queue in ms',
},
totalExecutionTimeInMillis: {
type: 'number',
description: 'Total execution time in ms',
},
outputLocation: {
type: 'string',
description: 'S3 location of query results',
},
columns: {
type: 'array',
description: 'Column metadata (name and type)',
},
rows: {
type: 'array',
description: 'Result rows as key-value objects',
},
nextToken: {
type: 'string',
description: 'Pagination token for next page',
},
updateCount: {
type: 'number',
description: 'Rows affected by INSERT/UPDATE',
},
success: {
type: 'boolean',
description: 'Whether the operation succeeded',
},
queryExecutionIds: {
type: 'array',
description: 'List of query execution IDs',
},
namedQueryId: {
type: 'string',
description: 'Named query ID',
},
name: {
type: 'string',
description: 'Named query name',
},
description: {
type: 'string',
description: 'Named query description',
},
queryString: {
type: 'string',
description: 'Named query SQL string',
},
namedQueryIds: {
type: 'array',
description: 'List of named query IDs',
},
},
}

View File

@@ -127,6 +127,8 @@ export const KnowledgeBlock: BlockConfig = {
title: 'Document',
type: 'document-selector',
canonicalParamId: 'documentId',
serviceId: 'knowledge',
selectorKey: 'knowledge.documents',
placeholder: 'Select document',
dependsOn: ['knowledgeBaseSelector'],
required: true,

View File

@@ -1634,8 +1634,21 @@ Do not include any explanations, markdown formatting, or other text outside the
// Trigger outputs (when used as webhook trigger)
event_type: { type: 'string', description: 'Type of Slack event that triggered the workflow' },
subtype: {
type: 'string',
description:
'Message subtype (e.g., channel_join, channel_leave, bot_message). Null for regular user messages',
},
channel_name: { type: 'string', description: 'Human-readable channel name' },
channel_type: {
type: 'string',
description: 'Type of channel (e.g., channel, group, im, mpim)',
},
user_name: { type: 'string', description: 'Username who triggered the event' },
bot_id: {
type: 'string',
description: 'Bot ID if the message was sent by a bot. Null for human users',
},
timestamp: { type: 'string', description: 'Message timestamp from the triggering event' },
thread_ts: {
type: 'string',

View File

@@ -13,6 +13,7 @@ import { ApolloBlock } from '@/blocks/blocks/apollo'
import { ArxivBlock } from '@/blocks/blocks/arxiv'
import { AsanaBlock } from '@/blocks/blocks/asana'
import { AshbyBlock } from '@/blocks/blocks/ashby'
import { AthenaBlock } from '@/blocks/blocks/athena'
import { AttioBlock } from '@/blocks/blocks/attio'
import { BoxBlock } from '@/blocks/blocks/box'
import { BrandfetchBlock } from '@/blocks/blocks/brandfetch'
@@ -236,6 +237,7 @@ export const registry: Record<string, BlockConfig> = {
arxiv: ArxivBlock,
asana: AsanaBlock,
ashby: AshbyBlock,
athena: AthenaBlock,
attio: AttioBlock,
brandfetch: BrandfetchBlock,
box: BoxBlock,

View File

@@ -4687,6 +4687,33 @@ export function CloudFormationIcon(props: SVGProps<SVGSVGElement>) {
)
}
export function AthenaIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
{...props}
viewBox='0 0 80 80'
version='1.1'
xmlns='http://www.w3.org/2000/svg'
xmlnsXlink='http://www.w3.org/1999/xlink'
>
<g
id='Icon-Architecture/64/Arch_Amazon-Athena_64'
stroke='none'
strokeWidth='1'
fill='none'
fillRule='evenodd'
transform='translate(40, 40) scale(1.25) translate(-40, -40)'
>
<path
d='M38.29505,27.2267312 C42.787319,27.2267312 45.2478437,28.2331825 45.6964751,28.7379193 C45.2478437,29.2426562 42.787319,30.2491074 38.29505,30.2491074 C33.8027811,30.2491074 31.3422564,29.2426562 30.893625,28.7379193 C31.3422564,28.2331825 33.8027811,27.2267312 38.29505,27.2267312 L38.29505,27.2267312 Z M37.7838882,35.2823712 C37.6191254,35.1977447 37.5029973,35.0294991 37.5029973,34.8300223 C37.5029973,34.5499487 37.7292981,34.3212556 38.0062188,34.3212556 C38.0866151,34.3212556 38.1600636,34.3444272 38.2285494,34.3796882 L37.7838882,35.2823712 Z M43.5674612,43.5908834 C43.4930201,43.6513309 43.322302,43.7681961 42.9709403,43.9092403 C42.6582879,44.0341652 42.2880677,44.1470006 41.8682202,44.2457316 C40.7525971,44.5076708 39.3808968,44.6517374 38.0052262,44.6517374 C34.9968155,44.6517374 32.9005556,44.0019265 32.4489466,43.5989431 L31.1159556,31.150783 C33.1596104,31.9869737 36.1700063,32.2640249 38.29505,32.2640249 C40.3843621,32.2640249 43.3292498,31.9950334 45.3719121,31.1910813 L44.5748967,36.6656121 C43.0731726,36.0994203 41.1992434,35.2773339 39.4235763,34.4129344 C39.2429327,33.786295 38.6801584,33.3248789 38.0062188,33.3248789 C37.1883598,33.3248789 36.5233532,34.0008837 36.5233532,34.8300223 C36.5233532,35.6611757 37.1883598,36.3361731 38.0062188,36.3361731 C38.1997655,36.3361731 38.3843793,36.2958747 38.5531123,36.2273675 C41.0344805,37.4524373 42.8835961,38.2382552 44.2751474,38.7228428 L43.5674612,43.5908834 Z M28.8718062,28.8467249 L30.4787403,43.8498003 C30.5918907,46.6344162 37.6995217,46.6666549 38.0052262,46.6666549 C39.5268012,46.6666549 41.0573091,46.5034466 42.3148665,46.2092686 C42.8299985,46.0883736 43.2964958,45.9453144 43.7004625,45.7831136 C44.8736534,45.3116229 45.4890327,44.6688642 45.5317122,43.8739793 L46.2006891,39.2759376 C46.6562683,39.3696313 47.0284735,39.4109371 47.3252452,39.4109371 C48.2592321,39.4109371 48.5053839,39.0281028 48.6751094,38.7641486 C48.853768,38.48609 48.9053804,38.1445615 48.8220064,37.8010181 C48.6314374,37.0111704 47.5168068,35.971473 46.7723963,35.3539008 L47.7133311,28.8850083 L47.7043982,28.8840008 C47.7083684,28.8346354 47.7242492,28.7882923 47.7242492,28.7379193 C47.7242492,25.9543109 41.7967568,25.2118138 38.29505,25.2118138 C34.7933433,25.2118138 28.8658509,25.9543109 28.8658509,28.7379193 C28.8658509,28.7751953 28.8787541,28.8084414 28.8807391,28.8457174 L28.8718062,28.8467249 Z M37.8355007,20.0596698 C46.4865427,20.0596698 53.5246954,27.2035597 53.5246954,35.98457 C53.5246954,44.7655803 46.4865427,51.9094701 37.8355007,51.9094701 C29.1834661,51.9094701 22.1453133,44.7655803 22.1453133,35.98457 C22.1453133,27.2035597 29.1834661,20.0596698 37.8355007,20.0596698 L37.8355007,20.0596698 Z M12.9850945,41.8348828 L12.9850945,43.8498003 L21.91802,43.8498003 L21.91802,43.7309201 C24.7735785,49.7494786 30.8261318,53.9243876 37.8355007,53.9243876 C47.5803298,53.9243876 55.50979,45.8768072 55.50979,35.98457 C55.50979,26.0923327 47.5803298,18.0447524 37.8355007,18.0447524 C30.253432,18.0447524 23.7909567,22.9248825 21.2857674,29.7453781 L12.9850945,29.7453781 L12.9850945,31.7602955 L20.6763434,31.7602955 C20.3666686,33.0568949 20.1850325,34.4018523 20.1701443,35.7901304 L11,35.7901304 L11,37.8050479 L20.2515331,37.8050479 C20.3914823,39.2044081 20.7061198,40.548358 21.1448257,41.8348828 L12.9850945,41.8348828 Z M67.0799136,66.035049 C65.8789314,67.2560889 63.7965672,67.2631412 62.5965775,66.046131 L51.9326496,55.220987 C53.6487638,53.9223727 55.1802643,52.3900279 56.4934043,50.6763406 L67.0918241,61.4853653 C67.688345,62.0918555 68.0168782,62.8998374 68.014902,63.7591997 C68.0139005,64.6205769 67.6823898,65.4275513 67.0799136,66.035049 L67.0799136,66.035049 Z M68.4972711,60.0628336 L57.6616325,49.0100039 C60.0635969,45.2562127 61.4650736,40.7851108 61.4650736,35.98457 C61.4650736,22.7586518 50.8646687,12 37.8355007,12 C28.4728022,12 19.9825528,17.6196048 16.2039254,26.316996 L18.0202869,27.1290077 C21.4812992,19.1630316 29.2588997,14.0149175 37.8355007,14.0149175 C49.7708816,14.0149175 59.4799791,23.8698788 59.4799791,35.98457 C59.4799791,48.0982537 49.7708816,57.9542225 37.8355007,57.9542225 C29.8623684,57.9542225 22.5572205,53.5244265 18.7686675,46.3936336 L17.0217843,47.3507194 C21.1557437,55.1343455 29.1318536,59.9691399 37.8355007,59.9691399 C42.3912926,59.9691399 46.6483279,58.6503765 50.2602074,56.3735197 L61.1941082,67.4716851 C62.1648195,68.4569797 63.4561235,69 64.8278238,69 C66.2074645,69 67.5067089,68.4529499 68.4813903,67.462618 C69.4580568,66.4773233 69.9980025,65.1635972 70,63.7622221 C70.0029653,62.3628619 69.4679823,61.0491357 68.4972711,60.0628336 L68.4972711,60.0628336 Z'
id='Amazon-Athena_Icon_64_Squid'
fill='currentColor'
/>
</g>
</svg>
)
}
export function CloudWatchIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg

View File

@@ -0,0 +1,46 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Check, Copy } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
interface CopyCodeButtonProps {
code: string
className?: string
}
export function CopyCodeButton({ code, className }: CopyCodeButtonProps) {
const [copied, setCopied] = useState(false)
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const handleCopy = useCallback(async () => {
try {
await navigator.clipboard.writeText(code)
setCopied(true)
if (timerRef.current) clearTimeout(timerRef.current)
timerRef.current = setTimeout(() => setCopied(false), 2000)
} catch {
// Clipboard write can fail when document lacks focus or permission is denied
}
}, [code])
useEffect(
() => () => {
if (timerRef.current) clearTimeout(timerRef.current)
},
[]
)
return (
<button
type='button'
onClick={handleCopy}
className={cn(
'flex items-center gap-1 rounded px-1.5 py-0.5 text-xs transition-colors',
className
)}
>
{copied ? <Check className='size-3.5' /> : <Copy className='size-3.5' />}
</button>
)
}

View File

@@ -2,7 +2,7 @@ import { createLogger } from '@sim/logger'
import { AsanaIcon } from '@/components/icons'
import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils'
import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types'
import { computeContentHash, joinTagArray, parseTagDate } from '@/connectors/utils'
import { joinTagArray, parseTagDate } from '@/connectors/utils'
const logger = createLogger('AsanaConnector')
@@ -240,7 +240,6 @@ export const asanaConnector: ConnectorConfig = {
for (const task of result.data) {
const content = buildTaskContent(task)
const contentHash = await computeContentHash(content)
const tagNames = task.tags?.map((t) => t.name).filter(Boolean) || []
documents.push({
@@ -249,7 +248,7 @@ export const asanaConnector: ConnectorConfig = {
content,
mimeType: 'text/plain',
sourceUrl: task.permalink_url || undefined,
contentHash,
contentHash: `asana:${task.gid}:${task.modified_at ?? ''}`,
metadata: {
project: currentProjectGid,
assignee: task.assignee?.name,
@@ -315,7 +314,6 @@ export const asanaConnector: ConnectorConfig = {
if (!task) return null
const content = buildTaskContent(task)
const contentHash = await computeContentHash(content)
const tagNames = task.tags?.map((t) => t.name).filter(Boolean) || []
return {
@@ -324,7 +322,7 @@ export const asanaConnector: ConnectorConfig = {
content,
mimeType: 'text/plain',
sourceUrl: task.permalink_url || undefined,
contentHash,
contentHash: `asana:${task.gid}:${task.modified_at ?? ''}`,
metadata: {
assignee: task.assignee?.name,
completed: task.completed,

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