mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
Compare commits
2 Commits
improvemen
...
feat/multi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
33aa581351 | ||
|
|
bc41ea02b9 |
@@ -90,7 +90,6 @@ Sim also supports local models via [Ollama](https://ollama.ai) and [vLLM](https:
|
||||
git clone https://github.com/simstudioai/sim.git
|
||||
cd sim
|
||||
bun install
|
||||
bun run prepare # Set up pre-commit hooks
|
||||
```
|
||||
|
||||
2. Set up PostgreSQL with pgvector:
|
||||
@@ -105,11 +104,6 @@ Or install manually via the [pgvector guide](https://github.com/pgvector/pgvecto
|
||||
|
||||
```bash
|
||||
cp apps/sim/.env.example apps/sim/.env
|
||||
# Create your secrets
|
||||
perl -i -pe "s/your_encryption_key/$(openssl rand -hex 32)/" apps/sim/.env
|
||||
perl -i -pe "s/your_internal_api_secret/$(openssl rand -hex 32)/" apps/sim/.env
|
||||
perl -i -pe "s/your_api_encryption_key/$(openssl rand -hex 32)/" apps/sim/.env
|
||||
# DB configs for migration
|
||||
cp packages/db/.env.example packages/db/.env
|
||||
# Edit both .env files to set DATABASE_URL="postgresql://postgres:your_password@localhost:5432/simstudio"
|
||||
```
|
||||
@@ -117,7 +111,7 @@ cp packages/db/.env.example packages/db/.env
|
||||
4. Run migrations:
|
||||
|
||||
```bash
|
||||
cd packages/db && bun run db:migrate
|
||||
cd packages/db && bunx drizzle-kit migrate --config=./drizzle.config.ts
|
||||
```
|
||||
|
||||
5. Start development servers:
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
---
|
||||
title: Credential
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout'
|
||||
import { Tab, Tabs } from 'fumadocs-ui/components/tabs'
|
||||
import { Image } from '@/components/ui/image'
|
||||
import { FAQ } from '@/components/ui/faq'
|
||||
|
||||
The Credential block has two operations: **Select Credential** picks a single OAuth credential and outputs its ID reference for downstream blocks; **List Credentials** returns all OAuth credentials in the workspace (optionally filtered by provider) as an array for iteration.
|
||||
|
||||
<div className="flex justify-center">
|
||||
<Image
|
||||
src="/static/blocks/credential.png"
|
||||
alt="Credential Block"
|
||||
width={400}
|
||||
height={300}
|
||||
className="my-6"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Callout>
|
||||
The Credential block outputs credential **ID references**, not secrets. Downstream blocks receive the ID and resolve the actual OAuth token securely during their own execution.
|
||||
</Callout>
|
||||
|
||||
## Configuration Options
|
||||
|
||||
### Operation
|
||||
|
||||
| Value | Description |
|
||||
|---|---|
|
||||
| **Select Credential** | Pick one OAuth credential and output its reference — use this to wire a single credential into downstream blocks |
|
||||
| **List Credentials** | Return all OAuth credentials in the workspace as an array — use this with a ForEach loop |
|
||||
|
||||
### Credential (Select operation)
|
||||
|
||||
Select an OAuth credential from your workspace. The dropdown shows all connected OAuth accounts (Google, GitHub, Slack, etc.).
|
||||
|
||||
In advanced mode, paste a credential ID directly. You can copy a credential ID from your workspace's Credentials settings page.
|
||||
|
||||
### Provider (List operation)
|
||||
|
||||
Filter the returned OAuth credentials by provider. Select one or more providers from the dropdown — only providers you have credentials for will appear. Leave empty to return all OAuth credentials.
|
||||
|
||||
| Example | Returns |
|
||||
|---|---|
|
||||
| Gmail | Gmail credentials only |
|
||||
| Slack | Slack credentials only |
|
||||
| Gmail + Slack | Gmail and Slack credentials |
|
||||
|
||||
## Outputs
|
||||
|
||||
<Tabs items={['Select Credential', 'List Credentials']}>
|
||||
<Tab>
|
||||
| Output | Type | Description |
|
||||
|---|---|---|
|
||||
| `credentialId` | `string` | The credential ID — pipe this into other blocks' credential fields |
|
||||
| `displayName` | `string` | Human-readable name (e.g. "waleed@company.com") |
|
||||
| `providerId` | `string` | OAuth provider ID (e.g. `google-email`, `slack`) |
|
||||
</Tab>
|
||||
<Tab>
|
||||
| Output | Type | Description |
|
||||
|---|---|---|
|
||||
| `credentials` | `json` | Array of OAuth credential objects (see shape below) |
|
||||
| `count` | `number` | Number of credentials returned |
|
||||
|
||||
Each object in the `credentials` array:
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `credentialId` | `string` | The credential ID |
|
||||
| `displayName` | `string` | Human-readable name |
|
||||
| `providerId` | `string` | OAuth provider ID |
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
## Example Use Cases
|
||||
|
||||
**Shared credential across multiple blocks** — Define once, use everywhere
|
||||
```
|
||||
Credential (Select, Google) → Gmail (Send) & Google Drive (Upload) & Google Calendar (Create)
|
||||
```
|
||||
|
||||
**Multi-account workflows** — Route to different credentials based on logic
|
||||
```
|
||||
Agent (Determine account) → Condition → Credential A or Credential B → Slack (Post)
|
||||
```
|
||||
|
||||
**Iterate over all Gmail accounts**
|
||||
```
|
||||
Credential (List, Provider: Gmail) → ForEach Loop → Gmail (Send) using <loop.currentItem.credentialId>
|
||||
```
|
||||
|
||||
<div className="flex justify-center">
|
||||
<Image
|
||||
src="/static/blocks/credential-loop.png"
|
||||
alt="Credential List wired into a ForEach Loop"
|
||||
width={900}
|
||||
height={400}
|
||||
className="my-6"
|
||||
/>
|
||||
</div>
|
||||
|
||||
## How to wire a Credential block
|
||||
|
||||
### Select Credential
|
||||
|
||||
1. Drop a **Credential** block and select your OAuth credential from the picker
|
||||
2. In the downstream block, switch to **advanced mode** on its credential field
|
||||
3. Enter `<credentialBlockName.credentialId>` as the value
|
||||
|
||||
<Tabs items={['Gmail', 'Slack']}>
|
||||
<Tab>
|
||||
In the Gmail block's credential field (advanced mode):
|
||||
```
|
||||
<myCredential.credentialId>
|
||||
```
|
||||
</Tab>
|
||||
<Tab>
|
||||
In the Slack block's credential field (advanced mode):
|
||||
```
|
||||
<myCredential.credentialId>
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
### List Credentials
|
||||
|
||||
1. Drop a **Credential** block, set Operation to **List Credentials**
|
||||
2. Optionally select one or more **Providers** to narrow results (only your connected providers appear)
|
||||
3. Wire `<credentialBlockName.credentials>` into a **ForEach Loop** as the items source
|
||||
4. Inside the loop, reference `<loop.currentItem.credentialId>` in downstream blocks' credential fields
|
||||
|
||||
## Best Practices
|
||||
|
||||
- **Define once, reference many times**: When five blocks use the same Google account, use one Credential block and wire all five to `<credential.credentialId>` instead of selecting the account five times
|
||||
- **Outputs are safe to log**: The `credentialId` output is a UUID reference, not a secret. It is safe to inspect in execution logs
|
||||
- **Use for environment switching**: Pair with a Condition block to route to a production or staging OAuth credential based on a workflow variable
|
||||
- **Advanced mode is required**: Downstream blocks must be in advanced mode on their credential field to accept a dynamic reference
|
||||
- **Use List + ForEach for fan-out**: When you need to run the same action across all accounts of a provider, List Credentials feeds naturally into a ForEach loop
|
||||
- **Narrow by provider**: Use the Provider multiselect to filter to specific services — only providers you have credentials for are shown
|
||||
|
||||
<FAQ items={[
|
||||
{ question: "Does the Credential block expose my secret or token?", answer: "No. The block outputs a credential ID (a UUID), not the actual OAuth token. Downstream blocks receive the ID and resolve the token securely in their own execution context. Secrets never appear in workflow state, logs, or the canvas." },
|
||||
{ question: "What credential types does it support?", answer: "OAuth connected accounts only (Google, GitHub, Slack, etc.). Environment variables and service accounts cannot be resolved by ID in downstream blocks, so they are not supported." },
|
||||
{ question: "How is Select different from just copying a credential ID into advanced mode?", answer: "Functionally identical — both pass the same credential ID to the downstream block. The Credential block adds value when you need to use one credential in many blocks (change it once), or when you want to select between credentials dynamically using a Condition block." },
|
||||
{ question: "Can I list all OAuth credentials in my workspace?", answer: "Yes. Set the Operation to 'List Credentials'. Optionally filter by provider using the Provider multiselect. Wire the credentials output into a ForEach loop to process each credential individually." },
|
||||
{ question: "Can I use a Credential block output in a Function block?", answer: "Yes. Reference <credential.credentialId> in your Function block's code. Note that the function will receive the raw UUID string — if you need the resolved token, the downstream block must handle the resolution (as integration blocks do). The Function block does not automatically resolve credential IDs." },
|
||||
{ question: "What happens if the credential is deleted?", answer: "The Select operation will throw an error at execution time: 'Credential not found'. The List operation will simply omit the deleted credential from the results. Update the Credential block to select a valid credential before re-running." },
|
||||
]} />
|
||||
@@ -4,7 +4,6 @@
|
||||
"agent",
|
||||
"api",
|
||||
"condition",
|
||||
"credential",
|
||||
"evaluator",
|
||||
"function",
|
||||
"guardrails",
|
||||
|
||||
@@ -20,7 +20,7 @@ The Response block formats and sends structured HTTP responses back to API calle
|
||||
</div>
|
||||
|
||||
<Callout type="info">
|
||||
Response blocks are terminal blocks - they end workflow execution and cannot connect to other blocks.
|
||||
Response blocks are exit points — when a Response block executes, it ends the workflow and sends the HTTP response immediately. Multiple Response blocks can be placed on different branches (e.g. after a Router or Condition), but only the first one to execute determines the API response.
|
||||
</Callout>
|
||||
|
||||
## Configuration Options
|
||||
@@ -77,7 +77,11 @@ Condition (Error Detected) → Router → Response (400/500, Error Details)
|
||||
|
||||
## Outputs
|
||||
|
||||
Response blocks are terminal — no downstream blocks execute after them. However, the block does define outputs (`data`, `status`, `headers`) which are used to construct the HTTP response sent back to the API caller.
|
||||
Response blocks are exit points — when one executes, no further blocks run. The block defines outputs (`data`, `status`, `headers`) which are used to construct the HTTP response sent back to the API caller.
|
||||
|
||||
<Callout type="warning">
|
||||
If a Response block is placed on a parallel branch, there are no guarantees about whether other parallel blocks will run or not. Execution order across parallel branches is non-deterministic, so a parallel block may execute before or after the Response block on any given run. Avoid placing Response blocks in parallel with blocks that have important side effects.
|
||||
</Callout>
|
||||
|
||||
## Variable References
|
||||
|
||||
@@ -110,10 +114,10 @@ Use the `<variable.name>` syntax to dynamically insert workflow variables into y
|
||||
- **Validate variable references**: Ensure all referenced variables exist and contain the expected data types before the Response block executes
|
||||
|
||||
<FAQ items={[
|
||||
{ question: "Can I have multiple Response blocks in a workflow?", answer: "No. The Response block is a single-instance block — only one is allowed per workflow. If you need different responses for different conditions, use a Condition or Router block upstream to determine what data reaches the single Response block." },
|
||||
{ question: "Can I have multiple Response blocks in a workflow?", answer: "Yes. You can place multiple Response blocks on different branches (e.g. after a Router or Condition block). The first Response block to execute determines the API response and ends the workflow. This is useful for returning different responses based on conditions — for example, a 200 on the success branch and a 500 on the error branch." },
|
||||
{ question: "What triggers require a Response block?", answer: "The Response block is designed for use with the API Trigger. When your workflow is invoked via the API, the Response block sends the structured HTTP response back to the caller. Other trigger types (like webhooks or schedules) do not require a Response block." },
|
||||
{ question: "What is the difference between Builder and Editor mode?", answer: "Builder mode provides a visual interface for constructing your response structure with fields and types. Editor mode gives you a raw JSON code editor where you can write the response body directly. Builder mode is recommended for most use cases." },
|
||||
{ question: "What is the default status code?", answer: "If you do not specify a status code, the Response block defaults to 200 (OK). You can set any valid HTTP status code including error codes like 400, 404, or 500." },
|
||||
{ question: "Can the Response block connect to downstream blocks?", answer: "No. Response blocks are terminal — they end workflow execution and send the HTTP response. No further blocks can be connected after a Response block." },
|
||||
{ question: "Can the Response block connect to downstream blocks?", answer: "No. Response blocks are exit points — they end workflow execution and send the HTTP response. No further blocks can execute after a Response block." },
|
||||
]} />
|
||||
|
||||
|
||||
@@ -96,8 +96,9 @@ Understanding these core principles will help you build better workflows:
|
||||
2. **Automatic Parallelization**: Independent blocks run concurrently without configuration
|
||||
3. **Smart Data Flow**: Outputs flow automatically to connected blocks
|
||||
4. **Error Handling**: Failed blocks stop their execution path but don't affect independent paths
|
||||
5. **State Persistence**: All block outputs and execution details are preserved for debugging
|
||||
6. **Cycle Protection**: Workflows that call other workflows (via Workflow blocks, MCP tools, or API blocks) are tracked with a call chain. If the chain exceeds 25 hops, execution is stopped to prevent infinite loops
|
||||
5. **Response Blocks as Exit Points**: When a Response block executes, the entire workflow stops and the API response is sent immediately. Multiple Response blocks can exist on different branches — the first one to execute wins
|
||||
6. **State Persistence**: All block outputs and execution details are preserved for debugging
|
||||
7. **Cycle Protection**: Workflows that call other workflows (via Workflow blocks, MCP tools, or API blocks) are tracked with a call chain. If the chain exceeds 25 hops, execution is stopped to prevent infinite loops
|
||||
|
||||
## Next Steps
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 63 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 12 KiB |
@@ -1,5 +1,8 @@
|
||||
# Database (Required)
|
||||
DATABASE_URL="postgresql://postgres:your_password@localhost:5432/simstudio"
|
||||
DATABASE_URL="postgresql://postgres:password@localhost:5432/postgres"
|
||||
|
||||
# PostgreSQL Port (Optional) - defaults to 5432 if not specified
|
||||
# POSTGRES_PORT=5432
|
||||
|
||||
# Authentication (Required unless DISABLE_AUTH=true)
|
||||
BETTER_AUTH_SECRET=your_secret_key # Use `openssl rand -hex 32` to generate, or visit https://www.better-auth.com/docs/installation
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
'use client'
|
||||
|
||||
import { Suspense, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Suspense, useMemo, useRef, useState } from 'react'
|
||||
import { Turnstile, type TurnstileInstance } from '@marsidev/react-turnstile'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { Eye, EyeOff, Loader2 } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { usePostHog } from 'posthog-js/react'
|
||||
import { Input, Label } from '@/components/emcn'
|
||||
import { client, useSession } from '@/lib/auth/auth-client'
|
||||
import { getEnv, isFalsy, isTruthy } from '@/lib/core/config/env'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { quickValidateEmail } from '@/lib/messaging/email/validation'
|
||||
import { captureEvent } from '@/lib/posthog/client'
|
||||
import { AUTH_SUBMIT_BTN } from '@/app/(auth)/components/auth-button-classes'
|
||||
import { SocialLoginButtons } from '@/app/(auth)/components/social-login-buttons'
|
||||
import { SSOLoginButton } from '@/app/(auth)/components/sso-login-button'
|
||||
@@ -83,12 +81,7 @@ function SignupFormContent({
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const { refetch: refetchSession } = useSession()
|
||||
const posthog = usePostHog()
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
captureEvent(posthog, 'signup_page_viewed', {})
|
||||
}, [posthog])
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [password, setPassword] = useState('')
|
||||
const [passwordErrors, setPasswordErrors] = useState<string[]>([])
|
||||
@@ -99,6 +92,8 @@ function SignupFormContent({
|
||||
const [showEmailValidationError, setShowEmailValidationError] = useState(false)
|
||||
const [formError, setFormError] = useState<string | null>(null)
|
||||
const turnstileRef = useRef<TurnstileInstance>(null)
|
||||
const captchaResolveRef = useRef<((token: string) => void) | null>(null)
|
||||
const captchaRejectRef = useRef<((reason: Error) => void) | null>(null)
|
||||
const turnstileSiteKey = useMemo(() => getEnv('NEXT_PUBLIC_TURNSTILE_SITE_KEY'), [])
|
||||
const redirectUrl = useMemo(
|
||||
() => searchParams.get('redirect') || searchParams.get('callbackUrl') || '',
|
||||
@@ -256,14 +251,27 @@ function SignupFormContent({
|
||||
let token: string | undefined
|
||||
const widget = turnstileRef.current
|
||||
if (turnstileSiteKey && widget) {
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined
|
||||
try {
|
||||
widget.reset()
|
||||
widget.execute()
|
||||
token = await widget.getResponsePromise()
|
||||
token = await Promise.race([
|
||||
new Promise<string>((resolve, reject) => {
|
||||
captchaResolveRef.current = resolve
|
||||
captchaRejectRef.current = reject
|
||||
widget.execute()
|
||||
}),
|
||||
new Promise<string>((_, reject) => {
|
||||
timeoutId = setTimeout(() => reject(new Error('Captcha timed out')), 15_000)
|
||||
}),
|
||||
])
|
||||
} catch {
|
||||
setFormError('Captcha verification failed. Please try again.')
|
||||
setIsLoading(false)
|
||||
return
|
||||
} finally {
|
||||
clearTimeout(timeoutId)
|
||||
captchaResolveRef.current = null
|
||||
captchaRejectRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -520,7 +528,10 @@ function SignupFormContent({
|
||||
<Turnstile
|
||||
ref={turnstileRef}
|
||||
siteKey={turnstileSiteKey}
|
||||
options={{ execution: 'execute', appearance: 'execute' }}
|
||||
onSuccess={(token) => captchaResolveRef.current?.(token)}
|
||||
onError={() => captchaRejectRef.current?.(new Error('Captcha verification failed'))}
|
||||
onExpire={() => captchaRejectRef.current?.(new Error('Captcha token expired'))}
|
||||
options={{ execution: 'execute' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { usePostHog } from 'posthog-js/react'
|
||||
import { captureEvent } from '@/lib/posthog/client'
|
||||
|
||||
export function LandingAnalytics() {
|
||||
const posthog = usePostHog()
|
||||
|
||||
useEffect(() => {
|
||||
captureEvent(posthog, 'landing_page_viewed', {})
|
||||
}, [posthog])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
Templates,
|
||||
Testimonials,
|
||||
} from '@/app/(home)/components'
|
||||
import { LandingAnalytics } from '@/app/(home)/landing-analytics'
|
||||
|
||||
/**
|
||||
* Landing page root component.
|
||||
@@ -46,7 +45,6 @@ export default async function Landing() {
|
||||
>
|
||||
Skip to main content
|
||||
</a>
|
||||
<LandingAnalytics />
|
||||
<StructuredData />
|
||||
<header>
|
||||
<Navbar blogPosts={blogPosts} />
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
formatPrice,
|
||||
formatTokenCount,
|
||||
formatUpdatedAt,
|
||||
getEffectiveMaxOutputTokens,
|
||||
getModelBySlug,
|
||||
getPricingBounds,
|
||||
getProviderBySlug,
|
||||
@@ -199,8 +198,7 @@ export default async function ModelPage({
|
||||
</div>
|
||||
|
||||
<p className='max-w-[820px] text-[17px] text-[var(--landing-text-muted)] leading-relaxed'>
|
||||
{model.summary}
|
||||
{model.bestFor ? ` ${model.bestFor}` : ''}
|
||||
{model.summary} {model.bestFor}
|
||||
</p>
|
||||
|
||||
<div className='mt-8 flex flex-wrap gap-3'>
|
||||
@@ -231,11 +229,13 @@ export default async function ModelPage({
|
||||
? `${formatPrice(model.pricing.cachedInput)}/1M`
|
||||
: 'N/A'
|
||||
}
|
||||
compact
|
||||
/>
|
||||
<StatCard label='Output price' value={`${formatPrice(model.pricing.output)}/1M`} />
|
||||
<StatCard
|
||||
label='Context window'
|
||||
value={model.contextWindow ? formatTokenCount(model.contextWindow) : 'Unknown'}
|
||||
compact
|
||||
/>
|
||||
</section>
|
||||
|
||||
@@ -280,12 +280,12 @@ export default async function ModelPage({
|
||||
label='Max output'
|
||||
value={
|
||||
model.capabilities.maxOutputTokens
|
||||
? `${formatTokenCount(getEffectiveMaxOutputTokens(model.capabilities))} tokens`
|
||||
: 'Not published'
|
||||
? `${formatTokenCount(model.capabilities.maxOutputTokens)} tokens`
|
||||
: 'Standard defaults'
|
||||
}
|
||||
/>
|
||||
<DetailItem label='Provider' value={provider.name} />
|
||||
{model.bestFor ? <DetailItem label='Best for' value={model.bestFor} /> : null}
|
||||
<DetailItem label='Best for' value={model.bestFor} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildModelCapabilityFacts, getEffectiveMaxOutputTokens, getModelBySlug } from './utils'
|
||||
|
||||
describe('model catalog capability facts', () => {
|
||||
it.concurrent(
|
||||
'shows structured outputs support and published max output tokens for gpt-4o',
|
||||
() => {
|
||||
const model = getModelBySlug('openai', 'gpt-4o')
|
||||
|
||||
expect(model).not.toBeNull()
|
||||
expect(model).toBeDefined()
|
||||
|
||||
const capabilityFacts = buildModelCapabilityFacts(model!)
|
||||
const structuredOutputs = capabilityFacts.find((fact) => fact.label === 'Structured outputs')
|
||||
const maxOutputTokens = capabilityFacts.find((fact) => fact.label === 'Max output tokens')
|
||||
|
||||
expect(getEffectiveMaxOutputTokens(model!.capabilities)).toBe(16384)
|
||||
expect(structuredOutputs?.value).toBe('Supported')
|
||||
expect(maxOutputTokens?.value).toBe('16k')
|
||||
}
|
||||
)
|
||||
|
||||
it.concurrent('preserves native structured outputs labeling for claude models', () => {
|
||||
const model = getModelBySlug('anthropic', 'claude-sonnet-4-6')
|
||||
|
||||
expect(model).not.toBeNull()
|
||||
expect(model).toBeDefined()
|
||||
|
||||
const capabilityFacts = buildModelCapabilityFacts(model!)
|
||||
const structuredOutputs = capabilityFacts.find((fact) => fact.label === 'Structured outputs')
|
||||
|
||||
expect(structuredOutputs?.value).toBe('Supported (native)')
|
||||
})
|
||||
|
||||
it.concurrent('does not invent a max output token limit when one is not published', () => {
|
||||
expect(getEffectiveMaxOutputTokens({})).toBeNull()
|
||||
})
|
||||
|
||||
it.concurrent('keeps best-for copy for clearly differentiated models only', () => {
|
||||
const researchModel = getModelBySlug('google', 'deep-research-pro-preview-12-2025')
|
||||
const generalModel = getModelBySlug('xai', 'grok-4-latest')
|
||||
|
||||
expect(researchModel).not.toBeNull()
|
||||
expect(generalModel).not.toBeNull()
|
||||
|
||||
expect(researchModel?.bestFor).toContain('research workflows')
|
||||
expect(generalModel?.bestFor).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -112,7 +112,7 @@ export interface CatalogModel {
|
||||
capabilities: ModelCapabilities
|
||||
capabilityTags: string[]
|
||||
summary: string
|
||||
bestFor?: string
|
||||
bestFor: string
|
||||
searchText: string
|
||||
}
|
||||
|
||||
@@ -190,14 +190,6 @@ export function formatCapabilityBoolean(
|
||||
return value ? positive : negative
|
||||
}
|
||||
|
||||
function supportsCatalogStructuredOutputs(capabilities: ModelCapabilities): boolean {
|
||||
return !capabilities.deepResearch
|
||||
}
|
||||
|
||||
export function getEffectiveMaxOutputTokens(capabilities: ModelCapabilities): number | null {
|
||||
return capabilities.maxOutputTokens ?? null
|
||||
}
|
||||
|
||||
function trimTrailingZeros(value: string): string {
|
||||
return value.replace(/\.0+$/, '').replace(/(\.\d*?)0+$/, '$1')
|
||||
}
|
||||
@@ -334,7 +326,7 @@ function buildCapabilityTags(capabilities: ModelCapabilities): string[] {
|
||||
tags.push('Tool choice')
|
||||
}
|
||||
|
||||
if (supportsCatalogStructuredOutputs(capabilities)) {
|
||||
if (capabilities.nativeStructuredOutputs) {
|
||||
tags.push('Structured outputs')
|
||||
}
|
||||
|
||||
@@ -373,7 +365,7 @@ function buildBestForLine(model: {
|
||||
pricing: PricingInfo
|
||||
capabilities: ModelCapabilities
|
||||
contextWindow: number | null
|
||||
}): string | null {
|
||||
}): string {
|
||||
const { pricing, capabilities, contextWindow } = model
|
||||
|
||||
if (capabilities.deepResearch) {
|
||||
@@ -384,6 +376,10 @@ function buildBestForLine(model: {
|
||||
return 'Best for reasoning-heavy tasks that need more deliberate model control.'
|
||||
}
|
||||
|
||||
if (pricing.input <= 0.2 && pricing.output <= 1.25) {
|
||||
return 'Best for cost-sensitive automations, background tasks, and high-volume workloads.'
|
||||
}
|
||||
|
||||
if (contextWindow && contextWindow >= 1000000) {
|
||||
return 'Best for long-context retrieval, large documents, and high-memory workflows.'
|
||||
}
|
||||
@@ -392,11 +388,7 @@ function buildBestForLine(model: {
|
||||
return 'Best for production workflows that need reliable typed outputs.'
|
||||
}
|
||||
|
||||
if (pricing.input <= 0.2 && pricing.output <= 1.25) {
|
||||
return 'Best for cost-sensitive automations, background tasks, and high-volume workloads.'
|
||||
}
|
||||
|
||||
return null
|
||||
return 'Best for general-purpose AI workflows inside Sim.'
|
||||
}
|
||||
|
||||
function buildModelSummary(
|
||||
@@ -445,11 +437,6 @@ const rawProviders = Object.values(PROVIDER_DEFINITIONS).map((provider) => {
|
||||
const shortId = stripProviderPrefix(provider.id, model.id)
|
||||
const mergedCapabilities = { ...provider.capabilities, ...model.capabilities }
|
||||
const capabilityTags = buildCapabilityTags(mergedCapabilities)
|
||||
const bestFor = buildBestForLine({
|
||||
pricing: model.pricing,
|
||||
capabilities: mergedCapabilities,
|
||||
contextWindow: model.contextWindow ?? null,
|
||||
})
|
||||
const displayName = formatModelDisplayName(provider.id, model.id)
|
||||
const modelSlug = slugify(shortId)
|
||||
const href = `/models/${providerSlug}/${modelSlug}`
|
||||
@@ -474,7 +461,11 @@ const rawProviders = Object.values(PROVIDER_DEFINITIONS).map((provider) => {
|
||||
model.contextWindow ?? null,
|
||||
capabilityTags
|
||||
),
|
||||
...(bestFor ? { bestFor } : {}),
|
||||
bestFor: buildBestForLine({
|
||||
pricing: model.pricing,
|
||||
capabilities: mergedCapabilities,
|
||||
contextWindow: model.contextWindow ?? null,
|
||||
}),
|
||||
searchText: [
|
||||
provider.name,
|
||||
providerDisplayName,
|
||||
@@ -692,7 +683,6 @@ export function buildModelFaqs(provider: CatalogProvider, model: CatalogModel):
|
||||
|
||||
export function buildModelCapabilityFacts(model: CatalogModel): CapabilityFact[] {
|
||||
const { capabilities } = model
|
||||
const supportsStructuredOutputs = supportsCatalogStructuredOutputs(capabilities)
|
||||
|
||||
return [
|
||||
{
|
||||
@@ -721,11 +711,7 @@ export function buildModelCapabilityFacts(model: CatalogModel): CapabilityFact[]
|
||||
},
|
||||
{
|
||||
label: 'Structured outputs',
|
||||
value: supportsStructuredOutputs
|
||||
? capabilities.nativeStructuredOutputs
|
||||
? 'Supported (native)'
|
||||
: 'Supported'
|
||||
: 'Not supported',
|
||||
value: formatCapabilityBoolean(capabilities.nativeStructuredOutputs),
|
||||
},
|
||||
{
|
||||
label: 'Tool choice',
|
||||
@@ -746,8 +732,8 @@ export function buildModelCapabilityFacts(model: CatalogModel): CapabilityFact[]
|
||||
{
|
||||
label: 'Max output tokens',
|
||||
value: capabilities.maxOutputTokens
|
||||
? formatTokenCount(getEffectiveMaxOutputTokens(capabilities))
|
||||
: 'Not published',
|
||||
? formatTokenCount(capabilities.maxOutputTokens)
|
||||
: 'Standard defaults',
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -766,8 +752,8 @@ export function getProviderCapabilitySummary(provider: CatalogProvider): Capabil
|
||||
const reasoningCount = provider.models.filter(
|
||||
(model) => model.capabilities.reasoningEffort || model.capabilities.thinking
|
||||
).length
|
||||
const structuredCount = provider.models.filter((model) =>
|
||||
supportsCatalogStructuredOutputs(model.capabilities)
|
||||
const structuredCount = provider.models.filter(
|
||||
(model) => model.capabilities.nativeStructuredOutputs
|
||||
).length
|
||||
const deepResearchCount = provider.models.filter(
|
||||
(model) => model.capabilities.deepResearch
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* @see stores/constants.ts for the source of truth
|
||||
*/
|
||||
:root {
|
||||
--sidebar-width: 0px; /* 0 outside workspace; blocking script always sets actual value on workspace pages */
|
||||
--sidebar-width: 248px; /* SIDEBAR_WIDTH.DEFAULT */
|
||||
--panel-width: 320px; /* PANEL_WIDTH.DEFAULT */
|
||||
--toolbar-triggers-height: 300px; /* TOOLBAR_TRIGGERS_HEIGHT.DEFAULT */
|
||||
--editor-connections-height: 172px; /* EDITOR_CONNECTIONS_HEIGHT.DEFAULT */
|
||||
|
||||
@@ -7,7 +7,6 @@ import { generateAgentCard, generateSkillsFromWorkflow } from '@/lib/a2a/agent-c
|
||||
import type { AgentCapabilities, AgentSkill } from '@/lib/a2a/types'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { getRedisClient } from '@/lib/core/config/redis'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
|
||||
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
@@ -181,17 +180,6 @@ export async function DELETE(request: NextRequest, { params }: { params: Promise
|
||||
|
||||
logger.info(`Deleted A2A agent: ${agentId}`)
|
||||
|
||||
captureServerEvent(
|
||||
auth.userId,
|
||||
'a2a_agent_deleted',
|
||||
{
|
||||
agent_id: agentId,
|
||||
workflow_id: existingAgent.workflowId,
|
||||
workspace_id: existingAgent.workspaceId,
|
||||
},
|
||||
{ groups: { workspace: existingAgent.workspaceId } }
|
||||
)
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
logger.error('Error deleting agent:', error)
|
||||
@@ -263,16 +251,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<R
|
||||
}
|
||||
|
||||
logger.info(`Published A2A agent: ${agentId}`)
|
||||
captureServerEvent(
|
||||
auth.userId,
|
||||
'a2a_agent_published',
|
||||
{
|
||||
agent_id: agentId,
|
||||
workflow_id: existingAgent.workflowId,
|
||||
workspace_id: existingAgent.workspaceId,
|
||||
},
|
||||
{ groups: { workspace: existingAgent.workspaceId } }
|
||||
)
|
||||
return NextResponse.json({ success: true, isPublished: true })
|
||||
}
|
||||
|
||||
@@ -295,16 +273,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<R
|
||||
}
|
||||
|
||||
logger.info(`Unpublished A2A agent: ${agentId}`)
|
||||
captureServerEvent(
|
||||
auth.userId,
|
||||
'a2a_agent_unpublished',
|
||||
{
|
||||
agent_id: agentId,
|
||||
workflow_id: existingAgent.workflowId,
|
||||
workspace_id: existingAgent.workspaceId,
|
||||
},
|
||||
{ groups: { workspace: existingAgent.workspaceId } }
|
||||
)
|
||||
return NextResponse.json({ success: true, isPublished: false })
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ import { generateSkillsFromWorkflow } from '@/lib/a2a/agent-card'
|
||||
import { A2A_DEFAULT_CAPABILITIES } from '@/lib/a2a/constants'
|
||||
import { sanitizeAgentName } from '@/lib/a2a/utils'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
|
||||
import { hasValidStartBlockInState } from '@/lib/workflows/triggers/trigger-utils'
|
||||
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
|
||||
@@ -202,16 +201,6 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
logger.info(`Created A2A agent ${agentId} for workflow ${workflowId}`)
|
||||
|
||||
captureServerEvent(
|
||||
auth.userId,
|
||||
'a2a_agent_created',
|
||||
{ agent_id: agentId, workflow_id: workflowId, workspace_id: workspaceId },
|
||||
{
|
||||
groups: { workspace: workspaceId },
|
||||
setOnce: { first_a2a_agent_created_at: new Date().toISOString() },
|
||||
}
|
||||
)
|
||||
|
||||
return NextResponse.json({ success: true, agent }, { status: 201 })
|
||||
} catch (error) {
|
||||
logger.error('Error creating agent:', error)
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
hasUsableSubscriptionStatus,
|
||||
} from '@/lib/billing/subscriptions/utils'
|
||||
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
|
||||
const logger = createLogger('SwitchPlan')
|
||||
|
||||
@@ -174,13 +173,6 @@ export async function POST(request: NextRequest) {
|
||||
interval: targetInterval,
|
||||
})
|
||||
|
||||
captureServerEvent(
|
||||
userId,
|
||||
'subscription_changed',
|
||||
{ from_plan: sub.plan ?? 'unknown', to_plan: targetPlanName, interval: targetInterval },
|
||||
{ set: { plan: targetPlanName } }
|
||||
)
|
||||
|
||||
return NextResponse.json({ success: true, plan: targetPlanName, interval: targetInterval })
|
||||
} catch (error) {
|
||||
logger.error('Failed to switch subscription', {
|
||||
|
||||
@@ -27,7 +27,6 @@ import {
|
||||
createRequestTracker,
|
||||
createUnauthorizedResponse,
|
||||
} from '@/lib/copilot/request-helpers'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import {
|
||||
authorizeWorkflowByWorkspacePermission,
|
||||
resolveWorkflowIdForUser,
|
||||
@@ -189,22 +188,6 @@ export async function POST(req: NextRequest) {
|
||||
.warn('Failed to resolve workspaceId from workflow')
|
||||
}
|
||||
|
||||
captureServerEvent(
|
||||
authenticatedUserId,
|
||||
'copilot_chat_sent',
|
||||
{
|
||||
workflow_id: workflowId,
|
||||
workspace_id: resolvedWorkspaceId ?? '',
|
||||
has_file_attachments: Array.isArray(fileAttachments) && fileAttachments.length > 0,
|
||||
has_contexts: Array.isArray(contexts) && contexts.length > 0,
|
||||
mode,
|
||||
},
|
||||
{
|
||||
groups: resolvedWorkspaceId ? { workspace: resolvedWorkspaceId } : undefined,
|
||||
setOnce: { first_copilot_use_at: new Date().toISOString() },
|
||||
}
|
||||
)
|
||||
|
||||
const userMessageIdToUse = userMessageId || crypto.randomUUID()
|
||||
const reqLogger = logger.withMetadata({
|
||||
requestId: tracker.requestId,
|
||||
|
||||
@@ -304,6 +304,7 @@ describe('Copilot Checkpoints Revert API Route', () => {
|
||||
loops: {},
|
||||
parallels: {},
|
||||
isDeployed: true,
|
||||
deploymentStatuses: { production: 'deployed' },
|
||||
},
|
||||
}
|
||||
|
||||
@@ -348,6 +349,7 @@ describe('Copilot Checkpoints Revert API Route', () => {
|
||||
loops: {},
|
||||
parallels: {},
|
||||
isDeployed: true,
|
||||
deploymentStatuses: { production: 'deployed' },
|
||||
lastSaved: 1640995200000,
|
||||
},
|
||||
},
|
||||
@@ -368,6 +370,7 @@ describe('Copilot Checkpoints Revert API Route', () => {
|
||||
loops: {},
|
||||
parallels: {},
|
||||
isDeployed: true,
|
||||
deploymentStatuses: { production: 'deployed' },
|
||||
lastSaved: 1640995200000,
|
||||
}),
|
||||
}
|
||||
@@ -470,6 +473,7 @@ describe('Copilot Checkpoints Revert API Route', () => {
|
||||
edges: undefined,
|
||||
loops: null,
|
||||
parallels: undefined,
|
||||
deploymentStatuses: null,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -504,6 +508,7 @@ describe('Copilot Checkpoints Revert API Route', () => {
|
||||
loops: {},
|
||||
parallels: {},
|
||||
isDeployed: false,
|
||||
deploymentStatuses: {},
|
||||
lastSaved: 1640995200000,
|
||||
})
|
||||
})
|
||||
@@ -763,6 +768,10 @@ describe('Copilot Checkpoints Revert API Route', () => {
|
||||
parallel1: { branches: ['branch1', 'branch2'] },
|
||||
},
|
||||
isDeployed: true,
|
||||
deploymentStatuses: {
|
||||
production: 'deployed',
|
||||
staging: 'pending',
|
||||
},
|
||||
deployedAt: '2024-01-01T10:00:00.000Z',
|
||||
},
|
||||
}
|
||||
@@ -807,6 +816,10 @@ describe('Copilot Checkpoints Revert API Route', () => {
|
||||
parallel1: { branches: ['branch1', 'branch2'] },
|
||||
},
|
||||
isDeployed: true,
|
||||
deploymentStatuses: {
|
||||
production: 'deployed',
|
||||
staging: 'pending',
|
||||
},
|
||||
deployedAt: '2024-01-01T10:00:00.000Z',
|
||||
lastSaved: 1640995200000,
|
||||
})
|
||||
|
||||
@@ -82,6 +82,7 @@ export async function POST(request: NextRequest) {
|
||||
loops: checkpointState?.loops || {},
|
||||
parallels: checkpointState?.parallels || {},
|
||||
isDeployed: checkpointState?.isDeployed || false,
|
||||
deploymentStatuses: checkpointState?.deploymentStatuses || {},
|
||||
lastSaved: Date.now(),
|
||||
...(checkpointState?.deployedAt &&
|
||||
checkpointState.deployedAt !== null &&
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
createRequestTracker,
|
||||
createUnauthorizedResponse,
|
||||
} from '@/lib/copilot/request-helpers'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
|
||||
const logger = createLogger('CopilotFeedbackAPI')
|
||||
|
||||
@@ -77,12 +76,6 @@ export async function POST(req: NextRequest) {
|
||||
duration: tracker.getDuration(),
|
||||
})
|
||||
|
||||
captureServerEvent(authenticatedUserId, 'copilot_feedback_submitted', {
|
||||
is_positive: isPositiveFeedback,
|
||||
has_text_feedback: !!feedback,
|
||||
has_workflow_yaml: !!workflowYaml,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
feedbackId: feedbackRecord.feedbackId,
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
syncPersonalEnvCredentialsForUser,
|
||||
syncWorkspaceEnvCredentials,
|
||||
} from '@/lib/credentials/environment'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
|
||||
const logger = createLogger('CredentialByIdAPI')
|
||||
|
||||
@@ -237,17 +236,6 @@ export async function DELETE(
|
||||
envKeys: Object.keys(current),
|
||||
})
|
||||
|
||||
captureServerEvent(
|
||||
session.user.id,
|
||||
'credential_deleted',
|
||||
{
|
||||
credential_type: 'env_personal',
|
||||
provider_id: access.credential.envKey,
|
||||
workspace_id: access.credential.workspaceId,
|
||||
},
|
||||
{ groups: { workspace: access.credential.workspaceId } }
|
||||
)
|
||||
|
||||
return NextResponse.json({ success: true }, { status: 200 })
|
||||
}
|
||||
|
||||
@@ -290,33 +278,10 @@ export async function DELETE(
|
||||
actingUserId: session.user.id,
|
||||
})
|
||||
|
||||
captureServerEvent(
|
||||
session.user.id,
|
||||
'credential_deleted',
|
||||
{
|
||||
credential_type: 'env_workspace',
|
||||
provider_id: access.credential.envKey,
|
||||
workspace_id: access.credential.workspaceId,
|
||||
},
|
||||
{ groups: { workspace: access.credential.workspaceId } }
|
||||
)
|
||||
|
||||
return NextResponse.json({ success: true }, { status: 200 })
|
||||
}
|
||||
|
||||
await db.delete(credential).where(eq(credential.id, id))
|
||||
|
||||
captureServerEvent(
|
||||
session.user.id,
|
||||
'credential_deleted',
|
||||
{
|
||||
credential_type: access.credential.type as 'oauth' | 'service_account',
|
||||
provider_id: access.credential.providerId ?? id,
|
||||
workspace_id: access.credential.workspaceId,
|
||||
},
|
||||
{ groups: { workspace: access.credential.workspaceId } }
|
||||
)
|
||||
|
||||
return NextResponse.json({ success: true }, { status: 200 })
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete credential', error)
|
||||
|
||||
@@ -10,7 +10,6 @@ import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { getWorkspaceMemberUserIds } from '@/lib/credentials/environment'
|
||||
import { syncWorkspaceOAuthCredentialsForUser } from '@/lib/credentials/oauth'
|
||||
import { getServiceConfigByProviderId } from '@/lib/oauth'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
|
||||
import { isValidEnvVarName } from '@/executor/constants'
|
||||
|
||||
@@ -601,16 +600,6 @@ export async function POST(request: NextRequest) {
|
||||
.where(eq(credential.id, credentialId))
|
||||
.limit(1)
|
||||
|
||||
captureServerEvent(
|
||||
session.user.id,
|
||||
'credential_connected',
|
||||
{ credential_type: type, provider_id: resolvedProviderId ?? type, workspace_id: workspaceId },
|
||||
{
|
||||
groups: { workspace: workspaceId },
|
||||
setOnce: { first_credential_connected_at: new Date().toISOString() },
|
||||
}
|
||||
)
|
||||
|
||||
return NextResponse.json({ credential: created }, { status: 201 })
|
||||
} catch (error: any) {
|
||||
if (error?.code === '23505') {
|
||||
|
||||
@@ -5,7 +5,6 @@ import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import { performDeleteFolder } from '@/lib/workflows/orchestration'
|
||||
import { checkForCircularReference } from '@/lib/workflows/utils'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
@@ -157,13 +156,6 @@ export async function DELETE(
|
||||
return NextResponse.json({ error: result.error }, { status })
|
||||
}
|
||||
|
||||
captureServerEvent(
|
||||
session.user.id,
|
||||
'folder_deleted',
|
||||
{ workspace_id: existingFolder.workspaceId },
|
||||
{ groups: { workspace: existingFolder.workspaceId } }
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
deletedItems: result.deletedItems,
|
||||
|
||||
@@ -6,7 +6,6 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
const logger = createLogger('FoldersAPI')
|
||||
@@ -146,13 +145,6 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
logger.info('Created new folder:', { id, name, workspaceId, parentId })
|
||||
|
||||
captureServerEvent(
|
||||
session.user.id,
|
||||
'folder_created',
|
||||
{ workspace_id: workspaceId },
|
||||
{ groups: { workspace: workspaceId } }
|
||||
)
|
||||
|
||||
recordAudit({
|
||||
workspaceId,
|
||||
actorId: session.user.id,
|
||||
|
||||
@@ -13,11 +13,9 @@ import { z } from 'zod'
|
||||
import { decryptApiKey } from '@/lib/api-key/crypto'
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { hasLiveSyncAccess } from '@/lib/billing/core/subscription'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { deleteDocumentStorageFiles } from '@/lib/knowledge/documents/service'
|
||||
import { cleanupUnusedTagDefinitions } from '@/lib/knowledge/tags/service'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
import { checkKnowledgeBaseAccess, checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils'
|
||||
import { CONNECTOR_REGISTRY } from '@/connectors/registry'
|
||||
@@ -117,20 +115,6 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
||||
)
|
||||
}
|
||||
|
||||
if (
|
||||
parsed.data.syncIntervalMinutes !== undefined &&
|
||||
parsed.data.syncIntervalMinutes > 0 &&
|
||||
parsed.data.syncIntervalMinutes < 60
|
||||
) {
|
||||
const canUseLiveSync = await hasLiveSyncAccess(auth.userId)
|
||||
if (!canUseLiveSync) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Live sync requires a Max or Enterprise plan' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (parsed.data.sourceConfig !== undefined) {
|
||||
const existingRows = await db
|
||||
.select()
|
||||
@@ -367,19 +351,6 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||
`[${requestId}] Deleted connector ${connectorId}${deleteDocuments ? ` and ${docCount} documents` : `, kept ${docCount} documents`}`
|
||||
)
|
||||
|
||||
const kbWorkspaceId = writeCheck.knowledgeBase.workspaceId ?? ''
|
||||
captureServerEvent(
|
||||
auth.userId,
|
||||
'knowledge_base_connector_removed',
|
||||
{
|
||||
knowledge_base_id: knowledgeBaseId,
|
||||
workspace_id: kbWorkspaceId,
|
||||
connector_type: existingConnector[0].connectorType,
|
||||
documents_deleted: deleteDocuments ? docCount : 0,
|
||||
},
|
||||
kbWorkspaceId ? { groups: { workspace: kbWorkspaceId } } : undefined
|
||||
)
|
||||
|
||||
recordAudit({
|
||||
workspaceId: writeCheck.knowledgeBase.workspaceId,
|
||||
actorId: auth.userId,
|
||||
|
||||
@@ -7,7 +7,6 @@ import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { dispatchSync } from '@/lib/knowledge/connectors/sync-engine'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import { checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils'
|
||||
|
||||
const logger = createLogger('ConnectorManualSyncAPI')
|
||||
@@ -56,18 +55,6 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
|
||||
logger.info(`[${requestId}] Manual sync triggered for connector ${connectorId}`)
|
||||
|
||||
const kbWorkspaceId = writeCheck.knowledgeBase.workspaceId ?? ''
|
||||
captureServerEvent(
|
||||
auth.userId,
|
||||
'knowledge_base_connector_synced',
|
||||
{
|
||||
knowledge_base_id: knowledgeBaseId,
|
||||
workspace_id: kbWorkspaceId,
|
||||
connector_type: connectorRows[0].connectorType,
|
||||
},
|
||||
kbWorkspaceId ? { groups: { workspace: kbWorkspaceId } } : undefined
|
||||
)
|
||||
|
||||
recordAudit({
|
||||
workspaceId: writeCheck.knowledgeBase.workspaceId,
|
||||
actorId: auth.userId,
|
||||
|
||||
@@ -7,12 +7,10 @@ import { z } from 'zod'
|
||||
import { encryptApiKey } from '@/lib/api-key/crypto'
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { hasLiveSyncAccess } from '@/lib/billing/core/subscription'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { dispatchSync } from '@/lib/knowledge/connectors/sync-engine'
|
||||
import { allocateTagSlots } from '@/lib/knowledge/constants'
|
||||
import { createTagDefinition } from '@/lib/knowledge/tags/service'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import { getCredential } from '@/app/api/auth/oauth/utils'
|
||||
import { checkKnowledgeBaseAccess, checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils'
|
||||
import { CONNECTOR_REGISTRY } from '@/connectors/registry'
|
||||
@@ -98,16 +96,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
|
||||
const { connectorType, credentialId, apiKey, sourceConfig, syncIntervalMinutes } = parsed.data
|
||||
|
||||
if (syncIntervalMinutes > 0 && syncIntervalMinutes < 60) {
|
||||
const canUseLiveSync = await hasLiveSyncAccess(auth.userId)
|
||||
if (!canUseLiveSync) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Live sync requires a Max or Enterprise plan' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const connectorConfig = CONNECTOR_REGISTRY[connectorType]
|
||||
if (!connectorConfig) {
|
||||
return NextResponse.json(
|
||||
@@ -162,39 +150,19 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
}
|
||||
|
||||
const tagSlotMapping: Record<string, string> = {}
|
||||
let newTagSlots: Record<string, string> = {}
|
||||
|
||||
if (connectorConfig.tagDefinitions?.length) {
|
||||
const disabledIds = new Set((sourceConfig.disabledTagIds as string[] | undefined) ?? [])
|
||||
const enabledDefs = connectorConfig.tagDefinitions.filter((td) => !disabledIds.has(td.id))
|
||||
|
||||
const existingDefs = await db
|
||||
.select({
|
||||
tagSlot: knowledgeBaseTagDefinitions.tagSlot,
|
||||
displayName: knowledgeBaseTagDefinitions.displayName,
|
||||
fieldType: knowledgeBaseTagDefinitions.fieldType,
|
||||
})
|
||||
.select({ tagSlot: knowledgeBaseTagDefinitions.tagSlot })
|
||||
.from(knowledgeBaseTagDefinitions)
|
||||
.where(eq(knowledgeBaseTagDefinitions.knowledgeBaseId, knowledgeBaseId))
|
||||
|
||||
const usedSlots = new Set<string>(existingDefs.map((d) => d.tagSlot))
|
||||
const existingByName = new Map(
|
||||
existingDefs.map((d) => [d.displayName, { tagSlot: d.tagSlot, fieldType: d.fieldType }])
|
||||
)
|
||||
|
||||
const defsNeedingSlots: typeof enabledDefs = []
|
||||
for (const td of enabledDefs) {
|
||||
const existing = existingByName.get(td.displayName)
|
||||
if (existing && existing.fieldType === td.fieldType) {
|
||||
tagSlotMapping[td.id] = existing.tagSlot
|
||||
} else {
|
||||
defsNeedingSlots.push(td)
|
||||
}
|
||||
}
|
||||
|
||||
const { mapping, skipped: skippedTags } = allocateTagSlots(defsNeedingSlots, usedSlots)
|
||||
const { mapping, skipped: skippedTags } = allocateTagSlots(enabledDefs, usedSlots)
|
||||
Object.assign(tagSlotMapping, mapping)
|
||||
newTagSlots = mapping
|
||||
|
||||
for (const name of skippedTags) {
|
||||
logger.warn(`[${requestId}] No available slots for "${name}"`)
|
||||
@@ -228,7 +196,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
throw new Error('Knowledge base not found')
|
||||
}
|
||||
|
||||
for (const [semanticId, slot] of Object.entries(newTagSlots)) {
|
||||
for (const [semanticId, slot] of Object.entries(tagSlotMapping)) {
|
||||
const td = connectorConfig.tagDefinitions!.find((d) => d.id === semanticId)!
|
||||
await createTagDefinition(
|
||||
{
|
||||
@@ -259,22 +227,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
|
||||
logger.info(`[${requestId}] Created connector ${connectorId} for KB ${knowledgeBaseId}`)
|
||||
|
||||
const kbWorkspaceId = writeCheck.knowledgeBase.workspaceId ?? ''
|
||||
captureServerEvent(
|
||||
auth.userId,
|
||||
'knowledge_base_connector_added',
|
||||
{
|
||||
knowledge_base_id: knowledgeBaseId,
|
||||
workspace_id: kbWorkspaceId,
|
||||
connector_type: connectorType,
|
||||
sync_interval_minutes: syncIntervalMinutes,
|
||||
},
|
||||
{
|
||||
groups: kbWorkspaceId ? { workspace: kbWorkspaceId } : undefined,
|
||||
setOnce: { first_connector_added_at: new Date().toISOString() },
|
||||
}
|
||||
)
|
||||
|
||||
recordAudit({
|
||||
workspaceId: writeCheck.knowledgeBase.workspaceId,
|
||||
actorId: auth.userId,
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
retryDocumentProcessing,
|
||||
updateDocument,
|
||||
} from '@/lib/knowledge/documents/service'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import { checkDocumentAccess, checkDocumentWriteAccess } from '@/app/api/knowledge/utils'
|
||||
|
||||
const logger = createLogger('DocumentByIdAPI')
|
||||
@@ -286,14 +285,6 @@ export async function DELETE(
|
||||
request: req,
|
||||
})
|
||||
|
||||
const kbWorkspaceId = accessCheck.knowledgeBase?.workspaceId ?? ''
|
||||
captureServerEvent(
|
||||
userId,
|
||||
'knowledge_base_document_deleted',
|
||||
{ knowledge_base_id: knowledgeBaseId, workspace_id: kbWorkspaceId },
|
||||
kbWorkspaceId ? { groups: { workspace: kbWorkspaceId } } : undefined
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: result,
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
type TagFilterCondition,
|
||||
} from '@/lib/knowledge/documents/service'
|
||||
import type { DocumentSortField, SortOrder } from '@/lib/knowledge/documents/types'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
|
||||
import { checkKnowledgeBaseAccess, checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils'
|
||||
|
||||
@@ -215,8 +214,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const kbWorkspaceId = accessCheck.knowledgeBase?.workspaceId
|
||||
|
||||
if (body.bulk === true) {
|
||||
try {
|
||||
const validatedData = BulkCreateDocumentsSchema.parse(body)
|
||||
@@ -243,21 +240,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
// Silently fail
|
||||
}
|
||||
|
||||
captureServerEvent(
|
||||
userId,
|
||||
'knowledge_base_document_uploaded',
|
||||
{
|
||||
knowledge_base_id: knowledgeBaseId,
|
||||
workspace_id: kbWorkspaceId ?? '',
|
||||
document_count: createdDocuments.length,
|
||||
upload_type: 'bulk',
|
||||
},
|
||||
{
|
||||
...(kbWorkspaceId ? { groups: { workspace: kbWorkspaceId } } : {}),
|
||||
setOnce: { first_document_uploaded_at: new Date().toISOString() },
|
||||
}
|
||||
)
|
||||
|
||||
processDocumentsWithQueue(
|
||||
createdDocuments,
|
||||
knowledgeBaseId,
|
||||
@@ -332,21 +314,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
// Silently fail
|
||||
}
|
||||
|
||||
captureServerEvent(
|
||||
userId,
|
||||
'knowledge_base_document_uploaded',
|
||||
{
|
||||
knowledge_base_id: knowledgeBaseId,
|
||||
workspace_id: kbWorkspaceId ?? '',
|
||||
document_count: 1,
|
||||
upload_type: 'single',
|
||||
},
|
||||
{
|
||||
...(kbWorkspaceId ? { groups: { workspace: kbWorkspaceId } } : {}),
|
||||
setOnce: { first_document_uploaded_at: new Date().toISOString() },
|
||||
}
|
||||
)
|
||||
|
||||
recordAudit({
|
||||
workspaceId: accessCheck.knowledgeBase?.workspaceId ?? null,
|
||||
actorId: userId,
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
KnowledgeBaseConflictError,
|
||||
type KnowledgeBaseScope,
|
||||
} from '@/lib/knowledge/service'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
|
||||
const logger = createLogger('KnowledgeBaseAPI')
|
||||
|
||||
@@ -116,20 +115,6 @@ export async function POST(req: NextRequest) {
|
||||
// Telemetry should not fail the operation
|
||||
}
|
||||
|
||||
captureServerEvent(
|
||||
session.user.id,
|
||||
'knowledge_base_created',
|
||||
{
|
||||
knowledge_base_id: newKnowledgeBase.id,
|
||||
workspace_id: validatedData.workspaceId,
|
||||
name: validatedData.name,
|
||||
},
|
||||
{
|
||||
groups: { workspace: validatedData.workspaceId },
|
||||
setOnce: { first_kb_created_at: new Date().toISOString() },
|
||||
}
|
||||
)
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Knowledge base created: ${newKnowledgeBase.id} for user ${session.user.id}`
|
||||
)
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { createEnvMock, databaseMock, loggerMock } from '@sim/testing'
|
||||
import { mockNextFetchResponse } from '@sim/testing/mocks'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('drizzle-orm')
|
||||
@@ -15,6 +14,16 @@ vi.mock('@/lib/knowledge/documents/utils', () => ({
|
||||
retryWithExponentialBackoff: (fn: any) => fn(),
|
||||
}))
|
||||
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [{ embedding: [0.1, 0.2, 0.3] }],
|
||||
}),
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@/lib/core/config/env', () => createEnvMock())
|
||||
|
||||
import {
|
||||
@@ -169,16 +178,17 @@ describe('Knowledge Search Utils', () => {
|
||||
OPENAI_API_KEY: 'test-openai-key',
|
||||
})
|
||||
|
||||
mockNextFetchResponse({
|
||||
json: {
|
||||
const fetchSpy = vi.mocked(fetch)
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [{ embedding: [0.1, 0.2, 0.3] }],
|
||||
usage: { prompt_tokens: 1, total_tokens: 1 },
|
||||
},
|
||||
})
|
||||
}),
|
||||
} as any)
|
||||
|
||||
const result = await generateSearchEmbedding('test query')
|
||||
|
||||
expect(vi.mocked(fetch)).toHaveBeenCalledWith(
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
'https://test.openai.azure.com/openai/deployments/text-embedding-ada-002/embeddings?api-version=2024-12-01-preview',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
@@ -199,16 +209,17 @@ describe('Knowledge Search Utils', () => {
|
||||
OPENAI_API_KEY: 'test-openai-key',
|
||||
})
|
||||
|
||||
mockNextFetchResponse({
|
||||
json: {
|
||||
const fetchSpy = vi.mocked(fetch)
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [{ embedding: [0.1, 0.2, 0.3] }],
|
||||
usage: { prompt_tokens: 1, total_tokens: 1 },
|
||||
},
|
||||
})
|
||||
}),
|
||||
} as any)
|
||||
|
||||
const result = await generateSearchEmbedding('test query')
|
||||
|
||||
expect(vi.mocked(fetch)).toHaveBeenCalledWith(
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
'https://api.openai.com/v1/embeddings',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
@@ -232,16 +243,17 @@ describe('Knowledge Search Utils', () => {
|
||||
OPENAI_API_KEY: 'test-openai-key',
|
||||
})
|
||||
|
||||
mockNextFetchResponse({
|
||||
json: {
|
||||
const fetchSpy = vi.mocked(fetch)
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [{ embedding: [0.1, 0.2, 0.3] }],
|
||||
usage: { prompt_tokens: 1, total_tokens: 1 },
|
||||
},
|
||||
})
|
||||
}),
|
||||
} as any)
|
||||
|
||||
await generateSearchEmbedding('test query')
|
||||
|
||||
expect(vi.mocked(fetch)).toHaveBeenCalledWith(
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('api-version='),
|
||||
expect.any(Object)
|
||||
)
|
||||
@@ -261,16 +273,17 @@ describe('Knowledge Search Utils', () => {
|
||||
OPENAI_API_KEY: 'test-openai-key',
|
||||
})
|
||||
|
||||
mockNextFetchResponse({
|
||||
json: {
|
||||
const fetchSpy = vi.mocked(fetch)
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [{ embedding: [0.1, 0.2, 0.3] }],
|
||||
usage: { prompt_tokens: 1, total_tokens: 1 },
|
||||
},
|
||||
})
|
||||
}),
|
||||
} as any)
|
||||
|
||||
await generateSearchEmbedding('test query', 'text-embedding-3-small')
|
||||
|
||||
expect(vi.mocked(fetch)).toHaveBeenCalledWith(
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
'https://test.openai.azure.com/openai/deployments/custom-embedding-model/embeddings?api-version=2024-12-01-preview',
|
||||
expect.any(Object)
|
||||
)
|
||||
@@ -298,12 +311,13 @@ describe('Knowledge Search Utils', () => {
|
||||
KB_OPENAI_MODEL_NAME: 'text-embedding-ada-002',
|
||||
})
|
||||
|
||||
mockNextFetchResponse({
|
||||
const fetchSpy = vi.mocked(fetch)
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 404,
|
||||
statusText: 'Not Found',
|
||||
text: 'Deployment not found',
|
||||
})
|
||||
text: async () => 'Deployment not found',
|
||||
} as any)
|
||||
|
||||
await expect(generateSearchEmbedding('test query')).rejects.toThrow('Embedding API failed')
|
||||
|
||||
@@ -318,12 +332,13 @@ describe('Knowledge Search Utils', () => {
|
||||
OPENAI_API_KEY: 'test-openai-key',
|
||||
})
|
||||
|
||||
mockNextFetchResponse({
|
||||
const fetchSpy = vi.mocked(fetch)
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 429,
|
||||
statusText: 'Too Many Requests',
|
||||
text: 'Rate limit exceeded',
|
||||
})
|
||||
text: async () => 'Rate limit exceeded',
|
||||
} as any)
|
||||
|
||||
await expect(generateSearchEmbedding('test query')).rejects.toThrow('Embedding API failed')
|
||||
|
||||
@@ -341,16 +356,17 @@ describe('Knowledge Search Utils', () => {
|
||||
KB_OPENAI_MODEL_NAME: 'text-embedding-ada-002',
|
||||
})
|
||||
|
||||
mockNextFetchResponse({
|
||||
json: {
|
||||
const fetchSpy = vi.mocked(fetch)
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [{ embedding: [0.1, 0.2, 0.3] }],
|
||||
usage: { prompt_tokens: 1, total_tokens: 1 },
|
||||
},
|
||||
})
|
||||
}),
|
||||
} as any)
|
||||
|
||||
await generateSearchEmbedding('test query')
|
||||
|
||||
expect(vi.mocked(fetch)).toHaveBeenCalledWith(
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
@@ -371,16 +387,17 @@ describe('Knowledge Search Utils', () => {
|
||||
OPENAI_API_KEY: 'test-openai-key',
|
||||
})
|
||||
|
||||
mockNextFetchResponse({
|
||||
json: {
|
||||
const fetchSpy = vi.mocked(fetch)
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [{ embedding: [0.1, 0.2, 0.3] }],
|
||||
usage: { prompt_tokens: 1, total_tokens: 1 },
|
||||
},
|
||||
})
|
||||
}),
|
||||
} as any)
|
||||
|
||||
await generateSearchEmbedding('test query', 'text-embedding-3-small')
|
||||
|
||||
expect(vi.mocked(fetch)).toHaveBeenCalledWith(
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
|
||||
@@ -77,7 +77,6 @@ vi.stubGlobal(
|
||||
{ embedding: [0.1, 0.2], index: 0 },
|
||||
{ embedding: [0.3, 0.4], index: 1 },
|
||||
],
|
||||
usage: { prompt_tokens: 2, total_tokens: 2 },
|
||||
}),
|
||||
})
|
||||
)
|
||||
@@ -295,7 +294,7 @@ describe('Knowledge Utils', () => {
|
||||
it.concurrent('should return same length as input', async () => {
|
||||
const result = await generateEmbeddings(['a', 'b'])
|
||||
|
||||
expect(result.embeddings.length).toBe(2)
|
||||
expect(result.length).toBe(2)
|
||||
})
|
||||
|
||||
it('should use Azure OpenAI when Azure config is provided', async () => {
|
||||
@@ -314,7 +313,6 @@ describe('Knowledge Utils', () => {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [{ embedding: [0.1, 0.2], index: 0 }],
|
||||
usage: { prompt_tokens: 1, total_tokens: 1 },
|
||||
}),
|
||||
} as any)
|
||||
|
||||
@@ -344,7 +342,6 @@ describe('Knowledge Utils', () => {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [{ embedding: [0.1, 0.2], index: 0 }],
|
||||
usage: { prompt_tokens: 1, total_tokens: 1 },
|
||||
}),
|
||||
} as any)
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
createMcpSuccessResponse,
|
||||
generateMcpServerId,
|
||||
} from '@/lib/mcp/utils'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
|
||||
const logger = createLogger('McpServersAPI')
|
||||
|
||||
@@ -181,20 +180,6 @@ export const POST = withMcpAuth('write')(
|
||||
// Silently fail
|
||||
}
|
||||
|
||||
const sourceParam = body.source as string | undefined
|
||||
const source =
|
||||
sourceParam === 'settings' || sourceParam === 'tool_input' ? sourceParam : undefined
|
||||
|
||||
captureServerEvent(
|
||||
userId,
|
||||
'mcp_server_connected',
|
||||
{ workspace_id: workspaceId, server_name: body.name, transport: body.transport, source },
|
||||
{
|
||||
groups: { workspace: workspaceId },
|
||||
setOnce: { first_mcp_connected_at: new Date().toISOString() },
|
||||
}
|
||||
)
|
||||
|
||||
recordAudit({
|
||||
workspaceId,
|
||||
actorId: userId,
|
||||
@@ -229,9 +214,6 @@ export const DELETE = withMcpAuth('admin')(
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const serverId = searchParams.get('serverId')
|
||||
const sourceParam = searchParams.get('source')
|
||||
const source =
|
||||
sourceParam === 'settings' || sourceParam === 'tool_input' ? sourceParam : undefined
|
||||
|
||||
if (!serverId) {
|
||||
return createMcpErrorResponse(
|
||||
@@ -260,13 +242,6 @@ export const DELETE = withMcpAuth('admin')(
|
||||
|
||||
logger.info(`[${requestId}] Successfully deleted MCP server: ${serverId}`)
|
||||
|
||||
captureServerEvent(
|
||||
userId,
|
||||
'mcp_server_disconnected',
|
||||
{ workspace_id: workspaceId, server_name: deletedServer.name, source },
|
||||
{ groups: { workspace: workspaceId } }
|
||||
)
|
||||
|
||||
recordAudit({
|
||||
workspaceId,
|
||||
actorId: userId,
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
createUnauthorizedResponse,
|
||||
} from '@/lib/copilot/request-helpers'
|
||||
import { taskPubSub } from '@/lib/copilot/task-events'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
|
||||
const logger = createLogger('MothershipChatAPI')
|
||||
|
||||
@@ -143,32 +142,12 @@ export async function PATCH(
|
||||
return NextResponse.json({ success: false, error: 'Chat not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (updatedChat.workspaceId) {
|
||||
if (title !== undefined) {
|
||||
taskPubSub?.publishStatusChanged({
|
||||
workspaceId: updatedChat.workspaceId,
|
||||
chatId,
|
||||
type: 'renamed',
|
||||
})
|
||||
captureServerEvent(
|
||||
userId,
|
||||
'task_renamed',
|
||||
{ workspace_id: updatedChat.workspaceId },
|
||||
{
|
||||
groups: { workspace: updatedChat.workspaceId },
|
||||
}
|
||||
)
|
||||
}
|
||||
if (isUnread === true) {
|
||||
captureServerEvent(
|
||||
userId,
|
||||
'task_marked_unread',
|
||||
{ workspace_id: updatedChat.workspaceId },
|
||||
{
|
||||
groups: { workspace: updatedChat.workspaceId },
|
||||
}
|
||||
)
|
||||
}
|
||||
if (title !== undefined && updatedChat.workspaceId) {
|
||||
taskPubSub?.publishStatusChanged({
|
||||
workspaceId: updatedChat.workspaceId,
|
||||
chatId,
|
||||
type: 'renamed',
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
@@ -224,14 +203,6 @@ export async function DELETE(
|
||||
chatId,
|
||||
type: 'deleted',
|
||||
})
|
||||
captureServerEvent(
|
||||
userId,
|
||||
'task_deleted',
|
||||
{ workspace_id: deletedChat.workspaceId },
|
||||
{
|
||||
groups: { workspace: deletedChat.workspaceId },
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
createUnauthorizedResponse,
|
||||
} from '@/lib/copilot/request-helpers'
|
||||
import { taskPubSub } from '@/lib/copilot/task-events'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import { assertActiveWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
const logger = createLogger('MothershipChatsAPI')
|
||||
@@ -96,15 +95,6 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
taskPubSub?.publishStatusChanged({ workspaceId, chatId: chat.id, type: 'created' })
|
||||
|
||||
captureServerEvent(
|
||||
userId,
|
||||
'task_created',
|
||||
{ workspace_id: workspaceId },
|
||||
{
|
||||
groups: { workspace: workspaceId },
|
||||
}
|
||||
)
|
||||
|
||||
return NextResponse.json({ success: true, id: chat.id })
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
|
||||
@@ -7,7 +7,6 @@ import { z } from 'zod'
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import { validateCronExpression } from '@/lib/workflows/schedules/utils'
|
||||
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
|
||||
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
|
||||
@@ -299,13 +298,6 @@ export async function DELETE(
|
||||
request,
|
||||
})
|
||||
|
||||
captureServerEvent(
|
||||
session.user.id,
|
||||
'scheduled_task_deleted',
|
||||
{ workspace_id: workspaceId ?? '' },
|
||||
workspaceId ? { groups: { workspace: workspaceId } } : undefined
|
||||
)
|
||||
|
||||
return NextResponse.json({ message: 'Schedule deleted successfully' })
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error deleting schedule`, error)
|
||||
|
||||
@@ -3,9 +3,6 @@
|
||||
*
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
import { createFeatureFlagsMock, createMockRequest } from '@sim/testing'
|
||||
import { drizzleOrmMock } from '@sim/testing/mocks'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
@@ -13,6 +10,7 @@ const {
|
||||
mockVerifyCronAuth,
|
||||
mockExecuteScheduleJob,
|
||||
mockExecuteJobInline,
|
||||
mockFeatureFlags,
|
||||
mockDbReturning,
|
||||
mockDbUpdate,
|
||||
mockEnqueue,
|
||||
@@ -35,6 +33,12 @@ const {
|
||||
mockVerifyCronAuth: vi.fn().mockReturnValue(null),
|
||||
mockExecuteScheduleJob: vi.fn().mockResolvedValue(undefined),
|
||||
mockExecuteJobInline: vi.fn().mockResolvedValue(undefined),
|
||||
mockFeatureFlags: {
|
||||
isTriggerDevEnabled: false,
|
||||
isHosted: false,
|
||||
isProd: false,
|
||||
isDev: true,
|
||||
},
|
||||
mockDbReturning,
|
||||
mockDbUpdate,
|
||||
mockEnqueue,
|
||||
@@ -45,13 +49,6 @@ const {
|
||||
}
|
||||
})
|
||||
|
||||
const mockFeatureFlags = createFeatureFlagsMock({
|
||||
isTriggerDevEnabled: false,
|
||||
isHosted: false,
|
||||
isProd: false,
|
||||
isDev: true,
|
||||
})
|
||||
|
||||
vi.mock('@/lib/auth/internal', () => ({
|
||||
verifyCronAuth: mockVerifyCronAuth,
|
||||
}))
|
||||
@@ -94,7 +91,17 @@ vi.mock('@/lib/workflows/utils', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('drizzle-orm', () => drizzleOrmMock)
|
||||
vi.mock('drizzle-orm', () => ({
|
||||
and: vi.fn((...conditions: unknown[]) => ({ type: 'and', conditions })),
|
||||
eq: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'eq' })),
|
||||
ne: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'ne' })),
|
||||
lte: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'lte' })),
|
||||
lt: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'lt' })),
|
||||
not: vi.fn((condition: unknown) => ({ type: 'not', condition })),
|
||||
isNull: vi.fn((field: unknown) => ({ type: 'isNull', field })),
|
||||
or: vi.fn((...conditions: unknown[]) => ({ type: 'or', conditions })),
|
||||
sql: vi.fn((strings: unknown, ...values: unknown[]) => ({ type: 'sql', strings, values })),
|
||||
}))
|
||||
|
||||
vi.mock('@sim/db', () => ({
|
||||
db: {
|
||||
@@ -170,13 +177,18 @@ const SINGLE_JOB = [
|
||||
},
|
||||
]
|
||||
|
||||
function createCronRequest() {
|
||||
return createMockRequest(
|
||||
'GET',
|
||||
undefined,
|
||||
{ Authorization: 'Bearer test-cron-secret' },
|
||||
'http://localhost:3000/api/schedules/execute'
|
||||
)
|
||||
function createMockRequest(): NextRequest {
|
||||
const mockHeaders = new Map([
|
||||
['authorization', 'Bearer test-cron-secret'],
|
||||
['content-type', 'application/json'],
|
||||
])
|
||||
|
||||
return {
|
||||
headers: {
|
||||
get: (key: string) => mockHeaders.get(key.toLowerCase()) || null,
|
||||
},
|
||||
url: 'http://localhost:3000/api/schedules/execute',
|
||||
} as NextRequest
|
||||
}
|
||||
|
||||
describe('Scheduled Workflow Execution API Route', () => {
|
||||
@@ -192,7 +204,7 @@ describe('Scheduled Workflow Execution API Route', () => {
|
||||
it('should execute scheduled workflows with Trigger.dev disabled', async () => {
|
||||
mockDbReturning.mockReturnValueOnce(SINGLE_SCHEDULE).mockReturnValueOnce([])
|
||||
|
||||
const response = await GET(createCronRequest() as unknown as NextRequest)
|
||||
const response = await GET(createMockRequest())
|
||||
|
||||
expect(response).toBeDefined()
|
||||
expect(response.status).toBe(200)
|
||||
@@ -205,7 +217,7 @@ describe('Scheduled Workflow Execution API Route', () => {
|
||||
mockFeatureFlags.isTriggerDevEnabled = true
|
||||
mockDbReturning.mockReturnValueOnce(SINGLE_SCHEDULE).mockReturnValueOnce([])
|
||||
|
||||
const response = await GET(createCronRequest() as unknown as NextRequest)
|
||||
const response = await GET(createMockRequest())
|
||||
|
||||
expect(response).toBeDefined()
|
||||
expect(response.status).toBe(200)
|
||||
@@ -216,7 +228,7 @@ describe('Scheduled Workflow Execution API Route', () => {
|
||||
it('should handle case with no due schedules', async () => {
|
||||
mockDbReturning.mockReturnValueOnce([]).mockReturnValueOnce([])
|
||||
|
||||
const response = await GET(createCronRequest() as unknown as NextRequest)
|
||||
const response = await GET(createMockRequest())
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const data = await response.json()
|
||||
@@ -227,7 +239,7 @@ describe('Scheduled Workflow Execution API Route', () => {
|
||||
it('should execute multiple schedules in parallel', async () => {
|
||||
mockDbReturning.mockReturnValueOnce(MULTIPLE_SCHEDULES).mockReturnValueOnce([])
|
||||
|
||||
const response = await GET(createCronRequest() as unknown as NextRequest)
|
||||
const response = await GET(createMockRequest())
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const data = await response.json()
|
||||
@@ -237,7 +249,7 @@ describe('Scheduled Workflow Execution API Route', () => {
|
||||
it('should queue mothership jobs to BullMQ when available', async () => {
|
||||
mockDbReturning.mockReturnValueOnce([]).mockReturnValueOnce(SINGLE_JOB)
|
||||
|
||||
const response = await GET(createCronRequest() as unknown as NextRequest)
|
||||
const response = await GET(createMockRequest())
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(mockEnqueueWorkspaceDispatch).toHaveBeenCalledWith(
|
||||
@@ -262,7 +274,7 @@ describe('Scheduled Workflow Execution API Route', () => {
|
||||
it('should enqueue preassigned correlation metadata for schedules', async () => {
|
||||
mockDbReturning.mockReturnValue(SINGLE_SCHEDULE)
|
||||
|
||||
const response = await GET(createCronRequest() as unknown as NextRequest)
|
||||
const response = await GET(createMockRequest())
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(mockEnqueueWorkspaceDispatch).toHaveBeenCalledWith(
|
||||
|
||||
@@ -5,7 +5,6 @@ import { and, eq, isNull, or } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import { validateCronExpression } from '@/lib/workflows/schedules/utils'
|
||||
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
|
||||
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
|
||||
@@ -278,13 +277,6 @@ export async function POST(req: NextRequest) {
|
||||
lifecycle,
|
||||
})
|
||||
|
||||
captureServerEvent(
|
||||
session.user.id,
|
||||
'scheduled_task_created',
|
||||
{ workspace_id: workspaceId },
|
||||
{ groups: { workspace: workspaceId } }
|
||||
)
|
||||
|
||||
return NextResponse.json(
|
||||
{ schedule: { id, status: 'active', cronExpression, nextRunAt } },
|
||||
{ status: 201 }
|
||||
|
||||
@@ -4,7 +4,6 @@ import { z } from 'zod'
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import { deleteSkill, listSkills, upsertSkills } from '@/lib/workflows/skills/operations'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
@@ -24,7 +23,6 @@ const SkillSchema = z.object({
|
||||
})
|
||||
),
|
||||
workspaceId: z.string().optional(),
|
||||
source: z.enum(['settings', 'tool_input']).optional(),
|
||||
})
|
||||
|
||||
/** GET - Fetch all skills for a workspace */
|
||||
@@ -77,7 +75,7 @@ export async function POST(req: NextRequest) {
|
||||
const body = await req.json()
|
||||
|
||||
try {
|
||||
const { skills, workspaceId, source } = SkillSchema.parse(body)
|
||||
const { skills, workspaceId } = SkillSchema.parse(body)
|
||||
|
||||
if (!workspaceId) {
|
||||
logger.warn(`[${requestId}] Missing workspaceId in request body`)
|
||||
@@ -109,12 +107,6 @@ export async function POST(req: NextRequest) {
|
||||
resourceName: skill.name,
|
||||
description: `Created/updated skill "${skill.name}"`,
|
||||
})
|
||||
captureServerEvent(
|
||||
userId,
|
||||
'skill_created',
|
||||
{ skill_id: skill.id, skill_name: skill.name, workspace_id: workspaceId, source },
|
||||
{ groups: { workspace: workspaceId } }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, data: resultSkills })
|
||||
@@ -145,9 +137,6 @@ export async function DELETE(request: NextRequest) {
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const skillId = searchParams.get('id')
|
||||
const workspaceId = searchParams.get('workspaceId')
|
||||
const sourceParam = searchParams.get('source')
|
||||
const source =
|
||||
sourceParam === 'settings' || sourceParam === 'tool_input' ? sourceParam : undefined
|
||||
|
||||
try {
|
||||
const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
|
||||
@@ -191,13 +180,6 @@ export async function DELETE(request: NextRequest) {
|
||||
description: `Deleted skill`,
|
||||
})
|
||||
|
||||
captureServerEvent(
|
||||
userId,
|
||||
'skill_deleted',
|
||||
{ skill_id: skillId, workspace_id: workspaceId, source },
|
||||
{ groups: { workspace: workspaceId } }
|
||||
)
|
||||
|
||||
logger.info(`[${requestId}] Deleted skill: ${skillId}`)
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
|
||||
@@ -3,7 +3,6 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import {
|
||||
deleteTable,
|
||||
NAME_PATTERN,
|
||||
@@ -184,13 +183,6 @@ export async function DELETE(request: NextRequest, { params }: TableRouteParams)
|
||||
|
||||
await deleteTable(tableId, requestId)
|
||||
|
||||
captureServerEvent(
|
||||
authResult.userId,
|
||||
'table_deleted',
|
||||
{ table_id: tableId, workspace_id: table.workspaceId },
|
||||
{ groups: { workspace: table.workspaceId } }
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
|
||||
@@ -3,7 +3,6 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import {
|
||||
createTable,
|
||||
getWorkspaceTableLimits,
|
||||
@@ -142,20 +141,6 @@ export async function POST(request: NextRequest) {
|
||||
requestId
|
||||
)
|
||||
|
||||
captureServerEvent(
|
||||
authResult.userId,
|
||||
'table_created',
|
||||
{
|
||||
table_id: table.id,
|
||||
workspace_id: params.workspaceId,
|
||||
column_count: params.schema.columns.length,
|
||||
},
|
||||
{
|
||||
groups: { workspace: params.workspaceId },
|
||||
setOnce: { first_table_created_at: new Date().toISOString() },
|
||||
}
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
import {
|
||||
type AlarmType,
|
||||
CloudWatchClient,
|
||||
DescribeAlarmsCommand,
|
||||
type StateValue,
|
||||
} from '@aws-sdk/client-cloudwatch'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
|
||||
const logger = createLogger('CloudWatchDescribeAlarms')
|
||||
|
||||
const DescribeAlarmsSchema = 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'),
|
||||
alarmNamePrefix: z.string().optional(),
|
||||
stateValue: z.preprocess(
|
||||
(v) => (v === '' ? undefined : v),
|
||||
z.enum(['OK', 'ALARM', 'INSUFFICIENT_DATA']).optional()
|
||||
),
|
||||
alarmType: z.preprocess(
|
||||
(v) => (v === '' ? undefined : v),
|
||||
z.enum(['MetricAlarm', 'CompositeAlarm']).optional()
|
||||
),
|
||||
limit: z.preprocess(
|
||||
(v) => (v === '' || v === undefined || v === null ? undefined : v),
|
||||
z.number({ coerce: true }).int().positive().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 validatedData = DescribeAlarmsSchema.parse(body)
|
||||
|
||||
const client = new CloudWatchClient({
|
||||
region: validatedData.region,
|
||||
credentials: {
|
||||
accessKeyId: validatedData.accessKeyId,
|
||||
secretAccessKey: validatedData.secretAccessKey,
|
||||
},
|
||||
})
|
||||
|
||||
const command = new DescribeAlarmsCommand({
|
||||
...(validatedData.alarmNamePrefix && { AlarmNamePrefix: validatedData.alarmNamePrefix }),
|
||||
...(validatedData.stateValue && { StateValue: validatedData.stateValue as StateValue }),
|
||||
...(validatedData.alarmType && { AlarmTypes: [validatedData.alarmType as AlarmType] }),
|
||||
...(validatedData.limit !== undefined && { MaxRecords: validatedData.limit }),
|
||||
})
|
||||
|
||||
const response = await client.send(command)
|
||||
|
||||
const metricAlarms = (response.MetricAlarms ?? []).map((a) => ({
|
||||
alarmName: a.AlarmName ?? '',
|
||||
alarmArn: a.AlarmArn ?? '',
|
||||
stateValue: a.StateValue ?? 'UNKNOWN',
|
||||
stateReason: a.StateReason ?? '',
|
||||
metricName: a.MetricName,
|
||||
namespace: a.Namespace,
|
||||
comparisonOperator: a.ComparisonOperator,
|
||||
threshold: a.Threshold,
|
||||
evaluationPeriods: a.EvaluationPeriods,
|
||||
stateUpdatedTimestamp: a.StateUpdatedTimestamp?.getTime(),
|
||||
}))
|
||||
|
||||
const compositeAlarms = (response.CompositeAlarms ?? []).map((a) => ({
|
||||
alarmName: a.AlarmName ?? '',
|
||||
alarmArn: a.AlarmArn ?? '',
|
||||
stateValue: a.StateValue ?? 'UNKNOWN',
|
||||
stateReason: a.StateReason ?? '',
|
||||
metricName: undefined,
|
||||
namespace: undefined,
|
||||
comparisonOperator: undefined,
|
||||
threshold: undefined,
|
||||
evaluationPeriods: undefined,
|
||||
stateUpdatedTimestamp: a.StateUpdatedTimestamp?.getTime(),
|
||||
}))
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: { alarms: [...metricAlarms, ...compositeAlarms] },
|
||||
})
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Failed to describe CloudWatch alarms'
|
||||
logger.error('DescribeAlarms failed', { error: errorMessage })
|
||||
return NextResponse.json({ error: errorMessage }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import { DescribeLogGroupsCommand } from '@aws-sdk/client-cloudwatch-logs'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { createCloudWatchLogsClient } from '@/app/api/tools/cloudwatch/utils'
|
||||
|
||||
const logger = createLogger('CloudWatchDescribeLogGroups')
|
||||
|
||||
const DescribeLogGroupsSchema = 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'),
|
||||
prefix: z.string().optional(),
|
||||
limit: z.preprocess(
|
||||
(v) => (v === '' || v === undefined || v === null ? undefined : v),
|
||||
z.number({ coerce: true }).int().positive().optional()
|
||||
),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const auth = await checkSessionOrInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const validatedData = DescribeLogGroupsSchema.parse(body)
|
||||
|
||||
const client = createCloudWatchLogsClient({
|
||||
region: validatedData.region,
|
||||
accessKeyId: validatedData.accessKeyId,
|
||||
secretAccessKey: validatedData.secretAccessKey,
|
||||
})
|
||||
|
||||
const command = new DescribeLogGroupsCommand({
|
||||
...(validatedData.prefix && { logGroupNamePrefix: validatedData.prefix }),
|
||||
...(validatedData.limit !== undefined && { limit: validatedData.limit }),
|
||||
})
|
||||
|
||||
const response = await client.send(command)
|
||||
|
||||
const logGroups = (response.logGroups ?? []).map((lg) => ({
|
||||
logGroupName: lg.logGroupName ?? '',
|
||||
arn: lg.arn ?? '',
|
||||
storedBytes: lg.storedBytes ?? 0,
|
||||
retentionInDays: lg.retentionInDays,
|
||||
creationTime: lg.creationTime,
|
||||
}))
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: { logGroups },
|
||||
})
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Failed to describe CloudWatch log groups'
|
||||
logger.error('DescribeLogGroups failed', { error: errorMessage })
|
||||
return NextResponse.json({ error: errorMessage }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { createCloudWatchLogsClient, describeLogStreams } from '@/app/api/tools/cloudwatch/utils'
|
||||
|
||||
const logger = createLogger('CloudWatchDescribeLogStreams')
|
||||
|
||||
const DescribeLogStreamsSchema = 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'),
|
||||
logGroupName: z.string().min(1, 'Log group name is required'),
|
||||
prefix: z.string().optional(),
|
||||
limit: z.preprocess(
|
||||
(v) => (v === '' || v === undefined || v === null ? undefined : v),
|
||||
z.number({ coerce: true }).int().positive().optional()
|
||||
),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const auth = await checkSessionOrInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const validatedData = DescribeLogStreamsSchema.parse(body)
|
||||
|
||||
const client = createCloudWatchLogsClient({
|
||||
region: validatedData.region,
|
||||
accessKeyId: validatedData.accessKeyId,
|
||||
secretAccessKey: validatedData.secretAccessKey,
|
||||
})
|
||||
|
||||
const result = await describeLogStreams(client, validatedData.logGroupName, {
|
||||
prefix: validatedData.prefix,
|
||||
limit: validatedData.limit,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: { logStreams: result.logStreams },
|
||||
})
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Failed to describe CloudWatch log streams'
|
||||
logger.error('DescribeLogStreams failed', { error: errorMessage })
|
||||
return NextResponse.json({ error: errorMessage }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { createCloudWatchLogsClient, getLogEvents } from '@/app/api/tools/cloudwatch/utils'
|
||||
|
||||
const logger = createLogger('CloudWatchGetLogEvents')
|
||||
|
||||
const GetLogEventsSchema = 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'),
|
||||
logGroupName: z.string().min(1, 'Log group name is required'),
|
||||
logStreamName: z.string().min(1, 'Log stream name is required'),
|
||||
startTime: z.number({ coerce: true }).int().optional(),
|
||||
endTime: z.number({ coerce: true }).int().optional(),
|
||||
limit: z.preprocess(
|
||||
(v) => (v === '' || v === undefined || v === null ? undefined : v),
|
||||
z.number({ coerce: true }).int().positive().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 validatedData = GetLogEventsSchema.parse(body)
|
||||
|
||||
const client = createCloudWatchLogsClient({
|
||||
region: validatedData.region,
|
||||
accessKeyId: validatedData.accessKeyId,
|
||||
secretAccessKey: validatedData.secretAccessKey,
|
||||
})
|
||||
|
||||
const result = await getLogEvents(
|
||||
client,
|
||||
validatedData.logGroupName,
|
||||
validatedData.logStreamName,
|
||||
{
|
||||
startTime: validatedData.startTime,
|
||||
endTime: validatedData.endTime,
|
||||
limit: validatedData.limit,
|
||||
}
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: { events: result.events },
|
||||
})
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Failed to get CloudWatch log events'
|
||||
logger.error('GetLogEvents failed', { error: errorMessage })
|
||||
return NextResponse.json({ error: errorMessage }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
import { CloudWatchClient, GetMetricStatisticsCommand } from '@aws-sdk/client-cloudwatch'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
|
||||
const logger = createLogger('CloudWatchGetMetricStatistics')
|
||||
|
||||
const GetMetricStatisticsSchema = 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'),
|
||||
namespace: z.string().min(1, 'Namespace is required'),
|
||||
metricName: z.string().min(1, 'Metric name is required'),
|
||||
startTime: z.number({ coerce: true }).int(),
|
||||
endTime: z.number({ coerce: true }).int(),
|
||||
period: z.number({ coerce: true }).int().min(1),
|
||||
statistics: z.array(z.enum(['Average', 'Sum', 'Minimum', 'Maximum', 'SampleCount'])).min(1),
|
||||
dimensions: 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 validatedData = GetMetricStatisticsSchema.parse(body)
|
||||
|
||||
const client = new CloudWatchClient({
|
||||
region: validatedData.region,
|
||||
credentials: {
|
||||
accessKeyId: validatedData.accessKeyId,
|
||||
secretAccessKey: validatedData.secretAccessKey,
|
||||
},
|
||||
})
|
||||
|
||||
let parsedDimensions: { Name: string; Value: string }[] | undefined
|
||||
if (validatedData.dimensions) {
|
||||
try {
|
||||
const dims = JSON.parse(validatedData.dimensions)
|
||||
if (Array.isArray(dims)) {
|
||||
parsedDimensions = dims.map((d: Record<string, string>) => ({
|
||||
Name: d.name,
|
||||
Value: d.value,
|
||||
}))
|
||||
} else if (typeof dims === 'object') {
|
||||
parsedDimensions = Object.entries(dims).map(([name, value]) => ({
|
||||
Name: name,
|
||||
Value: String(value),
|
||||
}))
|
||||
}
|
||||
} catch {
|
||||
throw new Error('Invalid dimensions JSON')
|
||||
}
|
||||
}
|
||||
|
||||
const command = new GetMetricStatisticsCommand({
|
||||
Namespace: validatedData.namespace,
|
||||
MetricName: validatedData.metricName,
|
||||
StartTime: new Date(validatedData.startTime * 1000),
|
||||
EndTime: new Date(validatedData.endTime * 1000),
|
||||
Period: validatedData.period,
|
||||
Statistics: validatedData.statistics,
|
||||
...(parsedDimensions && { Dimensions: parsedDimensions }),
|
||||
})
|
||||
|
||||
const response = await client.send(command)
|
||||
|
||||
const datapoints = (response.Datapoints ?? [])
|
||||
.sort((a, b) => (a.Timestamp?.getTime() ?? 0) - (b.Timestamp?.getTime() ?? 0))
|
||||
.map((dp) => ({
|
||||
timestamp: dp.Timestamp ? Math.floor(dp.Timestamp.getTime() / 1000) : 0,
|
||||
average: dp.Average,
|
||||
sum: dp.Sum,
|
||||
minimum: dp.Minimum,
|
||||
maximum: dp.Maximum,
|
||||
sampleCount: dp.SampleCount,
|
||||
unit: dp.Unit,
|
||||
}))
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: {
|
||||
label: response.Label ?? validatedData.metricName,
|
||||
datapoints,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Failed to get CloudWatch metric statistics'
|
||||
logger.error('GetMetricStatistics failed', { error: errorMessage })
|
||||
return NextResponse.json({ error: errorMessage }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import { CloudWatchClient, ListMetricsCommand } from '@aws-sdk/client-cloudwatch'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
|
||||
const logger = createLogger('CloudWatchListMetrics')
|
||||
|
||||
const ListMetricsSchema = 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'),
|
||||
namespace: z.string().optional(),
|
||||
metricName: z.string().optional(),
|
||||
recentlyActive: z.boolean().optional(),
|
||||
limit: z.preprocess(
|
||||
(v) => (v === '' || v === undefined || v === null ? undefined : v),
|
||||
z.number({ coerce: true }).int().positive().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 validatedData = ListMetricsSchema.parse(body)
|
||||
|
||||
const client = new CloudWatchClient({
|
||||
region: validatedData.region,
|
||||
credentials: {
|
||||
accessKeyId: validatedData.accessKeyId,
|
||||
secretAccessKey: validatedData.secretAccessKey,
|
||||
},
|
||||
})
|
||||
|
||||
const command = new ListMetricsCommand({
|
||||
...(validatedData.namespace && { Namespace: validatedData.namespace }),
|
||||
...(validatedData.metricName && { MetricName: validatedData.metricName }),
|
||||
...(validatedData.recentlyActive && { RecentlyActive: 'PT3H' }),
|
||||
})
|
||||
|
||||
const response = await client.send(command)
|
||||
|
||||
const metrics = (response.Metrics ?? []).slice(0, validatedData.limit ?? 500).map((m) => ({
|
||||
namespace: m.Namespace ?? '',
|
||||
metricName: m.MetricName ?? '',
|
||||
dimensions: (m.Dimensions ?? []).map((d) => ({
|
||||
name: d.Name ?? '',
|
||||
value: d.Value ?? '',
|
||||
})),
|
||||
}))
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: { metrics },
|
||||
})
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Failed to list CloudWatch metrics'
|
||||
logger.error('ListMetrics failed', { error: errorMessage })
|
||||
return NextResponse.json({ error: errorMessage }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import { StartQueryCommand } from '@aws-sdk/client-cloudwatch-logs'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { createCloudWatchLogsClient, pollQueryResults } from '@/app/api/tools/cloudwatch/utils'
|
||||
|
||||
const logger = createLogger('CloudWatchQueryLogs')
|
||||
|
||||
const QueryLogsSchema = 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'),
|
||||
logGroupNames: z.array(z.string().min(1)).min(1, 'At least one log group name is required'),
|
||||
queryString: z.string().min(1, 'Query string is required'),
|
||||
startTime: z.number({ coerce: true }).int(),
|
||||
endTime: z.number({ coerce: true }).int(),
|
||||
limit: z.preprocess(
|
||||
(v) => (v === '' || v === undefined || v === null ? undefined : v),
|
||||
z.number({ coerce: true }).int().positive().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 validatedData = QueryLogsSchema.parse(body)
|
||||
|
||||
const client = createCloudWatchLogsClient({
|
||||
region: validatedData.region,
|
||||
accessKeyId: validatedData.accessKeyId,
|
||||
secretAccessKey: validatedData.secretAccessKey,
|
||||
})
|
||||
|
||||
const startQueryCommand = new StartQueryCommand({
|
||||
logGroupNames: validatedData.logGroupNames,
|
||||
queryString: validatedData.queryString,
|
||||
startTime: validatedData.startTime,
|
||||
endTime: validatedData.endTime,
|
||||
...(validatedData.limit !== undefined && { limit: validatedData.limit }),
|
||||
})
|
||||
|
||||
const startQueryResponse = await client.send(startQueryCommand)
|
||||
const queryId = startQueryResponse.queryId
|
||||
|
||||
if (!queryId) {
|
||||
throw new Error('Failed to start CloudWatch Log Insights query: no queryId returned')
|
||||
}
|
||||
|
||||
const result = await pollQueryResults(client, queryId)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: {
|
||||
results: result.results,
|
||||
statistics: result.statistics,
|
||||
status: result.status,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'CloudWatch Log Insights query failed'
|
||||
logger.error('QueryLogs failed', { error: errorMessage })
|
||||
return NextResponse.json({ error: errorMessage }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
import {
|
||||
CloudWatchLogsClient,
|
||||
DescribeLogStreamsCommand,
|
||||
GetLogEventsCommand,
|
||||
GetQueryResultsCommand,
|
||||
type ResultField,
|
||||
} from '@aws-sdk/client-cloudwatch-logs'
|
||||
import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/core/execution-limits'
|
||||
|
||||
interface AwsCredentials {
|
||||
region: string
|
||||
accessKeyId: string
|
||||
secretAccessKey: string
|
||||
}
|
||||
|
||||
export function createCloudWatchLogsClient(config: AwsCredentials): CloudWatchLogsClient {
|
||||
return new CloudWatchLogsClient({
|
||||
region: config.region,
|
||||
credentials: {
|
||||
accessKeyId: config.accessKeyId,
|
||||
secretAccessKey: config.secretAccessKey,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
interface PollOptions {
|
||||
maxWaitMs?: number
|
||||
pollIntervalMs?: number
|
||||
}
|
||||
|
||||
interface PollResult {
|
||||
results: Record<string, string>[]
|
||||
statistics: {
|
||||
bytesScanned: number
|
||||
recordsMatched: number
|
||||
recordsScanned: number
|
||||
}
|
||||
status: string
|
||||
}
|
||||
|
||||
function parseResultFields(fields: ResultField[] | undefined): Record<string, string> {
|
||||
const record: Record<string, string> = {}
|
||||
if (!fields) return record
|
||||
for (const field of fields) {
|
||||
if (field.field && field.value !== undefined) {
|
||||
record[field.field] = field.value ?? ''
|
||||
}
|
||||
}
|
||||
return record
|
||||
}
|
||||
|
||||
export async function pollQueryResults(
|
||||
client: CloudWatchLogsClient,
|
||||
queryId: string,
|
||||
options: PollOptions = {}
|
||||
): Promise<PollResult> {
|
||||
const { maxWaitMs = DEFAULT_EXECUTION_TIMEOUT_MS, pollIntervalMs = 1_000 } = options
|
||||
const startTime = Date.now()
|
||||
|
||||
while (Date.now() - startTime < maxWaitMs) {
|
||||
const command = new GetQueryResultsCommand({ queryId })
|
||||
const response = await client.send(command)
|
||||
|
||||
const status = response.status ?? 'Unknown'
|
||||
|
||||
if (status === 'Complete') {
|
||||
return {
|
||||
results: (response.results ?? []).map(parseResultFields),
|
||||
statistics: {
|
||||
bytesScanned: response.statistics?.bytesScanned ?? 0,
|
||||
recordsMatched: response.statistics?.recordsMatched ?? 0,
|
||||
recordsScanned: response.statistics?.recordsScanned ?? 0,
|
||||
},
|
||||
status,
|
||||
}
|
||||
}
|
||||
|
||||
if (status === 'Failed' || status === 'Cancelled') {
|
||||
throw new Error(`CloudWatch Log Insights query ${status.toLowerCase()}`)
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs))
|
||||
}
|
||||
|
||||
// Timeout -- fetch one last time for partial results
|
||||
const finalResponse = await client.send(new GetQueryResultsCommand({ queryId }))
|
||||
return {
|
||||
results: (finalResponse.results ?? []).map(parseResultFields),
|
||||
statistics: {
|
||||
bytesScanned: finalResponse.statistics?.bytesScanned ?? 0,
|
||||
recordsMatched: finalResponse.statistics?.recordsMatched ?? 0,
|
||||
recordsScanned: finalResponse.statistics?.recordsScanned ?? 0,
|
||||
},
|
||||
status: `Timeout (last status: ${finalResponse.status ?? 'Unknown'})`,
|
||||
}
|
||||
}
|
||||
|
||||
export async function describeLogStreams(
|
||||
client: CloudWatchLogsClient,
|
||||
logGroupName: string,
|
||||
options?: { prefix?: string; limit?: number }
|
||||
): Promise<{
|
||||
logStreams: {
|
||||
logStreamName: string
|
||||
lastEventTimestamp: number | undefined
|
||||
firstEventTimestamp: number | undefined
|
||||
creationTime: number | undefined
|
||||
storedBytes: number
|
||||
}[]
|
||||
}> {
|
||||
const hasPrefix = Boolean(options?.prefix)
|
||||
const command = new DescribeLogStreamsCommand({
|
||||
logGroupName,
|
||||
...(hasPrefix
|
||||
? { orderBy: 'LogStreamName', logStreamNamePrefix: options!.prefix }
|
||||
: { orderBy: 'LastEventTime', descending: true }),
|
||||
...(options?.limit !== undefined && { limit: options.limit }),
|
||||
})
|
||||
|
||||
const response = await client.send(command)
|
||||
return {
|
||||
logStreams: (response.logStreams ?? []).map((ls) => ({
|
||||
logStreamName: ls.logStreamName ?? '',
|
||||
lastEventTimestamp: ls.lastEventTimestamp,
|
||||
firstEventTimestamp: ls.firstEventTimestamp,
|
||||
creationTime: ls.creationTime,
|
||||
storedBytes: ls.storedBytes ?? 0,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export async function getLogEvents(
|
||||
client: CloudWatchLogsClient,
|
||||
logGroupName: string,
|
||||
logStreamName: string,
|
||||
options?: { startTime?: number; endTime?: number; limit?: number }
|
||||
): Promise<{
|
||||
events: {
|
||||
timestamp: number | undefined
|
||||
message: string | undefined
|
||||
ingestionTime: number | undefined
|
||||
}[]
|
||||
}> {
|
||||
const command = new GetLogEventsCommand({
|
||||
logGroupIdentifier: logGroupName,
|
||||
logStreamName,
|
||||
...(options?.startTime !== undefined && { startTime: options.startTime * 1000 }),
|
||||
...(options?.endTime !== undefined && { endTime: options.endTime * 1000 }),
|
||||
...(options?.limit !== undefined && { limit: options.limit }),
|
||||
startFromHead: true,
|
||||
})
|
||||
|
||||
const response = await client.send(command)
|
||||
return {
|
||||
events: (response.events ?? []).map((e) => ({
|
||||
timestamp: e.timestamp,
|
||||
message: e.message,
|
||||
ingestionTime: e.ingestionTime,
|
||||
})),
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import { z } from 'zod'
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import { upsertCustomTools } from '@/lib/workflows/custom-tools/operations'
|
||||
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
@@ -35,7 +34,6 @@ const CustomToolSchema = z.object({
|
||||
})
|
||||
),
|
||||
workspaceId: z.string().optional(),
|
||||
source: z.enum(['settings', 'tool_input']).optional(),
|
||||
})
|
||||
|
||||
// GET - Fetch all custom tools for the workspace
|
||||
@@ -137,7 +135,7 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
try {
|
||||
// Validate the request body
|
||||
const { tools, workspaceId, source } = CustomToolSchema.parse(body)
|
||||
const { tools, workspaceId } = CustomToolSchema.parse(body)
|
||||
|
||||
if (!workspaceId) {
|
||||
logger.warn(`[${requestId}] Missing workspaceId in request body`)
|
||||
@@ -170,16 +168,6 @@ export async function POST(req: NextRequest) {
|
||||
})
|
||||
|
||||
for (const tool of resultTools) {
|
||||
captureServerEvent(
|
||||
userId,
|
||||
'custom_tool_saved',
|
||||
{ tool_id: tool.id, workspace_id: workspaceId, tool_name: tool.title, source },
|
||||
{
|
||||
groups: { workspace: workspaceId },
|
||||
setOnce: { first_custom_tool_saved_at: new Date().toISOString() },
|
||||
}
|
||||
)
|
||||
|
||||
recordAudit({
|
||||
workspaceId,
|
||||
actorId: userId,
|
||||
@@ -217,9 +205,6 @@ export async function DELETE(request: NextRequest) {
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const toolId = searchParams.get('id')
|
||||
const workspaceId = searchParams.get('workspaceId')
|
||||
const sourceParam = searchParams.get('source')
|
||||
const source =
|
||||
sourceParam === 'settings' || sourceParam === 'tool_input' ? sourceParam : undefined
|
||||
|
||||
if (!toolId) {
|
||||
logger.warn(`[${requestId}] Missing tool ID for deletion`)
|
||||
@@ -293,14 +278,6 @@ export async function DELETE(request: NextRequest) {
|
||||
// Delete the tool
|
||||
await db.delete(customTools).where(eq(customTools.id, toolId))
|
||||
|
||||
const toolWorkspaceId = tool.workspaceId ?? workspaceId ?? ''
|
||||
captureServerEvent(
|
||||
userId,
|
||||
'custom_tool_deleted',
|
||||
{ tool_id: toolId, workspace_id: toolWorkspaceId, source },
|
||||
toolWorkspaceId ? { groups: { workspace: toolWorkspaceId } } : undefined
|
||||
)
|
||||
|
||||
recordAudit({
|
||||
workspaceId: tool.workspaceId || undefined,
|
||||
actorId: userId,
|
||||
|
||||
@@ -16,8 +16,7 @@ import { workflow, workflowFolder } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { sanitizePathSegment } from '@/lib/core/utils/file-download'
|
||||
import { exportFolderToZip } from '@/lib/workflows/operations/import-export'
|
||||
import { exportFolderToZip, sanitizePathSegment } from '@/lib/workflows/operations/import-export'
|
||||
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
|
||||
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
|
||||
import {
|
||||
|
||||
@@ -20,7 +20,7 @@ import { createLogger } from '@sim/logger'
|
||||
import { inArray } from 'drizzle-orm'
|
||||
import JSZip from 'jszip'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { sanitizePathSegment } from '@/lib/core/utils/file-download'
|
||||
import { sanitizePathSegment } from '@/lib/workflows/operations/import-export'
|
||||
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
|
||||
import { withAdminAuth } from '@/app/api/v1/admin/middleware'
|
||||
import {
|
||||
|
||||
@@ -16,8 +16,7 @@ import { workflow, workflowFolder, workspace } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { sanitizePathSegment } from '@/lib/core/utils/file-download'
|
||||
import { exportWorkspaceToZip } from '@/lib/workflows/operations/import-export'
|
||||
import { exportWorkspaceToZip, sanitizePathSegment } from '@/lib/workflows/operations/import-export'
|
||||
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
|
||||
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
|
||||
import {
|
||||
|
||||
@@ -8,7 +8,6 @@ import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { validateInteger } from '@/lib/core/security/input-validation'
|
||||
import { PlatformEvents } from '@/lib/core/telemetry'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import { cleanupExternalWebhook } from '@/lib/webhooks/provider-subscriptions'
|
||||
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
|
||||
|
||||
@@ -275,19 +274,6 @@ export async function DELETE(
|
||||
request,
|
||||
})
|
||||
|
||||
const wsId = webhookData.workflow.workspaceId || undefined
|
||||
captureServerEvent(
|
||||
userId,
|
||||
'webhook_trigger_deleted',
|
||||
{
|
||||
webhook_id: id,
|
||||
workflow_id: webhookData.workflow.id,
|
||||
provider: foundWebhook.provider || 'generic',
|
||||
workspace_id: wsId ?? '',
|
||||
},
|
||||
wsId ? { groups: { workspace: wsId } } : undefined
|
||||
)
|
||||
|
||||
return NextResponse.json({ success: true }, { status: 200 })
|
||||
} catch (error: any) {
|
||||
logger.error(`[${requestId}] Error deleting webhook`, {
|
||||
|
||||
@@ -9,7 +9,6 @@ import { getSession } from '@/lib/auth'
|
||||
import { PlatformEvents } from '@/lib/core/telemetry'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { getProviderIdFromServiceId } from '@/lib/oauth'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import { resolveEnvVarsInObject } from '@/lib/webhooks/env-resolver'
|
||||
import {
|
||||
cleanupExternalWebhook,
|
||||
@@ -764,19 +763,6 @@ export async function POST(request: NextRequest) {
|
||||
metadata: { provider, workflowId },
|
||||
request,
|
||||
})
|
||||
|
||||
const wsId = workflowRecord.workspaceId || undefined
|
||||
captureServerEvent(
|
||||
userId,
|
||||
'webhook_trigger_created',
|
||||
{
|
||||
webhook_id: savedWebhook.id,
|
||||
workflow_id: workflowId,
|
||||
provider: provider || 'generic',
|
||||
workspace_id: wsId ?? '',
|
||||
},
|
||||
wsId ? { groups: { workspace: wsId } } : undefined
|
||||
)
|
||||
}
|
||||
|
||||
const status = targetWebhookId ? 200 : 201
|
||||
|
||||
@@ -3,7 +3,6 @@ import { createLogger } from '@sim/logger'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import { performFullDeploy, performFullUndeploy } from '@/lib/workflows/orchestration'
|
||||
import { validateWorkflowPermissions } from '@/lib/workflows/utils'
|
||||
import {
|
||||
@@ -97,16 +96,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
|
||||
logger.info(`[${requestId}] Workflow deployed successfully: ${id}`)
|
||||
|
||||
captureServerEvent(
|
||||
actorUserId,
|
||||
'workflow_deployed',
|
||||
{ workflow_id: id, workspace_id: workflowData!.workspaceId ?? '' },
|
||||
{
|
||||
groups: workflowData!.workspaceId ? { workspace: workflowData!.workspaceId } : undefined,
|
||||
setOnce: { first_workflow_deployed_at: new Date().toISOString() },
|
||||
}
|
||||
)
|
||||
|
||||
const responseApiKeyInfo = workflowData!.workspaceId
|
||||
? 'Workspace API keys'
|
||||
: 'Personal API keys'
|
||||
@@ -129,11 +118,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
|
||||
const { id } = await params
|
||||
|
||||
try {
|
||||
const {
|
||||
error,
|
||||
session,
|
||||
workflow: workflowData,
|
||||
} = await validateWorkflowPermissions(id, requestId, 'admin')
|
||||
const { error, session } = await validateWorkflowPermissions(id, requestId, 'admin')
|
||||
if (error) {
|
||||
return createErrorResponse(error.message, error.status)
|
||||
}
|
||||
@@ -163,14 +148,6 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
|
||||
|
||||
logger.info(`[${requestId}] Updated isPublicApi for workflow ${id} to ${isPublicApi}`)
|
||||
|
||||
const wsId = workflowData?.workspaceId
|
||||
captureServerEvent(
|
||||
session!.user.id,
|
||||
'workflow_public_api_toggled',
|
||||
{ workflow_id: id, workspace_id: wsId ?? '', is_public: isPublicApi },
|
||||
wsId ? { groups: { workspace: wsId } } : undefined
|
||||
)
|
||||
|
||||
return createSuccessResponse({ isPublicApi })
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to update deployment settings'
|
||||
@@ -187,11 +164,7 @@ export async function DELETE(
|
||||
const { id } = await params
|
||||
|
||||
try {
|
||||
const {
|
||||
error,
|
||||
session,
|
||||
workflow: workflowData,
|
||||
} = await validateWorkflowPermissions(id, requestId, 'admin')
|
||||
const { error, session } = await validateWorkflowPermissions(id, requestId, 'admin')
|
||||
if (error) {
|
||||
return createErrorResponse(error.message, error.status)
|
||||
}
|
||||
@@ -206,14 +179,6 @@ export async function DELETE(
|
||||
return createErrorResponse(result.error || 'Failed to undeploy workflow', 500)
|
||||
}
|
||||
|
||||
const wsId = workflowData?.workspaceId
|
||||
captureServerEvent(
|
||||
session!.user.id,
|
||||
'workflow_undeployed',
|
||||
{ workflow_id: id, workspace_id: wsId ?? '' },
|
||||
wsId ? { groups: { workspace: wsId } } : undefined
|
||||
)
|
||||
|
||||
return createSuccessResponse({
|
||||
isDeployed: false,
|
||||
deployedAt: null,
|
||||
|
||||
@@ -5,7 +5,6 @@ import type { NextRequest } from 'next/server'
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils'
|
||||
import { validateWorkflowPermissions } from '@/lib/workflows/utils'
|
||||
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
|
||||
@@ -79,6 +78,7 @@ export async function POST(
|
||||
loops: deployedState.loops || {},
|
||||
parallels: deployedState.parallels || {},
|
||||
lastSaved: Date.now(),
|
||||
deploymentStatuses: deployedState.deploymentStatuses || {},
|
||||
})
|
||||
|
||||
if (!saveResult.success) {
|
||||
@@ -104,19 +104,6 @@ export async function POST(
|
||||
logger.error('Error sending workflow reverted event to socket server', e)
|
||||
}
|
||||
|
||||
captureServerEvent(
|
||||
session!.user.id,
|
||||
'workflow_deployment_reverted',
|
||||
{
|
||||
workflow_id: id,
|
||||
workspace_id: workflowRecord?.workspaceId ?? '',
|
||||
version,
|
||||
},
|
||||
workflowRecord?.workspaceId
|
||||
? { groups: { workspace: workflowRecord.workspaceId } }
|
||||
: undefined
|
||||
)
|
||||
|
||||
recordAudit({
|
||||
workspaceId: workflowRecord?.workspaceId ?? null,
|
||||
actorId: session!.user.id,
|
||||
|
||||
@@ -4,7 +4,6 @@ import { and, eq } from 'drizzle-orm'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import { performActivateVersion } from '@/lib/workflows/orchestration'
|
||||
import { validateWorkflowPermissions } from '@/lib/workflows/utils'
|
||||
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
|
||||
@@ -175,14 +174,6 @@ export async function PATCH(
|
||||
}
|
||||
}
|
||||
|
||||
const wsId = (workflowData as { workspaceId?: string } | null)?.workspaceId
|
||||
captureServerEvent(
|
||||
actorUserId,
|
||||
'deployment_version_activated',
|
||||
{ workflow_id: id, workspace_id: wsId ?? '', version: versionNum },
|
||||
wsId ? { groups: { workspace: wsId } } : undefined
|
||||
)
|
||||
|
||||
return createSuccessResponse({
|
||||
success: true,
|
||||
deployedAt: activateResult.deployedAt,
|
||||
|
||||
@@ -5,7 +5,6 @@ import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { PlatformEvents } from '@/lib/core/telemetry'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import { duplicateWorkflow } from '@/lib/workflows/persistence/duplicate'
|
||||
|
||||
const logger = createLogger('WorkflowDuplicateAPI')
|
||||
@@ -61,17 +60,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
// Telemetry should not fail the operation
|
||||
}
|
||||
|
||||
captureServerEvent(
|
||||
userId,
|
||||
'workflow_duplicated',
|
||||
{
|
||||
source_workflow_id: sourceWorkflowId,
|
||||
new_workflow_id: result.id,
|
||||
workspace_id: workspaceId ?? '',
|
||||
},
|
||||
workspaceId ? { groups: { workspace: workspaceId } } : undefined
|
||||
)
|
||||
|
||||
const elapsed = Date.now() - startTime
|
||||
logger.info(
|
||||
`[${requestId}] Successfully duplicated workflow ${sourceWorkflowId} to ${result.id} in ${elapsed}ms`
|
||||
|
||||
@@ -3,7 +3,6 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { markExecutionCancelled } from '@/lib/execution/cancellation'
|
||||
import { abortManualExecution } from '@/lib/execution/manual-cancellation'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
|
||||
|
||||
const logger = createLogger('CancelExecutionAPI')
|
||||
@@ -61,16 +60,6 @@ export async function POST(
|
||||
})
|
||||
}
|
||||
|
||||
if (cancellation.durablyRecorded || locallyAborted) {
|
||||
const workspaceId = workflowAuthorization.workflow?.workspaceId
|
||||
captureServerEvent(
|
||||
auth.userId,
|
||||
'workflow_execution_cancelled',
|
||||
{ workflow_id: workflowId, workspace_id: workspaceId ?? '' },
|
||||
workspaceId ? { groups: { workspace: workspaceId } } : undefined
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: cancellation.durablyRecorded || locallyAborted,
|
||||
executionId,
|
||||
|
||||
@@ -3,7 +3,6 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import { restoreWorkflow } from '@/lib/workflows/lifecycle'
|
||||
import { getWorkflowById } from '@/lib/workflows/utils'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
@@ -59,13 +58,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
request,
|
||||
})
|
||||
|
||||
captureServerEvent(
|
||||
auth.userId,
|
||||
'workflow_restored',
|
||||
{ workflow_id: workflowId, workspace_id: workflowData.workspaceId ?? '' },
|
||||
workflowData.workspaceId ? { groups: { workspace: workflowData.workspaceId } } : undefined
|
||||
)
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error restoring workflow ${workflowId}`, error)
|
||||
|
||||
@@ -6,7 +6,6 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { AuthType, checkHybridAuth, checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import { performDeleteWorkflow } from '@/lib/workflows/orchestration'
|
||||
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
|
||||
import { authorizeWorkflowByWorkspacePermission, getWorkflowById } from '@/lib/workflows/utils'
|
||||
@@ -89,6 +88,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
const finalWorkflowData = {
|
||||
...workflowData,
|
||||
state: {
|
||||
deploymentStatuses: {},
|
||||
blocks: normalizedData.blocks,
|
||||
edges: normalizedData.edges,
|
||||
loops: normalizedData.loops,
|
||||
@@ -114,6 +114,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
const emptyWorkflowData = {
|
||||
...workflowData,
|
||||
state: {
|
||||
deploymentStatuses: {},
|
||||
blocks: {},
|
||||
edges: [],
|
||||
loops: {},
|
||||
@@ -224,13 +225,6 @@ export async function DELETE(
|
||||
return NextResponse.json({ error: result.error }, { status })
|
||||
}
|
||||
|
||||
captureServerEvent(
|
||||
userId,
|
||||
'workflow_deleted',
|
||||
{ workflow_id: workflowId, workspace_id: workflowData.workspaceId ?? '' },
|
||||
workflowData.workspaceId ? { groups: { workspace: workflowData.workspaceId } } : undefined
|
||||
)
|
||||
|
||||
const elapsed = Date.now() - startTime
|
||||
logger.info(`[${requestId}] Successfully archived workflow ${workflowId} in ${elapsed}ms`)
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
|
||||
import type { Variable } from '@/stores/variables/types'
|
||||
import type { Variable } from '@/stores/panel/variables/types'
|
||||
|
||||
const logger = createLogger('WorkflowVariablesAPI')
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import { z } from 'zod'
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import { getNextWorkflowColor } from '@/lib/workflows/colors'
|
||||
import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults'
|
||||
import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils'
|
||||
@@ -275,16 +274,6 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
logger.info(`[${requestId}] Successfully created workflow ${workflowId} with default blocks`)
|
||||
|
||||
captureServerEvent(
|
||||
userId,
|
||||
'workflow_created',
|
||||
{ workflow_id: workflowId, workspace_id: workspaceId ?? '', name },
|
||||
{
|
||||
groups: workspaceId ? { workspace: workspaceId } : undefined,
|
||||
setOnce: { first_workflow_created_at: new Date().toISOString() },
|
||||
}
|
||||
)
|
||||
|
||||
recordAudit({
|
||||
workspaceId,
|
||||
actorId: userId,
|
||||
|
||||
@@ -7,7 +7,6 @@ import { z } from 'zod'
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
const logger = createLogger('WorkspaceApiKeyAPI')
|
||||
@@ -146,13 +145,6 @@ export async function DELETE(
|
||||
|
||||
const deletedKey = deletedRows[0]
|
||||
|
||||
captureServerEvent(
|
||||
userId,
|
||||
'api_key_revoked',
|
||||
{ workspace_id: workspaceId, key_name: deletedKey.name },
|
||||
{ groups: { workspace: workspaceId } }
|
||||
)
|
||||
|
||||
recordAudit({
|
||||
workspaceId,
|
||||
actorId: userId,
|
||||
|
||||
@@ -10,14 +10,12 @@ import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { PlatformEvents } from '@/lib/core/telemetry'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import { getUserEntityPermissions, getWorkspaceById } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
const logger = createLogger('WorkspaceApiKeysAPI')
|
||||
|
||||
const CreateKeySchema = z.object({
|
||||
name: z.string().trim().min(1, 'Name is required'),
|
||||
source: z.enum(['settings', 'deploy_modal']).optional(),
|
||||
})
|
||||
|
||||
const DeleteKeysSchema = z.object({
|
||||
@@ -103,7 +101,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { name, source } = CreateKeySchema.parse(body)
|
||||
const { name } = CreateKeySchema.parse(body)
|
||||
|
||||
const existingKey = await db
|
||||
.select()
|
||||
@@ -160,16 +158,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
// Telemetry should not fail the operation
|
||||
}
|
||||
|
||||
captureServerEvent(
|
||||
userId,
|
||||
'api_key_created',
|
||||
{ workspace_id: workspaceId, key_name: name, source },
|
||||
{
|
||||
groups: { workspace: workspaceId },
|
||||
setOnce: { first_api_key_created_at: new Date().toISOString() },
|
||||
}
|
||||
)
|
||||
|
||||
logger.info(`[${requestId}] Created workspace API key: ${name} in workspace ${workspaceId}`)
|
||||
|
||||
recordAudit({
|
||||
|
||||
@@ -9,7 +9,6 @@ import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import { getUserEntityPermissions, getWorkspaceById } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
const logger = createLogger('WorkspaceBYOKKeysAPI')
|
||||
@@ -202,16 +201,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
|
||||
logger.info(`[${requestId}] Created BYOK key for ${providerId} in workspace ${workspaceId}`)
|
||||
|
||||
captureServerEvent(
|
||||
userId,
|
||||
'byok_key_added',
|
||||
{ workspace_id: workspaceId, provider_id: providerId },
|
||||
{
|
||||
groups: { workspace: workspaceId },
|
||||
setOnce: { first_byok_key_added_at: new Date().toISOString() },
|
||||
}
|
||||
)
|
||||
|
||||
recordAudit({
|
||||
workspaceId,
|
||||
actorId: userId,
|
||||
@@ -283,13 +272,6 @@ export async function DELETE(
|
||||
|
||||
logger.info(`[${requestId}] Deleted BYOK key for ${providerId} from workspace ${workspaceId}`)
|
||||
|
||||
captureServerEvent(
|
||||
userId,
|
||||
'byok_key_removed',
|
||||
{ workspace_id: workspaceId, provider_id: providerId },
|
||||
{ groups: { workspace: workspaceId } }
|
||||
)
|
||||
|
||||
recordAudit({
|
||||
workspaceId,
|
||||
actorId: userId,
|
||||
|
||||
@@ -3,7 +3,6 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import {
|
||||
FileConflictError,
|
||||
listWorkspaceFiles,
|
||||
@@ -117,13 +116,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
|
||||
logger.info(`[${requestId}] Uploaded workspace file: ${fileName}`)
|
||||
|
||||
captureServerEvent(
|
||||
session.user.id,
|
||||
'file_uploaded',
|
||||
{ workspace_id: workspaceId, file_type: rawFile.type || 'application/octet-stream' },
|
||||
{ groups: { workspace: workspaceId } }
|
||||
)
|
||||
|
||||
recordAudit({
|
||||
workspaceId,
|
||||
actorId: session.user.id,
|
||||
|
||||
@@ -7,7 +7,6 @@ import { z } from 'zod'
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { encryptSecret } from '@/lib/core/security/encryption'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
import { MAX_EMAIL_RECIPIENTS, MAX_WORKFLOW_IDS } from '../constants'
|
||||
|
||||
@@ -343,17 +342,6 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||
request,
|
||||
})
|
||||
|
||||
captureServerEvent(
|
||||
session.user.id,
|
||||
'notification_channel_deleted',
|
||||
{
|
||||
notification_id: notificationId,
|
||||
notification_type: deletedSubscription.notificationType,
|
||||
workspace_id: workspaceId,
|
||||
},
|
||||
{ groups: { workspace: workspaceId } }
|
||||
)
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
logger.error('Error deleting notification', { error })
|
||||
|
||||
@@ -8,7 +8,6 @@ import { z } from 'zod'
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { encryptSecret } from '@/lib/core/security/encryption'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
import { MAX_EMAIL_RECIPIENTS, MAX_NOTIFICATIONS_PER_TYPE, MAX_WORKFLOW_IDS } from './constants'
|
||||
|
||||
@@ -257,17 +256,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
type: data.notificationType,
|
||||
})
|
||||
|
||||
captureServerEvent(
|
||||
session.user.id,
|
||||
'notification_channel_created',
|
||||
{
|
||||
workspace_id: workspaceId,
|
||||
notification_type: data.notificationType,
|
||||
alert_rule: data.alertConfig?.rule ?? null,
|
||||
},
|
||||
{ groups: { workspace: workspaceId } }
|
||||
)
|
||||
|
||||
recordAudit({
|
||||
workspaceId,
|
||||
actorId: session.user.id,
|
||||
|
||||
@@ -8,7 +8,6 @@ import { z } from 'zod'
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import {
|
||||
getUsersWithPermissions,
|
||||
hasWorkspaceAdminAccess,
|
||||
@@ -189,13 +188,6 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
|
||||
const updatedUsers = await getUsersWithPermissions(workspaceId)
|
||||
|
||||
for (const update of body.updates) {
|
||||
captureServerEvent(
|
||||
session.user.id,
|
||||
'workspace_member_role_changed',
|
||||
{ workspace_id: workspaceId, new_role: update.permissions },
|
||||
{ groups: { workspace: workspaceId } }
|
||||
)
|
||||
|
||||
recordAudit({
|
||||
workspaceId,
|
||||
actorId: session.user.id,
|
||||
|
||||
@@ -5,7 +5,6 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import { archiveWorkspace } from '@/lib/workspaces/lifecycle'
|
||||
|
||||
const logger = createLogger('WorkspaceByIdAPI')
|
||||
@@ -293,13 +292,6 @@ export async function DELETE(
|
||||
request,
|
||||
})
|
||||
|
||||
captureServerEvent(
|
||||
session.user.id,
|
||||
'workspace_deleted',
|
||||
{ workspace_id: workspaceId, workflow_count: workflowIds.length },
|
||||
{ groups: { workspace: workspaceId } }
|
||||
)
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
logger.error(`Error deleting workspace ${workspaceId}:`, error)
|
||||
|
||||
@@ -19,7 +19,6 @@ import { PlatformEvents } from '@/lib/core/telemetry'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { sendEmail } from '@/lib/messaging/email/mailer'
|
||||
import { getFromEmailAddress } from '@/lib/messaging/email/utils'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import { getWorkspaceById } from '@/lib/workspaces/permissions/utils'
|
||||
import {
|
||||
InvitationsNotAllowedError,
|
||||
@@ -215,16 +214,6 @@ export async function POST(req: NextRequest) {
|
||||
// Telemetry should not fail the operation
|
||||
}
|
||||
|
||||
captureServerEvent(
|
||||
session.user.id,
|
||||
'workspace_member_invited',
|
||||
{ workspace_id: workspaceId, invitee_role: permission },
|
||||
{
|
||||
groups: { workspace: workspaceId },
|
||||
setOnce: { first_invitation_sent_at: new Date().toISOString() },
|
||||
}
|
||||
)
|
||||
|
||||
await sendInvitationEmail({
|
||||
to: email,
|
||||
inviterName: session.user.name || session.user.email || 'A user',
|
||||
|
||||
@@ -7,7 +7,6 @@ import { z } from 'zod'
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { revokeWorkspaceCredentialMemberships } from '@/lib/credentials/access'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import { hasWorkspaceAdminAccess } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
const logger = createLogger('WorkspaceMemberAPI')
|
||||
@@ -106,13 +105,6 @@ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ i
|
||||
|
||||
await revokeWorkspaceCredentialMemberships(workspaceId, userId)
|
||||
|
||||
captureServerEvent(
|
||||
session.user.id,
|
||||
'workspace_member_removed',
|
||||
{ workspace_id: workspaceId, is_self_removal: isSelf },
|
||||
{ groups: { workspace: workspaceId } }
|
||||
)
|
||||
|
||||
recordAudit({
|
||||
workspaceId,
|
||||
actorId: session.user.id,
|
||||
|
||||
@@ -7,7 +7,6 @@ import { z } from 'zod'
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { PlatformEvents } from '@/lib/core/telemetry'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults'
|
||||
import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils'
|
||||
import { getRandomWorkspaceColor } from '@/lib/workspaces/colors'
|
||||
@@ -97,16 +96,6 @@ export async function POST(req: Request) {
|
||||
|
||||
const newWorkspace = await createWorkspace(session.user.id, name, skipDefaultWorkflow, color)
|
||||
|
||||
captureServerEvent(
|
||||
session.user.id,
|
||||
'workspace_created',
|
||||
{ workspace_id: newWorkspace.id, name: newWorkspace.name },
|
||||
{
|
||||
groups: { workspace: newWorkspace.id },
|
||||
setOnce: { first_workspace_created_at: new Date().toISOString() },
|
||||
}
|
||||
)
|
||||
|
||||
recordAudit({
|
||||
workspaceId: newWorkspace.id,
|
||||
actorId: session.user.id,
|
||||
|
||||
@@ -90,7 +90,6 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
}
|
||||
|
||||
// Sidebar width
|
||||
var defaultSidebarWidth = '248px';
|
||||
try {
|
||||
var stored = localStorage.getItem('sidebar-state');
|
||||
if (stored) {
|
||||
@@ -109,15 +108,11 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
document.documentElement.style.setProperty('--sidebar-width', width + 'px');
|
||||
} else if (width > maxSidebarWidth) {
|
||||
document.documentElement.style.setProperty('--sidebar-width', maxSidebarWidth + 'px');
|
||||
} else {
|
||||
document.documentElement.style.setProperty('--sidebar-width', defaultSidebarWidth);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
document.documentElement.style.setProperty('--sidebar-width', defaultSidebarWidth);
|
||||
}
|
||||
} catch (e) {
|
||||
document.documentElement.style.setProperty('--sidebar-width', defaultSidebarWidth);
|
||||
// Fallback handled by CSS defaults
|
||||
}
|
||||
|
||||
// Panel width and active tab
|
||||
|
||||
@@ -1,44 +1,42 @@
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
|
||||
export function GET() {
|
||||
export async function GET() {
|
||||
const baseUrl = getBaseUrl()
|
||||
|
||||
const content = `# Sim
|
||||
const llmsContent = `# Sim
|
||||
|
||||
> Sim is the open-source platform to build AI agents and run your agentic workforce. Connect integrations and LLMs to deploy and orchestrate agentic workflows.
|
||||
> Sim is the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows.
|
||||
|
||||
Sim lets teams create agents, workflows, knowledge bases, tables, and docs. It supports both product discovery pages and deeper technical documentation.
|
||||
Sim lets teams create agents, workflows, knowledge bases, tables, and docs. Over 100,000 builders use Sim — from startups to Fortune 500 companies. SOC2 compliant.
|
||||
|
||||
## Preferred URLs
|
||||
## Core Pages
|
||||
|
||||
- [Homepage](${baseUrl}): Product overview and primary entry point
|
||||
- [Integrations directory](${baseUrl}/integrations): Public catalog of integrations and automation capabilities
|
||||
- [Models directory](${baseUrl}/models): Public catalog of AI models, pricing, context windows, and capabilities
|
||||
- [Blog](${baseUrl}/blog): Announcements, guides, and product context
|
||||
- [Homepage](${baseUrl}): Product overview, features, and pricing
|
||||
- [Changelog](${baseUrl}/changelog): Product updates and release notes
|
||||
- [Sim Blog](${baseUrl}/blog): Announcements, insights, and guides
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Documentation](https://docs.sim.ai): Product guides and technical reference
|
||||
- [Quickstart](https://docs.sim.ai/quickstart): Fastest path to getting started
|
||||
- [API Reference](https://docs.sim.ai/api): API documentation
|
||||
- [Documentation](https://docs.sim.ai): Complete guides and API reference
|
||||
- [Quickstart](https://docs.sim.ai/quickstart): Get started in 5 minutes
|
||||
- [API Reference](https://docs.sim.ai/api): REST API documentation
|
||||
|
||||
## Key Concepts
|
||||
|
||||
- **Workspace**: Container for workflows, data sources, and executions
|
||||
- **Workflow**: Directed graph of blocks defining an agentic process
|
||||
- **Block**: Individual step such as an LLM call, tool call, HTTP request, or code execution
|
||||
- **Block**: Individual step (LLM call, tool call, HTTP request, code execution)
|
||||
- **Trigger**: Event or schedule that initiates workflow execution
|
||||
- **Execution**: A single run of a workflow with logs and outputs
|
||||
- **Knowledge Base**: Document store used for retrieval-augmented generation
|
||||
- **Knowledge Base**: Vector-indexed document store for retrieval-augmented generation
|
||||
|
||||
## Capabilities
|
||||
|
||||
- AI agent creation and deployment
|
||||
- Agentic workflow orchestration
|
||||
- Integrations across business tools, databases, and communication platforms
|
||||
- Multi-model LLM orchestration
|
||||
- Knowledge bases and retrieval-augmented generation
|
||||
- 1,000+ integrations (Slack, Gmail, Notion, Airtable, databases, and more)
|
||||
- Multi-model LLM orchestration (OpenAI, Anthropic, Google, Mistral, xAI, Perplexity)
|
||||
- Knowledge base creation with retrieval-augmented generation (RAG)
|
||||
- Table creation and management
|
||||
- Document creation and processing
|
||||
- Scheduled and webhook-triggered executions
|
||||
@@ -47,19 +45,24 @@ Sim lets teams create agents, workflows, knowledge bases, tables, and docs. It s
|
||||
|
||||
- AI agent deployment and orchestration
|
||||
- Knowledge bases and RAG pipelines
|
||||
- Document creation and processing
|
||||
- Customer support automation
|
||||
- Internal operations workflows across sales, marketing, legal, and finance
|
||||
- Internal operations (sales, marketing, legal, finance)
|
||||
|
||||
## Additional Links
|
||||
## Links
|
||||
|
||||
- [GitHub Repository](https://github.com/simstudioai/sim): Open-source codebase
|
||||
- [Docs](https://docs.sim.ai): Canonical documentation source
|
||||
- [Discord Community](https://discord.gg/Hr4UWYEcTT): Get help and connect with 100,000+ builders
|
||||
- [X/Twitter](https://x.com/simdotai): Product updates and announcements
|
||||
|
||||
## Optional
|
||||
|
||||
- [Careers](https://jobs.ashbyhq.com/sim): Join the Sim team
|
||||
- [Terms of Service](${baseUrl}/terms): Legal terms
|
||||
- [Privacy Policy](${baseUrl}/privacy): Data handling practices
|
||||
- [Sitemap](${baseUrl}/sitemap.xml): Public URL inventory
|
||||
`
|
||||
|
||||
return new Response(content, {
|
||||
return new Response(llmsContent, {
|
||||
headers: {
|
||||
'Content-Type': 'text/markdown; charset=utf-8',
|
||||
'Cache-Control': 'public, max-age=86400, s-maxage=86400',
|
||||
|
||||
@@ -8,34 +8,6 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
const baseUrl = getBaseUrl()
|
||||
|
||||
const now = new Date()
|
||||
const integrationPages: MetadataRoute.Sitemap = integrations.map((integration) => ({
|
||||
url: `${baseUrl}/integrations/${integration.slug}`,
|
||||
lastModified: now,
|
||||
}))
|
||||
const modelHubPages: MetadataRoute.Sitemap = [
|
||||
{
|
||||
url: `${baseUrl}/integrations`,
|
||||
lastModified: now,
|
||||
},
|
||||
{
|
||||
url: `${baseUrl}/models`,
|
||||
lastModified: now,
|
||||
},
|
||||
{
|
||||
url: `${baseUrl}/partners`,
|
||||
lastModified: now,
|
||||
},
|
||||
]
|
||||
const providerPages: MetadataRoute.Sitemap = MODEL_PROVIDERS_WITH_CATALOGS.map((provider) => ({
|
||||
url: `${baseUrl}${provider.href}`,
|
||||
lastModified: new Date(
|
||||
Math.max(...provider.models.map((model) => new Date(model.pricing.updatedAt).getTime()))
|
||||
),
|
||||
}))
|
||||
const modelPages: MetadataRoute.Sitemap = ALL_CATALOG_MODELS.map((model) => ({
|
||||
url: `${baseUrl}${model.href}`,
|
||||
lastModified: new Date(model.pricing.updatedAt),
|
||||
}))
|
||||
|
||||
const staticPages: MetadataRoute.Sitemap = [
|
||||
{
|
||||
@@ -54,6 +26,14 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
// url: `${baseUrl}/templates`,
|
||||
// lastModified: now,
|
||||
// },
|
||||
{
|
||||
url: `${baseUrl}/integrations`,
|
||||
lastModified: now,
|
||||
},
|
||||
{
|
||||
url: `${baseUrl}/models`,
|
||||
lastModified: now,
|
||||
},
|
||||
{
|
||||
url: `${baseUrl}/changelog`,
|
||||
lastModified: now,
|
||||
@@ -74,12 +54,20 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
lastModified: new Date(p.updated ?? p.date),
|
||||
}))
|
||||
|
||||
return [
|
||||
...staticPages,
|
||||
...modelHubPages,
|
||||
...integrationPages,
|
||||
...providerPages,
|
||||
...modelPages,
|
||||
...blogPages,
|
||||
]
|
||||
const integrationPages: MetadataRoute.Sitemap = integrations.map((i) => ({
|
||||
url: `${baseUrl}/integrations/${i.slug}`,
|
||||
lastModified: now,
|
||||
}))
|
||||
|
||||
const providerPages: MetadataRoute.Sitemap = MODEL_PROVIDERS_WITH_CATALOGS.map((provider) => ({
|
||||
url: `${baseUrl}${provider.href}`,
|
||||
lastModified: now,
|
||||
}))
|
||||
|
||||
const modelPages: MetadataRoute.Sitemap = ALL_CATALOG_MODELS.map((model) => ({
|
||||
url: `${baseUrl}${model.href}`,
|
||||
lastModified: new Date(model.pricing.updatedAt),
|
||||
}))
|
||||
|
||||
return [...staticPages, ...blogPages, ...integrationPages, ...providerPages, ...modelPages]
|
||||
}
|
||||
|
||||
@@ -108,6 +108,8 @@ function normalizeWorkflowState(input?: any): WorkflowState | null {
|
||||
lastUpdate: input.lastUpdate,
|
||||
metadata: input.metadata,
|
||||
variables: input.variables,
|
||||
deploymentStatuses: input.deploymentStatuses,
|
||||
needsRedeployment: input.needsRedeployment,
|
||||
dragStartPosition: input.dragStartPosition ?? null,
|
||||
}
|
||||
|
||||
|
||||
@@ -1,59 +1,22 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Check, Copy, Ellipsis, Hash } from 'lucide-react'
|
||||
import {
|
||||
Button,
|
||||
Check,
|
||||
Copy,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
Textarea,
|
||||
ThumbsDown,
|
||||
ThumbsUp,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/emcn'
|
||||
import { useSubmitCopilotFeedback } from '@/hooks/queries/copilot-feedback'
|
||||
|
||||
const SPECIAL_TAGS = 'thinking|options|usage_upgrade|credential|mothership-error|file'
|
||||
|
||||
function toPlainText(raw: string): string {
|
||||
return (
|
||||
raw
|
||||
// Strip special tags and their contents
|
||||
.replace(new RegExp(`<\\/?(${SPECIAL_TAGS})(?:>[\\s\\S]*?<\\/(${SPECIAL_TAGS})>|>)`, 'g'), '')
|
||||
// Strip markdown
|
||||
.replace(/^#{1,6}\s+/gm, '')
|
||||
.replace(/\*\*(.+?)\*\*/g, '$1')
|
||||
.replace(/\*(.+?)\*/g, '$1')
|
||||
.replace(/`{3}[\s\S]*?`{3}/g, '')
|
||||
.replace(/`(.+?)`/g, '$1')
|
||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
|
||||
.replace(/^[>\-*]\s+/gm, '')
|
||||
.replace(/!\[[^\]]*\]\([^)]+\)/g, '')
|
||||
// Normalize whitespace
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim()
|
||||
)
|
||||
}
|
||||
|
||||
const ICON_CLASS = 'h-[14px] w-[14px]'
|
||||
const BUTTON_CLASS =
|
||||
'flex h-[26px] w-[26px] items-center justify-center rounded-[6px] text-[var(--text-icon)] transition-colors hover-hover:bg-[var(--surface-hover)] focus-visible:outline-none'
|
||||
|
||||
interface MessageActionsProps {
|
||||
content: string
|
||||
chatId?: string
|
||||
userQuery?: string
|
||||
requestId?: string
|
||||
}
|
||||
|
||||
export function MessageActions({ content, chatId, userQuery }: MessageActionsProps) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [pendingFeedback, setPendingFeedback] = useState<'up' | 'down' | null>(null)
|
||||
const [feedbackText, setFeedbackText] = useState('')
|
||||
export function MessageActions({ content, requestId }: MessageActionsProps) {
|
||||
const [copied, setCopied] = useState<'message' | 'request' | null>(null)
|
||||
const resetTimeoutRef = useRef<number | null>(null)
|
||||
const submitFeedback = useSubmitCopilotFeedback()
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -63,119 +26,59 @@ export function MessageActions({ content, chatId, userQuery }: MessageActionsPro
|
||||
}
|
||||
}, [])
|
||||
|
||||
const copyToClipboard = useCallback(async () => {
|
||||
if (!content) return
|
||||
const text = toPlainText(content)
|
||||
if (!text) return
|
||||
const copyToClipboard = useCallback(async (text: string, type: 'message' | 'request') => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
setCopied(true)
|
||||
setCopied(type)
|
||||
if (resetTimeoutRef.current !== null) {
|
||||
window.clearTimeout(resetTimeoutRef.current)
|
||||
}
|
||||
resetTimeoutRef.current = window.setTimeout(() => setCopied(false), 1500)
|
||||
resetTimeoutRef.current = window.setTimeout(() => setCopied(null), 1500)
|
||||
} catch {
|
||||
/* clipboard unavailable */
|
||||
}
|
||||
}, [content])
|
||||
|
||||
const handleFeedbackClick = useCallback(
|
||||
(type: 'up' | 'down') => {
|
||||
if (chatId && userQuery) {
|
||||
setPendingFeedback(type)
|
||||
setFeedbackText('')
|
||||
}
|
||||
},
|
||||
[chatId, userQuery]
|
||||
)
|
||||
|
||||
const handleSubmitFeedback = useCallback(() => {
|
||||
if (!pendingFeedback || !chatId || !userQuery) return
|
||||
const text = feedbackText.trim()
|
||||
if (!text) {
|
||||
setPendingFeedback(null)
|
||||
setFeedbackText('')
|
||||
return
|
||||
}
|
||||
submitFeedback.mutate({
|
||||
chatId,
|
||||
userQuery,
|
||||
agentResponse: content,
|
||||
isPositiveFeedback: pendingFeedback === 'up',
|
||||
feedback: text,
|
||||
})
|
||||
setPendingFeedback(null)
|
||||
setFeedbackText('')
|
||||
}, [pendingFeedback, chatId, userQuery, content, feedbackText])
|
||||
|
||||
const handleModalClose = useCallback((open: boolean) => {
|
||||
if (!open) {
|
||||
setPendingFeedback(null)
|
||||
setFeedbackText('')
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (!content) return null
|
||||
if (!content && !requestId) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='flex items-center gap-0.5'>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type='button'
|
||||
aria-label='Copy message'
|
||||
onClick={copyToClipboard}
|
||||
className={BUTTON_CLASS}
|
||||
aria-label='More options'
|
||||
className='flex h-5 w-5 items-center justify-center rounded-sm text-[var(--text-icon)] opacity-0 transition-colors transition-opacity hover-hover:bg-[var(--surface-3)] hover-hover:text-[var(--text-primary)] focus-visible:opacity-100 focus-visible:outline-none group-hover/msg:opacity-100 data-[state=open]:opacity-100'
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
{copied ? <Check className={ICON_CLASS} /> : <Copy className={ICON_CLASS} />}
|
||||
<Ellipsis className='h-3 w-3' strokeWidth={2} />
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
aria-label='Like'
|
||||
onClick={() => handleFeedbackClick('up')}
|
||||
className={BUTTON_CLASS}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end' side='top' sideOffset={4}>
|
||||
<DropdownMenuItem
|
||||
disabled={!content}
|
||||
onSelect={(event) => {
|
||||
event.stopPropagation()
|
||||
void copyToClipboard(content, 'message')
|
||||
}}
|
||||
>
|
||||
<ThumbsUp className={ICON_CLASS} />
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
aria-label='Dislike'
|
||||
onClick={() => handleFeedbackClick('down')}
|
||||
className={BUTTON_CLASS}
|
||||
{copied === 'message' ? <Check /> : <Copy />}
|
||||
<span>Copy Message</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
disabled={!requestId}
|
||||
onSelect={(event) => {
|
||||
event.stopPropagation()
|
||||
if (requestId) {
|
||||
void copyToClipboard(requestId, 'request')
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ThumbsDown className={ICON_CLASS} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Modal open={pendingFeedback !== null} onOpenChange={handleModalClose}>
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>Give feedback</ModalHeader>
|
||||
<ModalBody>
|
||||
<div className='flex flex-col gap-2'>
|
||||
<p className='font-medium text-[var(--text-secondary)] text-sm'>
|
||||
{pendingFeedback === 'up' ? 'What did you like?' : 'What could be improved?'}
|
||||
</p>
|
||||
<Textarea
|
||||
placeholder={
|
||||
pendingFeedback === 'up'
|
||||
? 'Tell us what was helpful...'
|
||||
: 'Tell us what went wrong...'
|
||||
}
|
||||
value={feedbackText}
|
||||
onChange={(e) => setFeedbackText(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant='default' onClick={() => handleModalClose(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant='primary' onClick={handleSubmitFeedback}>
|
||||
Submit
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</>
|
||||
{copied === 'request' ? <Check /> : <Hash />}
|
||||
<span>Copy Request ID</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
} from '@/components/emcn'
|
||||
import { client, useSession } from '@/lib/auth/auth-client'
|
||||
import type { OAuthReturnContext } from '@/lib/credentials/client-state'
|
||||
import { ADD_CONNECTOR_SEARCH_PARAM, writeOAuthReturnContext } from '@/lib/credentials/client-state'
|
||||
import { writeOAuthReturnContext } from '@/lib/credentials/client-state'
|
||||
import {
|
||||
getCanonicalScopesForProvider,
|
||||
getProviderIdFromServiceId,
|
||||
@@ -59,8 +59,8 @@ type OAuthModalConnectProps = OAuthModalBaseProps & {
|
||||
workspaceId: string
|
||||
credentialCount: number
|
||||
} & (
|
||||
| { workflowId: string; knowledgeBaseId?: never; connectorType?: never }
|
||||
| { workflowId?: never; knowledgeBaseId: string; connectorType?: string }
|
||||
| { workflowId: string; knowledgeBaseId?: never }
|
||||
| { workflowId?: never; knowledgeBaseId: string }
|
||||
)
|
||||
|
||||
interface OAuthModalReauthorizeProps extends OAuthModalBaseProps {
|
||||
@@ -81,7 +81,6 @@ export function OAuthModal(props: OAuthModalProps) {
|
||||
const workspaceId = isConnect ? props.workspaceId : ''
|
||||
const workflowId = isConnect ? props.workflowId : undefined
|
||||
const knowledgeBaseId = isConnect ? props.knowledgeBaseId : undefined
|
||||
const connectorType = isConnect ? props.connectorType : undefined
|
||||
const toolName = !isConnect ? props.toolName : ''
|
||||
const requiredScopes = !isConnect ? (props.requiredScopes ?? EMPTY_SCOPES) : EMPTY_SCOPES
|
||||
const newScopes = !isConnect ? (props.newScopes ?? EMPTY_SCOPES) : EMPTY_SCOPES
|
||||
@@ -173,7 +172,7 @@ export function OAuthModal(props: OAuthModalProps) {
|
||||
}
|
||||
|
||||
const returnContext: OAuthReturnContext = knowledgeBaseId
|
||||
? { ...baseContext, origin: 'kb-connectors' as const, knowledgeBaseId, connectorType }
|
||||
? { ...baseContext, origin: 'kb-connectors' as const, knowledgeBaseId }
|
||||
: { ...baseContext, origin: 'workflow' as const, workflowId: workflowId! }
|
||||
|
||||
writeOAuthReturnContext(returnContext)
|
||||
@@ -206,11 +205,7 @@ export function OAuthModal(props: OAuthModalProps) {
|
||||
return
|
||||
}
|
||||
|
||||
const callbackURL = new URL(window.location.href)
|
||||
if (connectorType) {
|
||||
callbackURL.searchParams.set(ADD_CONNECTOR_SEARCH_PARAM, connectorType)
|
||||
}
|
||||
await client.oauth2.link({ providerId, callbackURL: callbackURL.toString() })
|
||||
await client.oauth2.link({ providerId, callbackURL: window.location.href })
|
||||
handleClose()
|
||||
} catch (err) {
|
||||
logger.error('Failed to initiate OAuth connection', { error: err })
|
||||
|
||||
@@ -26,7 +26,6 @@ export function NavTour() {
|
||||
steps: navTourSteps,
|
||||
triggerEvent: START_NAV_TOUR_EVENT,
|
||||
tourName: 'Navigation tour',
|
||||
tourType: 'nav',
|
||||
disabled: isWorkflowPage,
|
||||
})
|
||||
|
||||
|
||||
@@ -2,9 +2,7 @@
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { usePostHog } from 'posthog-js/react'
|
||||
import { ACTIONS, type CallBackProps, EVENTS, STATUS, type Step } from 'react-joyride'
|
||||
import { captureEvent } from '@/lib/posthog/client'
|
||||
|
||||
const logger = createLogger('useTour')
|
||||
|
||||
@@ -18,8 +16,6 @@ interface UseTourOptions {
|
||||
triggerEvent?: string
|
||||
/** Identifier for logging */
|
||||
tourName?: string
|
||||
/** Analytics tour type for PostHog events */
|
||||
tourType?: 'nav' | 'workflow'
|
||||
/** When true, stops a running tour (e.g. navigating away from the relevant page) */
|
||||
disabled?: boolean
|
||||
}
|
||||
@@ -49,10 +45,8 @@ export function useTour({
|
||||
steps,
|
||||
triggerEvent,
|
||||
tourName = 'tour',
|
||||
tourType,
|
||||
disabled = false,
|
||||
}: UseTourOptions): UseTourReturn {
|
||||
const posthog = usePostHog()
|
||||
const [run, setRun] = useState(false)
|
||||
const [stepIndex, setStepIndex] = useState(0)
|
||||
const [tourKey, setTourKey] = useState(0)
|
||||
@@ -158,9 +152,6 @@ export function useTour({
|
||||
setRun(true)
|
||||
logger.info(`${tourName} triggered via event`)
|
||||
scheduleReveal()
|
||||
if (tourType) {
|
||||
captureEvent(posthog, 'tour_started', { tour_type: tourType })
|
||||
}
|
||||
}, 50)
|
||||
}
|
||||
|
||||
@@ -190,13 +181,6 @@ export function useTour({
|
||||
if (status === STATUS.FINISHED || status === STATUS.SKIPPED) {
|
||||
stopTour()
|
||||
logger.info(`${tourName} ended`, { status })
|
||||
if (tourType) {
|
||||
if (status === STATUS.FINISHED) {
|
||||
captureEvent(posthog, 'tour_completed', { tour_type: tourType })
|
||||
} else {
|
||||
captureEvent(posthog, 'tour_skipped', { tour_type: tourType, step_index: index })
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -204,9 +188,6 @@ export function useTour({
|
||||
if (action === ACTIONS.CLOSE) {
|
||||
stopTour()
|
||||
logger.info(`${tourName} closed by user`)
|
||||
if (tourType) {
|
||||
captureEvent(posthog, 'tour_skipped', { tour_type: tourType, step_index: index })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -222,7 +203,7 @@ export function useTour({
|
||||
transitionToStep(nextIndex)
|
||||
}
|
||||
},
|
||||
[stopTour, transitionToStep, steps, tourName, tourType, posthog]
|
||||
[stopTour, transitionToStep, steps, tourName]
|
||||
)
|
||||
|
||||
return {
|
||||
|
||||
@@ -26,7 +26,6 @@ export function WorkflowTour() {
|
||||
steps: workflowTourSteps,
|
||||
triggerEvent: START_WORKFLOW_TOUR_EVENT,
|
||||
tourName: 'Workflow tour',
|
||||
tourType: 'workflow',
|
||||
})
|
||||
|
||||
const tourState = useMemo<TourState>(
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Loader2 } from 'lucide-react'
|
||||
|
||||
export default function FileViewLoading() {
|
||||
return (
|
||||
<div className='fixed inset-0 z-50 flex items-center justify-center bg-[var(--bg)]'>
|
||||
<Loader2 className='h-[20px] w-[20px] animate-spin text-[var(--text-tertiary)]' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { createContext, memo, useCallback, useContext, useEffect, useMemo, useRef } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { createContext, memo, useContext, useMemo, useRef } from 'react'
|
||||
import type { Components, ExtraProps } from 'react-markdown'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import rehypeSlug from 'rehype-slug'
|
||||
import remarkBreaks from 'remark-breaks'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { Checkbox } from '@/components/emcn'
|
||||
@@ -72,7 +70,6 @@ export const PreviewPanel = memo(function PreviewPanel({
|
||||
})
|
||||
|
||||
const REMARK_PLUGINS = [remarkGfm, remarkBreaks]
|
||||
const REHYPE_PLUGINS = [rehypeSlug]
|
||||
|
||||
/**
|
||||
* Carries the contentRef and toggle handler from MarkdownPreview down to the
|
||||
@@ -86,43 +83,29 @@ const MarkdownCheckboxCtx = createContext<{
|
||||
/** Carries the resolved checkbox index from LiRenderer to InputRenderer. */
|
||||
const CheckboxIndexCtx = createContext(-1)
|
||||
|
||||
const NavigateCtx = createContext<((path: string) => void) | null>(null)
|
||||
|
||||
const STATIC_MARKDOWN_COMPONENTS = {
|
||||
p: ({ children }: { children?: React.ReactNode }) => (
|
||||
<p className='mb-3 break-words text-[14px] text-[var(--text-primary)] leading-[1.6] last:mb-0'>
|
||||
{children}
|
||||
</p>
|
||||
),
|
||||
h1: ({ id, children }: { id?: string; children?: React.ReactNode }) => (
|
||||
<h1
|
||||
id={id}
|
||||
className='mt-6 mb-4 break-words font-semibold text-[24px] text-[var(--text-primary)] first:mt-0'
|
||||
>
|
||||
h1: ({ children }: { children?: React.ReactNode }) => (
|
||||
<h1 className='mt-6 mb-4 break-words font-semibold text-[24px] text-[var(--text-primary)] first:mt-0'>
|
||||
{children}
|
||||
</h1>
|
||||
),
|
||||
h2: ({ id, children }: { id?: string; children?: React.ReactNode }) => (
|
||||
<h2
|
||||
id={id}
|
||||
className='mt-5 mb-3 break-words font-semibold text-[20px] text-[var(--text-primary)] first:mt-0'
|
||||
>
|
||||
h2: ({ children }: { children?: React.ReactNode }) => (
|
||||
<h2 className='mt-5 mb-3 break-words font-semibold text-[20px] text-[var(--text-primary)] first:mt-0'>
|
||||
{children}
|
||||
</h2>
|
||||
),
|
||||
h3: ({ id, children }: { id?: string; children?: React.ReactNode }) => (
|
||||
<h3
|
||||
id={id}
|
||||
className='mt-4 mb-2 break-words font-semibold text-[16px] text-[var(--text-primary)] first:mt-0'
|
||||
>
|
||||
h3: ({ children }: { children?: React.ReactNode }) => (
|
||||
<h3 className='mt-4 mb-2 break-words font-semibold text-[16px] text-[var(--text-primary)] first:mt-0'>
|
||||
{children}
|
||||
</h3>
|
||||
),
|
||||
h4: ({ id, children }: { id?: string; children?: React.ReactNode }) => (
|
||||
<h4
|
||||
id={id}
|
||||
className='mt-3 mb-2 break-words font-semibold text-[14px] text-[var(--text-primary)] first:mt-0'
|
||||
>
|
||||
h4: ({ children }: { children?: React.ReactNode }) => (
|
||||
<h4 className='mt-3 mb-2 break-words font-semibold text-[14px] text-[var(--text-primary)] first:mt-0'>
|
||||
{children}
|
||||
</h4>
|
||||
),
|
||||
@@ -155,6 +138,16 @@ const STATIC_MARKDOWN_COMPONENTS = {
|
||||
)
|
||||
},
|
||||
pre: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
|
||||
a: ({ href, children }: { href?: string; children?: React.ReactNode }) => (
|
||||
<a
|
||||
href={href}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='break-all text-[var(--brand-secondary)] underline-offset-2 hover:underline'
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
strong: ({ children }: { children?: React.ReactNode }) => (
|
||||
<strong className='break-words font-semibold text-[var(--text-primary)]'>{children}</strong>
|
||||
),
|
||||
@@ -274,75 +267,8 @@ function InputRenderer({
|
||||
)
|
||||
}
|
||||
|
||||
function isInternalHref(
|
||||
href: string,
|
||||
origin = window.location.origin
|
||||
): { pathname: string; hash: string } | null {
|
||||
if (href.startsWith('#')) return { pathname: '', hash: href }
|
||||
try {
|
||||
const url = new URL(href, origin)
|
||||
if (url.origin === origin && url.pathname.startsWith('/workspace/')) {
|
||||
return { pathname: url.pathname, hash: url.hash }
|
||||
}
|
||||
} catch {
|
||||
if (href.startsWith('/workspace/')) {
|
||||
const hashIdx = href.indexOf('#')
|
||||
if (hashIdx === -1) return { pathname: href, hash: '' }
|
||||
return { pathname: href.slice(0, hashIdx), hash: href.slice(hashIdx) }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function AnchorRenderer({ href, children }: { href?: string; children?: React.ReactNode }) {
|
||||
const navigate = useContext(NavigateCtx)
|
||||
const parsed = useMemo(() => (href ? isInternalHref(href) : null), [href])
|
||||
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
if (!parsed || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return
|
||||
|
||||
e.preventDefault()
|
||||
|
||||
if (parsed.pathname === '' && parsed.hash) {
|
||||
const el = document.getElementById(parsed.hash.slice(1))
|
||||
if (el) {
|
||||
const container = el.closest('.overflow-auto') as HTMLElement | null
|
||||
if (container) {
|
||||
container.scrollTo({ top: el.offsetTop - container.offsetTop, behavior: 'smooth' })
|
||||
} else {
|
||||
el.scrollIntoView({ behavior: 'smooth' })
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const destination = parsed.pathname + parsed.hash
|
||||
if (navigate) {
|
||||
navigate(destination)
|
||||
} else {
|
||||
window.location.assign(destination)
|
||||
}
|
||||
},
|
||||
[parsed, navigate]
|
||||
)
|
||||
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target={parsed ? undefined : '_blank'}
|
||||
rel={parsed ? undefined : 'noopener noreferrer'}
|
||||
onClick={handleClick}
|
||||
className='break-all text-[var(--brand-secondary)] underline-offset-2 hover:underline'
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
const MARKDOWN_COMPONENTS = {
|
||||
...STATIC_MARKDOWN_COMPONENTS,
|
||||
a: AnchorRenderer,
|
||||
ul: UlRenderer,
|
||||
ol: OlRenderer,
|
||||
li: LiRenderer,
|
||||
@@ -358,7 +284,6 @@ const MarkdownPreview = memo(function MarkdownPreview({
|
||||
isStreaming?: boolean
|
||||
onCheckboxToggle?: (checkboxIndex: number, checked: boolean) => void
|
||||
}) {
|
||||
const { push: navigate } = useRouter()
|
||||
const { ref: scrollRef } = useAutoScroll(isStreaming)
|
||||
const { committed, incoming, generation } = useStreamingReveal(content, isStreaming)
|
||||
|
||||
@@ -370,30 +295,10 @@ const MarkdownPreview = memo(function MarkdownPreview({
|
||||
[onCheckboxToggle]
|
||||
)
|
||||
|
||||
const hasScrolledToHash = useRef(false)
|
||||
useEffect(() => {
|
||||
const hash = window.location.hash
|
||||
if (!hash || hasScrolledToHash.current) return
|
||||
const id = hash.slice(1)
|
||||
const el = document.getElementById(id)
|
||||
if (!el) return
|
||||
hasScrolledToHash.current = true
|
||||
const container = el.closest('.overflow-auto') as HTMLElement | null
|
||||
if (container) {
|
||||
container.scrollTo({ top: el.offsetTop - container.offsetTop, behavior: 'smooth' })
|
||||
} else {
|
||||
el.scrollIntoView({ behavior: 'smooth' })
|
||||
}
|
||||
}, [content])
|
||||
|
||||
const committedMarkdown = useMemo(
|
||||
() =>
|
||||
committed ? (
|
||||
<ReactMarkdown
|
||||
remarkPlugins={REMARK_PLUGINS}
|
||||
rehypePlugins={REHYPE_PLUGINS}
|
||||
components={MARKDOWN_COMPONENTS}
|
||||
>
|
||||
<ReactMarkdown remarkPlugins={REMARK_PLUGINS} components={MARKDOWN_COMPONENTS}>
|
||||
{committed}
|
||||
</ReactMarkdown>
|
||||
) : null,
|
||||
@@ -402,42 +307,30 @@ const MarkdownPreview = memo(function MarkdownPreview({
|
||||
|
||||
if (onCheckboxToggle) {
|
||||
return (
|
||||
<NavigateCtx.Provider value={navigate}>
|
||||
<MarkdownCheckboxCtx.Provider value={ctxValue}>
|
||||
<div ref={scrollRef} className='h-full overflow-auto p-6'>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={REMARK_PLUGINS}
|
||||
rehypePlugins={REHYPE_PLUGINS}
|
||||
components={MARKDOWN_COMPONENTS}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</MarkdownCheckboxCtx.Provider>
|
||||
</NavigateCtx.Provider>
|
||||
<MarkdownCheckboxCtx.Provider value={ctxValue}>
|
||||
<div ref={scrollRef} className='h-full overflow-auto p-6'>
|
||||
<ReactMarkdown remarkPlugins={REMARK_PLUGINS} components={MARKDOWN_COMPONENTS}>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</MarkdownCheckboxCtx.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<NavigateCtx.Provider value={navigate}>
|
||||
<div ref={scrollRef} className='h-full overflow-auto p-6'>
|
||||
{committedMarkdown}
|
||||
{incoming && (
|
||||
<div
|
||||
key={generation}
|
||||
className={cn(isStreaming && 'animate-stream-fade-in', '[&>:first-child]:mt-0')}
|
||||
>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={REMARK_PLUGINS}
|
||||
rehypePlugins={REHYPE_PLUGINS}
|
||||
components={MARKDOWN_COMPONENTS}
|
||||
>
|
||||
{incoming}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</NavigateCtx.Provider>
|
||||
<div ref={scrollRef} className='h-full overflow-auto p-6'>
|
||||
{committedMarkdown}
|
||||
{incoming && (
|
||||
<div
|
||||
key={generation}
|
||||
className={cn(isStreaming && 'animate-stream-fade-in', '[&>:first-child]:mt-0')}
|
||||
>
|
||||
<ReactMarkdown remarkPlugins={REMARK_PLUGINS} components={MARKDOWN_COMPONENTS}>
|
||||
{incoming}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -473,9 +473,9 @@ function MothershipErrorDisplay({ data }: { data: MothershipErrorTagData }) {
|
||||
const detail = data.code ? `${data.message} (${data.code})` : data.message
|
||||
|
||||
return (
|
||||
<p className='animate-stream-fade-in font-base text-[13px] text-[var(--text-secondary)] italic leading-[20px]'>
|
||||
<span className='animate-stream-fade-in font-base text-[13px] text-[var(--text-secondary)] italic leading-[20px]'>
|
||||
{detail}
|
||||
</p>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -35,7 +35,6 @@ interface MothershipChatProps {
|
||||
onSendQueuedMessage: (id: string) => Promise<void>
|
||||
onEditQueuedMessage: (id: string) => void
|
||||
userId?: string
|
||||
chatId?: string
|
||||
onContextAdd?: (context: ChatContext) => void
|
||||
editValue?: string
|
||||
onEditValueConsumed?: () => void
|
||||
@@ -54,7 +53,7 @@ const LAYOUT_STYLES = {
|
||||
userRow: 'flex flex-col items-end gap-[6px] pt-3',
|
||||
attachmentWidth: 'max-w-[70%]',
|
||||
userBubble: 'max-w-[70%] overflow-hidden rounded-[16px] bg-[var(--surface-5)] px-3.5 py-2',
|
||||
assistantRow: 'group/msg',
|
||||
assistantRow: 'group/msg relative pb-5',
|
||||
footer: 'flex-shrink-0 px-[24px] pb-[16px]',
|
||||
footerInner: 'mx-auto max-w-[42rem]',
|
||||
},
|
||||
@@ -64,7 +63,7 @@ const LAYOUT_STYLES = {
|
||||
userRow: 'flex flex-col items-end gap-[6px] pt-2',
|
||||
attachmentWidth: 'max-w-[85%]',
|
||||
userBubble: 'max-w-[85%] overflow-hidden rounded-[16px] bg-[var(--surface-5)] px-3 py-2',
|
||||
assistantRow: 'group/msg',
|
||||
assistantRow: 'group/msg relative pb-3',
|
||||
footer: 'flex-shrink-0 px-3 pb-3',
|
||||
footerInner: '',
|
||||
},
|
||||
@@ -81,7 +80,6 @@ export function MothershipChat({
|
||||
onSendQueuedMessage,
|
||||
onEditQueuedMessage,
|
||||
userId,
|
||||
chatId,
|
||||
onContextAdd,
|
||||
editValue,
|
||||
onEditValueConsumed,
|
||||
@@ -149,28 +147,20 @@ export function MothershipChat({
|
||||
}
|
||||
|
||||
const isLastMessage = index === messages.length - 1
|
||||
const precedingUserMsg = [...messages]
|
||||
.slice(0, index)
|
||||
.reverse()
|
||||
.find((m) => m.role === 'user')
|
||||
|
||||
return (
|
||||
<div key={msg.id} className={styles.assistantRow}>
|
||||
{!isThisStreaming && (msg.content || msg.contentBlocks?.length) && (
|
||||
<div className='absolute right-0 bottom-0 z-10'>
|
||||
<MessageActions content={msg.content} requestId={msg.requestId} />
|
||||
</div>
|
||||
)}
|
||||
<MessageContent
|
||||
blocks={msg.contentBlocks || []}
|
||||
fallbackContent={msg.content}
|
||||
isStreaming={isThisStreaming}
|
||||
onOptionSelect={isLastMessage ? onSubmit : undefined}
|
||||
/>
|
||||
{!isThisStreaming && (msg.content || msg.contentBlocks?.length) && (
|
||||
<div className='mt-2.5'>
|
||||
<MessageActions
|
||||
content={msg.content}
|
||||
chatId={chatId}
|
||||
userQuery={precedingUserMsg?.content}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -115,7 +115,7 @@ export const MothershipView = memo(
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative z-10 flex h-full flex-col overflow-hidden border-[var(--border)] bg-[var(--bg)] transition-[width,min-width,border-width] duration-200 ease-[cubic-bezier(0.25,0.1,0.25,1)]',
|
||||
'relative z-10 flex h-full flex-col overflow-hidden border-[var(--border)] bg-[var(--bg)] transition-[width,min-width,border-width] duration-500 ease-[cubic-bezier(0.16,1,0.3,1)]',
|
||||
isCollapsed ? 'w-0 min-w-0 border-l-0' : 'w-1/2 border-l',
|
||||
className
|
||||
)}
|
||||
|
||||
@@ -353,17 +353,7 @@ const TemplateCard = memo(function TemplateCard({ template, onSelect }: Template
|
||||
return (
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => {
|
||||
import('@/lib/posthog/client')
|
||||
.then(({ captureClientEvent }) => {
|
||||
captureClientEvent('template_used', {
|
||||
template_title: template.title,
|
||||
template_modules: template.modules.join(' '),
|
||||
})
|
||||
})
|
||||
.catch(() => {})
|
||||
onSelect(template.prompt)
|
||||
}}
|
||||
onClick={() => onSelect(template.prompt)}
|
||||
aria-label={`Select template: ${template.title}`}
|
||||
className='group flex cursor-pointer flex-col text-left'
|
||||
>
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useParams, useRouter, useSearchParams } from 'next/navigation'
|
||||
import { usePostHog } from 'posthog-js/react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { PanelLeft } from '@/components/emcn/icons'
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
import {
|
||||
@@ -11,7 +10,6 @@ import {
|
||||
type LandingWorkflowSeed,
|
||||
LandingWorkflowSeedStorage,
|
||||
} from '@/lib/core/utils/browser-storage'
|
||||
import { captureEvent } from '@/lib/posthog/client'
|
||||
import { persistImportedWorkflow } from '@/lib/workflows/operations/import-export'
|
||||
import { useChatHistory, useMarkTaskRead } from '@/hooks/queries/tasks'
|
||||
import type { ChatContext } from '@/stores/panel'
|
||||
@@ -28,11 +26,7 @@ interface HomeProps {
|
||||
export function Home({ chatId }: HomeProps = {}) {
|
||||
const { workspaceId } = useParams<{ workspaceId: string }>()
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const initialResourceId = searchParams.get('resource')
|
||||
const { data: session } = useSession()
|
||||
const posthog = usePostHog()
|
||||
const posthogRef = useRef(posthog)
|
||||
const [initialPrompt, setInitialPrompt] = useState('')
|
||||
const hasCheckedLandingStorageRef = useRef(false)
|
||||
const initialViewInputRef = useRef<HTMLDivElement>(null)
|
||||
@@ -162,10 +156,7 @@ export function Home({ chatId }: HomeProps = {}) {
|
||||
} = useChat(
|
||||
workspaceId,
|
||||
chatId,
|
||||
getMothershipUseChatOptions({
|
||||
onResourceEvent: handleResourceEvent,
|
||||
initialActiveResourceId: initialResourceId,
|
||||
})
|
||||
getMothershipUseChatOptions({ onResourceEvent: handleResourceEvent })
|
||||
)
|
||||
|
||||
const [editingInputValue, setEditingInputValue] = useState('')
|
||||
@@ -188,16 +179,6 @@ export function Home({ chatId }: HomeProps = {}) {
|
||||
[editQueuedMessage]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const url = new URL(window.location.href)
|
||||
if (activeResourceId) {
|
||||
url.searchParams.set('resource', activeResourceId)
|
||||
} else {
|
||||
url.searchParams.delete('resource')
|
||||
}
|
||||
window.history.replaceState(null, '', url.toString())
|
||||
}, [activeResourceId])
|
||||
|
||||
useEffect(() => {
|
||||
wasSendingRef.current = false
|
||||
if (resolvedChatId) markRead(resolvedChatId)
|
||||
@@ -218,29 +199,18 @@ export function Home({ chatId }: HomeProps = {}) {
|
||||
return () => cancelAnimationFrame(id)
|
||||
}, [resources])
|
||||
|
||||
useEffect(() => {
|
||||
posthogRef.current = posthog
|
||||
}, [posthog])
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(text: string, fileAttachments?: FileAttachmentForApi[], contexts?: ChatContext[]) => {
|
||||
const trimmed = text.trim()
|
||||
if (!trimmed && !(fileAttachments && fileAttachments.length > 0)) return
|
||||
|
||||
captureEvent(posthogRef.current, 'task_message_sent', {
|
||||
workspace_id: workspaceId,
|
||||
has_attachments: !!(fileAttachments && fileAttachments.length > 0),
|
||||
has_contexts: !!(contexts && contexts.length > 0),
|
||||
is_new_task: !chatId,
|
||||
})
|
||||
|
||||
if (initialViewInputRef.current) {
|
||||
setIsInputEntering(true)
|
||||
}
|
||||
|
||||
sendMessage(trimmed || 'Analyze the attached file(s).', fileAttachments, contexts)
|
||||
},
|
||||
[sendMessage, workspaceId, chatId]
|
||||
[sendMessage]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -364,7 +334,6 @@ export function Home({ chatId }: HomeProps = {}) {
|
||||
onSendQueuedMessage={sendNow}
|
||||
onEditQueuedMessage={handleEditQueuedMessage}
|
||||
userId={session?.user?.id}
|
||||
chatId={resolvedChatId}
|
||||
onContextAdd={handleContextAdd}
|
||||
editValue={editingInputValue}
|
||||
onEditValueConsumed={clearEditingValue}
|
||||
|
||||
@@ -377,11 +377,10 @@ export interface UseChatOptions {
|
||||
onToolResult?: (toolName: string, success: boolean, result: unknown) => void
|
||||
onTitleUpdate?: () => void
|
||||
onStreamEnd?: (chatId: string, messages: ChatMessage[]) => void
|
||||
initialActiveResourceId?: string | null
|
||||
}
|
||||
|
||||
export function getMothershipUseChatOptions(
|
||||
options: Pick<UseChatOptions, 'onResourceEvent' | 'onStreamEnd' | 'initialActiveResourceId'> = {}
|
||||
options: Pick<UseChatOptions, 'onResourceEvent' | 'onStreamEnd'> = {}
|
||||
): UseChatOptions {
|
||||
return {
|
||||
apiPath: MOTHERSHIP_CHAT_API_PATH,
|
||||
@@ -417,7 +416,6 @@ export function useChat(
|
||||
const [resolvedChatId, setResolvedChatId] = useState<string | undefined>(initialChatId)
|
||||
const [resources, setResources] = useState<MothershipResource[]>([])
|
||||
const [activeResourceId, setActiveResourceId] = useState<string | null>(null)
|
||||
const initialActiveResourceIdRef = useRef(options?.initialActiveResourceId)
|
||||
const onResourceEventRef = useRef(options?.onResourceEvent)
|
||||
onResourceEventRef.current = options?.onResourceEvent
|
||||
const apiPathRef = useRef(options?.apiPath ?? MOTHERSHIP_CHAT_API_PATH)
|
||||
@@ -847,12 +845,7 @@ export function useChat(
|
||||
const persistedResources = history.resources.filter((r) => r.id !== 'streaming-file')
|
||||
if (persistedResources.length > 0) {
|
||||
setResources(persistedResources)
|
||||
const initialId = initialActiveResourceIdRef.current
|
||||
const restoredId =
|
||||
initialId && persistedResources.some((r) => r.id === initialId)
|
||||
? initialId
|
||||
: persistedResources[persistedResources.length - 1].id
|
||||
setActiveResourceId(restoredId)
|
||||
setActiveResourceId(persistedResources[persistedResources.length - 1].id)
|
||||
|
||||
for (const resource of persistedResources) {
|
||||
if (resource.type !== 'workflow') continue
|
||||
@@ -1414,6 +1407,17 @@ export function useChat(
|
||||
const output = tc.result?.output as Record<string, unknown> | undefined
|
||||
const deployedWorkflowId = (output?.workflowId as string) ?? undefined
|
||||
if (deployedWorkflowId && typeof output?.isDeployed === 'boolean') {
|
||||
const isDeployed = output.isDeployed as boolean
|
||||
const serverDeployedAt = output.deployedAt
|
||||
? new Date(output.deployedAt as string)
|
||||
: undefined
|
||||
useWorkflowRegistry
|
||||
.getState()
|
||||
.setDeploymentStatus(
|
||||
deployedWorkflowId,
|
||||
isDeployed,
|
||||
isDeployed ? (serverDeployedAt ?? new Date()) : undefined
|
||||
)
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: deploymentKeys.info(deployedWorkflowId),
|
||||
})
|
||||
|
||||
22
apps/sim/app/workspace/[workspaceId]/home/loading.tsx
Normal file
22
apps/sim/app/workspace/[workspaceId]/home/loading.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Skeleton } from '@/components/emcn'
|
||||
|
||||
const SKELETON_LINE_COUNT = 4
|
||||
|
||||
export default function HomeLoading() {
|
||||
return (
|
||||
<div className='flex h-full flex-col bg-[var(--bg)]'>
|
||||
<div className='min-h-0 flex-1 overflow-hidden px-6 py-4'>
|
||||
<div className='mx-auto max-w-[42rem] space-y-[10px] pt-3'>
|
||||
{Array.from({ length: SKELETON_LINE_COUNT }).map((_, i) => (
|
||||
<Skeleton key={i} className='h-[16px]' style={{ width: `${120 + (i % 4) * 48}px` }} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex-shrink-0 px-[24px] pb-[16px]'>
|
||||
<div className='mx-auto max-w-[42rem]'>
|
||||
<Skeleton className='h-[48px] w-full rounded-[12px]' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -4,8 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { format } from 'date-fns'
|
||||
import { AlertCircle, Loader2, Pencil, Plus, Tag, X } from 'lucide-react'
|
||||
import { useParams, usePathname, useRouter, useSearchParams } from 'next/navigation'
|
||||
import { usePostHog } from 'posthog-js/react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
@@ -25,12 +24,10 @@ import {
|
||||
import { Database, DatabaseX } from '@/components/emcn/icons'
|
||||
import { SearchHighlight } from '@/components/ui/search-highlight'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { ADD_CONNECTOR_SEARCH_PARAM } from '@/lib/credentials/client-state'
|
||||
import { ALL_TAG_SLOTS, type AllTagSlot, getFieldTypeForSlot } from '@/lib/knowledge/constants'
|
||||
import type { DocumentSortField, SortOrder } from '@/lib/knowledge/documents/types'
|
||||
import { type FilterFieldType, getOperatorsForFieldType } from '@/lib/knowledge/filters/types'
|
||||
import type { DocumentData } from '@/lib/knowledge/types'
|
||||
import { captureEvent } from '@/lib/posthog/client'
|
||||
import { formatFileSize } from '@/lib/uploads/utils/file-utils'
|
||||
import type {
|
||||
BreadcrumbItem,
|
||||
@@ -193,19 +190,6 @@ export function KnowledgeBase({
|
||||
}: KnowledgeBaseProps) {
|
||||
const params = useParams()
|
||||
const workspaceId = propWorkspaceId || (params.workspaceId as string)
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const pathname = usePathname()
|
||||
const addConnectorParam = searchParams.get(ADD_CONNECTOR_SEARCH_PARAM)
|
||||
const posthog = usePostHog()
|
||||
|
||||
useEffect(() => {
|
||||
captureEvent(posthog, 'knowledge_base_opened', {
|
||||
knowledge_base_id: id,
|
||||
knowledge_base_name: passedKnowledgeBaseName ?? 'Unknown',
|
||||
})
|
||||
}, [id, passedKnowledgeBaseName, posthog])
|
||||
|
||||
useOAuthReturnForKBConnectors(id)
|
||||
const { removeKnowledgeBase } = useKnowledgeBasesList(workspaceId, { enabled: false })
|
||||
const userPermissions = useUserPermissionsContext()
|
||||
@@ -283,29 +267,7 @@ export function KnowledgeBase({
|
||||
const [contextMenuDocument, setContextMenuDocument] = useState<DocumentData | null>(null)
|
||||
const [showRenameModal, setShowRenameModal] = useState(false)
|
||||
const [documentToRename, setDocumentToRename] = useState<DocumentData | null>(null)
|
||||
const showAddConnectorModal = addConnectorParam != null
|
||||
const searchParamsRef = useRef(searchParams)
|
||||
searchParamsRef.current = searchParams
|
||||
const updateAddConnectorParam = useCallback(
|
||||
(value: string | null) => {
|
||||
const current = searchParamsRef.current
|
||||
const currentValue = current.get(ADD_CONNECTOR_SEARCH_PARAM)
|
||||
if (value === currentValue || (value === null && currentValue === null)) return
|
||||
const next = new URLSearchParams(current.toString())
|
||||
if (value === null) {
|
||||
next.delete(ADD_CONNECTOR_SEARCH_PARAM)
|
||||
} else {
|
||||
next.set(ADD_CONNECTOR_SEARCH_PARAM, value)
|
||||
}
|
||||
const qs = next.toString()
|
||||
router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false })
|
||||
},
|
||||
[pathname, router]
|
||||
)
|
||||
const setShowAddConnectorModal = useCallback(
|
||||
(open: boolean) => updateAddConnectorParam(open ? '' : null),
|
||||
[updateAddConnectorParam]
|
||||
)
|
||||
const [showAddConnectorModal, setShowAddConnectorModal] = useState(false)
|
||||
|
||||
const {
|
||||
isOpen: isContextMenuOpen,
|
||||
@@ -367,6 +329,8 @@ export function KnowledgeBase({
|
||||
prevHadSyncingRef.current = hasSyncingConnectors
|
||||
}, [hasSyncingConnectors, refreshKnowledgeBase, refreshDocuments])
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const knowledgeBaseName = knowledgeBase?.name || passedKnowledgeBaseName || 'Knowledge Base'
|
||||
const error = knowledgeBaseError || documentsError
|
||||
|
||||
@@ -1279,13 +1243,7 @@ export function KnowledgeBase({
|
||||
/>
|
||||
|
||||
{showAddConnectorModal && (
|
||||
<AddConnectorModal
|
||||
open
|
||||
onOpenChange={setShowAddConnectorModal}
|
||||
onConnectorTypeChange={updateAddConnectorParam}
|
||||
knowledgeBaseId={id}
|
||||
initialConnectorType={addConnectorParam || undefined}
|
||||
/>
|
||||
<AddConnectorModal open onOpenChange={setShowAddConnectorModal} knowledgeBaseId={id} />
|
||||
)}
|
||||
|
||||
{documentToRename && (
|
||||
|
||||
@@ -19,44 +19,39 @@ import {
|
||||
ModalHeader,
|
||||
Tooltip,
|
||||
} from '@/components/emcn'
|
||||
import { getSubscriptionAccessState } from '@/lib/billing/client'
|
||||
import { consumeOAuthReturnContext } from '@/lib/credentials/client-state'
|
||||
import { getProviderIdFromServiceId, type OAuthProvider } from '@/lib/oauth'
|
||||
import { OAuthModal } from '@/app/workspace/[workspaceId]/components/oauth-modal'
|
||||
import { ConnectorSelectorField } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/components/connector-selector-field'
|
||||
import { SYNC_INTERVALS } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/consts'
|
||||
import { MaxBadge } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/max-badge'
|
||||
import { isBillingEnabled } from '@/app/workspace/[workspaceId]/settings/navigation'
|
||||
import { getDependsOnFields } from '@/blocks/utils'
|
||||
import { CONNECTOR_REGISTRY } from '@/connectors/registry'
|
||||
import type { ConnectorConfig, ConnectorConfigField } from '@/connectors/types'
|
||||
import { useCreateConnector } from '@/hooks/queries/kb/connectors'
|
||||
import { useOAuthCredentials } from '@/hooks/queries/oauth/oauth-credentials'
|
||||
import { useSubscriptionData } from '@/hooks/queries/subscription'
|
||||
import type { SelectorKey } from '@/hooks/selectors/types'
|
||||
import { useCredentialRefreshTriggers } from '@/hooks/use-credential-refresh-triggers'
|
||||
|
||||
const SYNC_INTERVALS = [
|
||||
{ label: 'Every hour', value: 60 },
|
||||
{ label: 'Every 6 hours', value: 360 },
|
||||
{ label: 'Daily', value: 1440 },
|
||||
{ label: 'Weekly', value: 10080 },
|
||||
{ label: 'Manual only', value: 0 },
|
||||
] as const
|
||||
|
||||
const CONNECTOR_ENTRIES = Object.entries(CONNECTOR_REGISTRY)
|
||||
|
||||
interface AddConnectorModalProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onConnectorTypeChange?: (connectorType: string | null) => void
|
||||
knowledgeBaseId: string
|
||||
initialConnectorType?: string | null
|
||||
}
|
||||
|
||||
type Step = 'select-type' | 'configure'
|
||||
|
||||
export function AddConnectorModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
onConnectorTypeChange,
|
||||
knowledgeBaseId,
|
||||
initialConnectorType,
|
||||
}: AddConnectorModalProps) {
|
||||
const [step, setStep] = useState<Step>(() => (initialConnectorType ? 'configure' : 'select-type'))
|
||||
const [selectedType, setSelectedType] = useState<string | null>(initialConnectorType ?? null)
|
||||
export function AddConnectorModal({ open, onOpenChange, knowledgeBaseId }: AddConnectorModalProps) {
|
||||
const [step, setStep] = useState<Step>('select-type')
|
||||
const [selectedType, setSelectedType] = useState<string | null>(null)
|
||||
const [sourceConfig, setSourceConfig] = useState<Record<string, string>>({})
|
||||
const [syncInterval, setSyncInterval] = useState(1440)
|
||||
const [selectedCredentialId, setSelectedCredentialId] = useState<string | null>(null)
|
||||
@@ -72,10 +67,6 @@ export function AddConnectorModal({
|
||||
const { workspaceId } = useParams<{ workspaceId: string }>()
|
||||
const { mutate: createConnector, isPending: isCreating } = useCreateConnector()
|
||||
|
||||
const { data: subscriptionResponse } = useSubscriptionData({ enabled: isBillingEnabled })
|
||||
const subscriptionAccess = getSubscriptionAccessState(subscriptionResponse?.data)
|
||||
const hasMaxAccess = !isBillingEnabled || subscriptionAccess.hasUsableMaxAccess
|
||||
|
||||
const connectorConfig = selectedType ? CONNECTOR_REGISTRY[selectedType] : null
|
||||
const isApiKeyMode = connectorConfig?.auth.mode === 'apiKey'
|
||||
const connectorProviderId = useMemo(
|
||||
@@ -160,7 +151,6 @@ export function AddConnectorModal({
|
||||
setError(null)
|
||||
setSearchTerm('')
|
||||
setStep('configure')
|
||||
onConnectorTypeChange?.(type)
|
||||
}
|
||||
|
||||
const handleFieldChange = useCallback(
|
||||
@@ -296,10 +286,7 @@ export function AddConnectorModal({
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='mr-2 h-6 w-6 p-0'
|
||||
onClick={() => {
|
||||
setStep('select-type')
|
||||
onConnectorTypeChange?.('')
|
||||
}}
|
||||
onClick={() => setStep('select-type')}
|
||||
>
|
||||
<ArrowLeft className='h-4 w-4' />
|
||||
</Button>
|
||||
@@ -529,13 +516,8 @@ export function AddConnectorModal({
|
||||
onValueChange={(val) => setSyncInterval(Number(val))}
|
||||
>
|
||||
{SYNC_INTERVALS.map((interval) => (
|
||||
<ButtonGroupItem
|
||||
key={interval.value}
|
||||
value={String(interval.value)}
|
||||
disabled={interval.requiresMax && !hasMaxAccess}
|
||||
>
|
||||
<ButtonGroupItem key={interval.value} value={String(interval.value)}>
|
||||
{interval.label}
|
||||
{interval.requiresMax && !hasMaxAccess && <MaxBadge />}
|
||||
</ButtonGroupItem>
|
||||
))}
|
||||
</ButtonGroup>
|
||||
@@ -583,7 +565,6 @@ export function AddConnectorModal({
|
||||
workspaceId={workspaceId}
|
||||
knowledgeBaseId={knowledgeBaseId}
|
||||
credentialCount={credentials.length}
|
||||
connectorType={selectedType ?? undefined}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -79,8 +79,6 @@ export function ConnectorSelectorField({
|
||||
options={comboboxOptions}
|
||||
value={value || undefined}
|
||||
onChange={onChange}
|
||||
searchable
|
||||
searchPlaceholder={`Search ${field.title.toLowerCase()}...`}
|
||||
placeholder={
|
||||
!credentialId
|
||||
? 'Connect an account first'
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
export const SYNC_INTERVALS = [
|
||||
{ label: 'Live', value: 5, requiresMax: true },
|
||||
{ label: 'Every hour', value: 60, requiresMax: false },
|
||||
{ label: 'Every 6 hours', value: 360, requiresMax: false },
|
||||
{ label: 'Daily', value: 1440, requiresMax: false },
|
||||
{ label: 'Weekly', value: 10080, requiresMax: false },
|
||||
{ label: 'Manual only', value: 0, requiresMax: false },
|
||||
] as const
|
||||
@@ -21,10 +21,6 @@ import {
|
||||
ModalTabsTrigger,
|
||||
Skeleton,
|
||||
} from '@/components/emcn'
|
||||
import { getSubscriptionAccessState } from '@/lib/billing/client'
|
||||
import { SYNC_INTERVALS } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/consts'
|
||||
import { MaxBadge } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/max-badge'
|
||||
import { isBillingEnabled } from '@/app/workspace/[workspaceId]/settings/navigation'
|
||||
import { CONNECTOR_REGISTRY } from '@/connectors/registry'
|
||||
import type { ConnectorConfig } from '@/connectors/types'
|
||||
import type { ConnectorData } from '@/hooks/queries/kb/connectors'
|
||||
@@ -34,10 +30,17 @@ import {
|
||||
useRestoreConnectorDocument,
|
||||
useUpdateConnector,
|
||||
} from '@/hooks/queries/kb/connectors'
|
||||
import { useSubscriptionData } from '@/hooks/queries/subscription'
|
||||
|
||||
const logger = createLogger('EditConnectorModal')
|
||||
|
||||
const SYNC_INTERVALS = [
|
||||
{ label: 'Every hour', value: 60 },
|
||||
{ label: 'Every 6 hours', value: 360 },
|
||||
{ label: 'Daily', value: 1440 },
|
||||
{ label: 'Weekly', value: 10080 },
|
||||
{ label: 'Manual only', value: 0 },
|
||||
] as const
|
||||
|
||||
/** Keys injected by the sync engine — not user-editable */
|
||||
const INTERNAL_CONFIG_KEYS = new Set(['tagSlotMapping', 'disabledTagIds'])
|
||||
|
||||
@@ -73,10 +76,6 @@ export function EditConnectorModal({
|
||||
|
||||
const { mutate: updateConnector, isPending: isSaving } = useUpdateConnector()
|
||||
|
||||
const { data: subscriptionResponse } = useSubscriptionData({ enabled: isBillingEnabled })
|
||||
const subscriptionAccess = getSubscriptionAccessState(subscriptionResponse?.data)
|
||||
const hasMaxAccess = !isBillingEnabled || subscriptionAccess.hasUsableMaxAccess
|
||||
|
||||
const hasChanges = useMemo(() => {
|
||||
if (syncInterval !== connector.syncIntervalMinutes) return true
|
||||
for (const [key, value] of Object.entries(sourceConfig)) {
|
||||
@@ -147,7 +146,6 @@ export function EditConnectorModal({
|
||||
setSourceConfig={setSourceConfig}
|
||||
syncInterval={syncInterval}
|
||||
setSyncInterval={setSyncInterval}
|
||||
hasMaxAccess={hasMaxAccess}
|
||||
error={error}
|
||||
/>
|
||||
</ModalTabsContent>
|
||||
@@ -186,7 +184,6 @@ interface SettingsTabProps {
|
||||
setSourceConfig: React.Dispatch<React.SetStateAction<Record<string, string>>>
|
||||
syncInterval: number
|
||||
setSyncInterval: (v: number) => void
|
||||
hasMaxAccess: boolean
|
||||
error: string | null
|
||||
}
|
||||
|
||||
@@ -196,7 +193,6 @@ function SettingsTab({
|
||||
setSourceConfig,
|
||||
syncInterval,
|
||||
setSyncInterval,
|
||||
hasMaxAccess,
|
||||
error,
|
||||
}: SettingsTabProps) {
|
||||
return (
|
||||
@@ -238,13 +234,8 @@ function SettingsTab({
|
||||
onValueChange={(val) => setSyncInterval(Number(val))}
|
||||
>
|
||||
{SYNC_INTERVALS.map((interval) => (
|
||||
<ButtonGroupItem
|
||||
key={interval.value}
|
||||
value={String(interval.value)}
|
||||
disabled={interval.requiresMax && !hasMaxAccess}
|
||||
>
|
||||
<ButtonGroupItem key={interval.value} value={String(interval.value)}>
|
||||
{interval.label}
|
||||
{interval.requiresMax && !hasMaxAccess && <MaxBadge />}
|
||||
</ButtonGroupItem>
|
||||
))}
|
||||
</ButtonGroup>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user