mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-28 03:00:29 -04:00
Compare commits
18 Commits
v0.6.29
...
fix/log-so
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04c9057229 | ||
|
|
650487c3c9 | ||
|
|
efb582e96a | ||
|
|
3c7bfa797a | ||
|
|
d0d35dd406 | ||
|
|
9282d1bf54 | ||
|
|
7b81a760ea | ||
|
|
a591d7c227 | ||
|
|
086b7d9ca1 | ||
|
|
2760b4bff1 | ||
|
|
6f9f336f16 | ||
|
|
712e58a7b5 | ||
|
|
2504bfbaf8 | ||
|
|
6c3caf61e1 | ||
|
|
98be968b54 | ||
|
|
e0f5cf880a | ||
|
|
0f602f79a4 | ||
|
|
d0d3581605 |
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
238
apps/docs/content/docs/en/tools/athena.mdx
Normal file
238
apps/docs/content/docs/en/tools/athena.mdx
Normal 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 |
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"arxiv",
|
||||
"asana",
|
||||
"ashby",
|
||||
"athena",
|
||||
"attio",
|
||||
"box",
|
||||
"brandfetch",
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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',
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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',
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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',
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
11
apps/sim/app/api/settings/voice/route.ts
Normal file
11
apps/sim/app/api/settings/voice/route.ts
Normal 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() })
|
||||
}
|
||||
171
apps/sim/app/api/speech/token/route.ts
Normal file
171
apps/sim/app/api/speech/token/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
69
apps/sim/app/api/tools/athena/create-named-query/route.ts
Normal file
69
apps/sim/app/api/tools/athena/create-named-query/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
66
apps/sim/app/api/tools/athena/get-named-query/route.ts
Normal file
66
apps/sim/app/api/tools/athena/get-named-query/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
77
apps/sim/app/api/tools/athena/get-query-execution/route.ts
Normal file
77
apps/sim/app/api/tools/athena/get-query-execution/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
88
apps/sim/app/api/tools/athena/get-query-results/route.ts
Normal file
88
apps/sim/app/api/tools/athena/get-query-results/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
65
apps/sim/app/api/tools/athena/list-named-queries/route.ts
Normal file
65
apps/sim/app/api/tools/athena/list-named-queries/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
65
apps/sim/app/api/tools/athena/list-query-executions/route.ts
Normal file
65
apps/sim/app/api/tools/athena/list-query-executions/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
80
apps/sim/app/api/tools/athena/start-query/route.ts
Normal file
80
apps/sim/app/api/tools/athena/start-query/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
56
apps/sim/app/api/tools/athena/stop-query/route.ts
Normal file
56
apps/sim/app/api/tools/athena/stop-query/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
17
apps/sim/app/api/tools/athena/utils.ts
Normal file
17
apps/sim/app/api/tools/athena/utils.ts
Normal 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,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
@@ -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(() => {})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(() => {})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(() => {})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
export { ChatMessageAttachments } from './chat-message-attachments'
|
||||
export { ContextMentionIcon } from './context-mention-icon'
|
||||
export {
|
||||
assistantMessageHasRenderableContent,
|
||||
MessageContent,
|
||||
|
||||
@@ -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]'
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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]
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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) })
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 })
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'>
|
||||
|
||||
@@ -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',
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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',
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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',
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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',
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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',
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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]`} />
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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',
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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',
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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',
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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',
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
)
|
||||
|
||||
469
apps/sim/blocks/blocks/athena.ts
Normal file
469
apps/sim/blocks/blocks/athena.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
46
apps/sim/components/ui/copy-code-button.tsx
Normal file
46
apps/sim/components/ui/copy-code-button.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user