Skip to content

Commit cf233bb

Browse files
authored
v0.6.31: elevenlabs voice, trigger.dev fixes, cloud whitelabeling for enterprises
2 parents d7da35b + 4700590 commit cf233bb

File tree

71 files changed

+31958
-641
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

71 files changed

+31958
-641
lines changed

apps/docs/content/docs/en/execution/costs.mdx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,21 @@ Use your own API keys for AI model providers instead of Sim's hosted keys to pay
135135

136136
When configured, workflows use your key instead of Sim's hosted keys. If removed, workflows automatically fall back to hosted keys with the multiplier.
137137

138+
## Voice Input
139+
140+
Voice input uses ElevenLabs Scribe v2 Realtime for speech-to-text transcription. It is available in the Mothership chat and in deployed chat voice mode.
141+
142+
| Context | Cost per session | Max duration |
143+
|---------|-----------------|--------------|
144+
| Mothership (workspace) | ~5 credits ($0.024) | 3 minutes |
145+
| Deployed chat (voice mode) | ~2 credits ($0.008) | 1 minute |
146+
147+
Each voice session is billed when it starts. In deployed chat voice mode, each conversation turn (speak → agent responds → speak again) is a separate session. Multi-turn conversations are billed per turn.
148+
149+
<Callout type="info">
150+
Voice input requires `ELEVENLABS_API_KEY` to be configured. When the key is not set, voice input controls are hidden.
151+
</Callout>
152+
138153
## Plans
139154

140155
Sim has two paid plan tiers — **Pro** and **Max**. Either can be used individually or with a team. Team plans pool credits across all seats in the organization.

apps/sim/app/_styles/globals.css

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,7 @@ html[data-sidebar-collapsed] .sidebar-container .sidebar-collapse-btn {
220220
/* Brand & state */
221221
--brand-secondary: #33b4ff;
222222
--brand-accent: #33c482;
223+
--brand-accent-hover: #2dac72;
223224
--selection: #1a5cf6;
224225
--warning: #ea580c;
225226

@@ -375,6 +376,7 @@ html[data-sidebar-collapsed] .sidebar-container .sidebar-collapse-btn {
375376
/* Brand & state */
376377
--brand-secondary: #33b4ff;
377378
--brand-accent: #33c482;
379+
--brand-accent-hover: #2dac72;
378380
--selection: #4b83f7;
379381
--warning: #ff6600;
380382

apps/sim/app/api/a2a/serve/[agentId]/route.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
import { type AuthResult, AuthType, checkHybridAuth } from '@/lib/auth/hybrid'
1616
import { acquireLock, getRedisClient, releaseLock } from '@/lib/core/config/redis'
1717
import { validateUrlWithDNS } from '@/lib/core/security/input-validation.server'
18+
import { getClientIp } from '@/lib/core/utils/request'
1819
import { SSE_HEADERS } from '@/lib/core/utils/sse'
1920
import { getBaseUrl } from '@/lib/core/utils/urls'
2021
import { generateId } from '@/lib/core/utils/uuid'
@@ -52,10 +53,9 @@ function getCallerFingerprint(request: NextRequest, userId?: string | null): str
5253
return `user:${userId}`
5354
}
5455

55-
const forwardedFor = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
56-
const realIp = request.headers.get('x-real-ip')?.trim()
56+
const clientIp = getClientIp(request)
5757
const userAgent = request.headers.get('user-agent')?.trim() || 'unknown'
58-
return `public:${forwardedFor || realIp || 'unknown'}:${userAgent}`
58+
return `public:${clientIp}:${userAgent}`
5959
}
6060

6161
function hasCallerAccessToTask(

apps/sim/app/api/auth/socket-token/route.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1+
import { createLogger } from '@sim/logger'
12
import { headers } from 'next/headers'
23
import { NextResponse } from 'next/server'
34
import { auth } from '@/lib/auth'
45
import { isAuthDisabled } from '@/lib/core/config/feature-flags'
56

7+
const logger = createLogger('SocketTokenAPI')
8+
69
export async function POST() {
710
if (isAuthDisabled) {
811
return NextResponse.json({ token: 'anonymous-socket-token' })
@@ -19,7 +22,11 @@ export async function POST() {
1922
}
2023

2124
return NextResponse.json({ token: response.token })
22-
} catch {
25+
} catch (error) {
26+
logger.error('Failed to generate socket token', {
27+
error: error instanceof Error ? error.message : String(error),
28+
stack: error instanceof Error ? error.stack : undefined,
29+
})
2330
return NextResponse.json({ error: 'Failed to generate token' }, { status: 500 })
2431
}
2532
}

apps/sim/app/api/demo-requests/route.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server'
33
import { env } from '@/lib/core/config/env'
44
import type { TokenBucketConfig } from '@/lib/core/rate-limiter'
55
import { RateLimiter } from '@/lib/core/rate-limiter'
6-
import { generateRequestId } from '@/lib/core/utils/request'
6+
import { generateRequestId, getClientIp } from '@/lib/core/utils/request'
77
import { getEmailDomain } from '@/lib/core/utils/urls'
88
import { sendEmail } from '@/lib/messaging/email/mailer'
99
import { getFromEmailAddress } from '@/lib/messaging/email/utils'
@@ -25,7 +25,7 @@ export async function POST(req: NextRequest) {
2525
const requestId = generateRequestId()
2626

2727
try {
28-
const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown'
28+
const ip = getClientIp(req)
2929
const storageKey = `public:demo-request:${ip}`
3030

3131
const { allowed, remaining, resetAt } = await rateLimiter.checkRateLimitDirect(

apps/sim/app/api/help/integration-request/route.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { z } from 'zod'
44
import { env } from '@/lib/core/config/env'
55
import type { TokenBucketConfig } from '@/lib/core/rate-limiter'
66
import { RateLimiter } from '@/lib/core/rate-limiter'
7-
import { generateRequestId } from '@/lib/core/utils/request'
7+
import { generateRequestId, getClientIp } from '@/lib/core/utils/request'
88
import { getEmailDomain } from '@/lib/core/utils/urls'
99
import { sendEmail } from '@/lib/messaging/email/mailer'
1010
import {
@@ -37,7 +37,7 @@ export async function POST(req: NextRequest) {
3737
const requestId = generateRequestId()
3838

3939
try {
40-
const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown'
40+
const ip = getClientIp(req)
4141
const storageKey = `public:integration-request:${ip}`
4242

4343
const { allowed, remaining, resetAt } = await rateLimiter.checkRateLimitDirect(

apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,13 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
222222
}
223223
if (parsed.data.status !== undefined) {
224224
updates.status = parsed.data.status
225+
if (parsed.data.status === 'active') {
226+
updates.consecutiveFailures = 0
227+
updates.lastSyncError = null
228+
if (updates.nextSyncAt === undefined) {
229+
updates.nextSyncAt = new Date()
230+
}
231+
}
225232
}
226233

227234
await db
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
import { db } from '@sim/db'
2+
import { member, organization } from '@sim/db/schema'
3+
import { createLogger } from '@sim/logger'
4+
import { and, eq } from 'drizzle-orm'
5+
import { type NextRequest, NextResponse } from 'next/server'
6+
import { z } from 'zod'
7+
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
8+
import { getSession } from '@/lib/auth'
9+
import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription'
10+
import { HEX_COLOR_REGEX } from '@/lib/branding'
11+
import type { OrganizationWhitelabelSettings } from '@/lib/branding/types'
12+
13+
const logger = createLogger('WhitelabelAPI')
14+
15+
const updateWhitelabelSchema = z.object({
16+
brandName: z
17+
.string()
18+
.trim()
19+
.max(64, 'Brand name must be 64 characters or fewer')
20+
.nullable()
21+
.optional(),
22+
logoUrl: z.string().min(1).nullable().optional(),
23+
wordmarkUrl: z.string().min(1).nullable().optional(),
24+
primaryColor: z
25+
.string()
26+
.regex(HEX_COLOR_REGEX, 'Primary color must be a valid hex color (e.g. #701ffc)')
27+
.nullable()
28+
.optional(),
29+
primaryHoverColor: z
30+
.string()
31+
.regex(HEX_COLOR_REGEX, 'Primary hover color must be a valid hex color')
32+
.nullable()
33+
.optional(),
34+
accentColor: z
35+
.string()
36+
.regex(HEX_COLOR_REGEX, 'Accent color must be a valid hex color')
37+
.nullable()
38+
.optional(),
39+
accentHoverColor: z
40+
.string()
41+
.regex(HEX_COLOR_REGEX, 'Accent hover color must be a valid hex color')
42+
.nullable()
43+
.optional(),
44+
supportEmail: z
45+
.string()
46+
.email('Support email must be a valid email address')
47+
.nullable()
48+
.optional(),
49+
documentationUrl: z.string().url('Documentation URL must be a valid URL').nullable().optional(),
50+
termsUrl: z.string().url('Terms URL must be a valid URL').nullable().optional(),
51+
privacyUrl: z.string().url('Privacy URL must be a valid URL').nullable().optional(),
52+
hidePoweredBySim: z.boolean().optional(),
53+
})
54+
55+
/**
56+
* GET /api/organizations/[id]/whitelabel
57+
* Returns the organization's whitelabel settings.
58+
* Accessible by any member of the organization.
59+
*/
60+
export async function GET(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
61+
try {
62+
const session = await getSession()
63+
64+
if (!session?.user?.id) {
65+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
66+
}
67+
68+
const { id: organizationId } = await params
69+
70+
const [memberEntry] = await db
71+
.select({ id: member.id })
72+
.from(member)
73+
.where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id)))
74+
.limit(1)
75+
76+
if (!memberEntry) {
77+
return NextResponse.json(
78+
{ error: 'Forbidden - Not a member of this organization' },
79+
{ status: 403 }
80+
)
81+
}
82+
83+
const [org] = await db
84+
.select({ whitelabelSettings: organization.whitelabelSettings })
85+
.from(organization)
86+
.where(eq(organization.id, organizationId))
87+
.limit(1)
88+
89+
if (!org) {
90+
return NextResponse.json({ error: 'Organization not found' }, { status: 404 })
91+
}
92+
93+
return NextResponse.json({
94+
success: true,
95+
data: (org.whitelabelSettings ?? {}) as OrganizationWhitelabelSettings,
96+
})
97+
} catch (error) {
98+
logger.error('Failed to get whitelabel settings', { error })
99+
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
100+
}
101+
}
102+
103+
/**
104+
* PUT /api/organizations/[id]/whitelabel
105+
* Updates the organization's whitelabel settings.
106+
* Requires enterprise plan and owner/admin role.
107+
*/
108+
export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
109+
try {
110+
const session = await getSession()
111+
112+
if (!session?.user?.id) {
113+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
114+
}
115+
116+
const { id: organizationId } = await params
117+
118+
const body = await request.json()
119+
const parsed = updateWhitelabelSchema.safeParse(body)
120+
121+
if (!parsed.success) {
122+
return NextResponse.json(
123+
{ error: parsed.error.errors[0]?.message ?? 'Invalid request body' },
124+
{ status: 400 }
125+
)
126+
}
127+
128+
const [memberEntry] = await db
129+
.select({ role: member.role })
130+
.from(member)
131+
.where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id)))
132+
.limit(1)
133+
134+
if (!memberEntry) {
135+
return NextResponse.json(
136+
{ error: 'Forbidden - Not a member of this organization' },
137+
{ status: 403 }
138+
)
139+
}
140+
141+
if (memberEntry.role !== 'owner' && memberEntry.role !== 'admin') {
142+
return NextResponse.json(
143+
{ error: 'Forbidden - Only organization owners and admins can update whitelabel settings' },
144+
{ status: 403 }
145+
)
146+
}
147+
148+
const hasEnterprisePlan = await isOrganizationOnEnterprisePlan(organizationId)
149+
150+
if (!hasEnterprisePlan) {
151+
return NextResponse.json(
152+
{ error: 'Whitelabeling is available on Enterprise plans only' },
153+
{ status: 403 }
154+
)
155+
}
156+
157+
const [currentOrg] = await db
158+
.select({ name: organization.name, whitelabelSettings: organization.whitelabelSettings })
159+
.from(organization)
160+
.where(eq(organization.id, organizationId))
161+
.limit(1)
162+
163+
if (!currentOrg) {
164+
return NextResponse.json({ error: 'Organization not found' }, { status: 404 })
165+
}
166+
167+
const current: OrganizationWhitelabelSettings = currentOrg.whitelabelSettings ?? {}
168+
const incoming = parsed.data
169+
170+
const merged: OrganizationWhitelabelSettings = { ...current }
171+
172+
for (const key of Object.keys(incoming) as Array<keyof typeof incoming>) {
173+
const value = incoming[key]
174+
if (value === null) {
175+
delete merged[key as keyof OrganizationWhitelabelSettings]
176+
} else if (value !== undefined) {
177+
;(merged as Record<string, unknown>)[key] = value
178+
}
179+
}
180+
181+
const [updated] = await db
182+
.update(organization)
183+
.set({ whitelabelSettings: merged, updatedAt: new Date() })
184+
.where(eq(organization.id, organizationId))
185+
.returning({ whitelabelSettings: organization.whitelabelSettings })
186+
187+
if (!updated) {
188+
return NextResponse.json({ error: 'Organization not found' }, { status: 404 })
189+
}
190+
191+
recordAudit({
192+
workspaceId: null,
193+
actorId: session.user.id,
194+
action: AuditAction.ORGANIZATION_UPDATED,
195+
resourceType: AuditResourceType.ORGANIZATION,
196+
resourceId: organizationId,
197+
actorName: session.user.name ?? undefined,
198+
actorEmail: session.user.email ?? undefined,
199+
resourceName: currentOrg.name,
200+
description: 'Updated organization whitelabel settings',
201+
metadata: { changes: Object.keys(incoming) },
202+
request,
203+
})
204+
205+
return NextResponse.json({
206+
success: true,
207+
data: (updated.whitelabelSettings ?? {}) as OrganizationWhitelabelSettings,
208+
})
209+
} catch (error) {
210+
logger.error('Failed to update whitelabel settings', { error })
211+
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
212+
}
213+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { NextResponse } from 'next/server'
2+
import { hasSTTService } from '@/lib/speech/config'
3+
4+
/**
5+
* Returns whether server-side STT is configured.
6+
* Unauthenticated — the response is a single boolean,
7+
* not sensitive data, and deployed chat visitors need it.
8+
*/
9+
export async function GET() {
10+
return NextResponse.json({ sttAvailable: hasSTTService() })
11+
}

0 commit comments

Comments
 (0)