v0.6.31: elevenlabs voice, trigger.dev fixes, cloud whitelabeling for enterprises

This commit is contained in:
Waleed
2026-04-08 12:57:13 -07:00
committed by GitHub
71 changed files with 31957 additions and 640 deletions

View File

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

View File

@@ -220,6 +220,7 @@ html[data-sidebar-collapsed] .sidebar-container .sidebar-collapse-btn {
/* Brand & state */
--brand-secondary: #33b4ff;
--brand-accent: #33c482;
--brand-accent-hover: #2dac72;
--selection: #1a5cf6;
--warning: #ea580c;
@@ -375,6 +376,7 @@ html[data-sidebar-collapsed] .sidebar-container .sidebar-collapse-btn {
/* Brand & state */
--brand-secondary: #33b4ff;
--brand-accent: #33c482;
--brand-accent-hover: #2dac72;
--selection: #4b83f7;
--warning: #ff6600;

View File

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

View File

@@ -1,8 +1,11 @@
import { createLogger } from '@sim/logger'
import { headers } from 'next/headers'
import { NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
import { isAuthDisabled } from '@/lib/core/config/feature-flags'
const logger = createLogger('SocketTokenAPI')
export async function POST() {
if (isAuthDisabled) {
return NextResponse.json({ token: 'anonymous-socket-token' })
@@ -19,7 +22,11 @@ export async function POST() {
}
return NextResponse.json({ token: response.token })
} catch {
} catch (error) {
logger.error('Failed to generate socket token', {
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
})
return NextResponse.json({ error: 'Failed to generate token' }, { status: 500 })
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,213 @@
import { db } from '@sim/db'
import { member, organization } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
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 { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription'
import { HEX_COLOR_REGEX } from '@/lib/branding'
import type { OrganizationWhitelabelSettings } from '@/lib/branding/types'
const logger = createLogger('WhitelabelAPI')
const updateWhitelabelSchema = z.object({
brandName: z
.string()
.trim()
.max(64, 'Brand name must be 64 characters or fewer')
.nullable()
.optional(),
logoUrl: z.string().min(1).nullable().optional(),
wordmarkUrl: z.string().min(1).nullable().optional(),
primaryColor: z
.string()
.regex(HEX_COLOR_REGEX, 'Primary color must be a valid hex color (e.g. #701ffc)')
.nullable()
.optional(),
primaryHoverColor: z
.string()
.regex(HEX_COLOR_REGEX, 'Primary hover color must be a valid hex color')
.nullable()
.optional(),
accentColor: z
.string()
.regex(HEX_COLOR_REGEX, 'Accent color must be a valid hex color')
.nullable()
.optional(),
accentHoverColor: z
.string()
.regex(HEX_COLOR_REGEX, 'Accent hover color must be a valid hex color')
.nullable()
.optional(),
supportEmail: z
.string()
.email('Support email must be a valid email address')
.nullable()
.optional(),
documentationUrl: z.string().url('Documentation URL must be a valid URL').nullable().optional(),
termsUrl: z.string().url('Terms URL must be a valid URL').nullable().optional(),
privacyUrl: z.string().url('Privacy URL must be a valid URL').nullable().optional(),
hidePoweredBySim: z.boolean().optional(),
})
/**
* GET /api/organizations/[id]/whitelabel
* Returns the organization's whitelabel settings.
* Accessible by any member of the organization.
*/
export async function GET(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id: organizationId } = await params
const [memberEntry] = await db
.select({ id: member.id })
.from(member)
.where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id)))
.limit(1)
if (!memberEntry) {
return NextResponse.json(
{ error: 'Forbidden - Not a member of this organization' },
{ status: 403 }
)
}
const [org] = await db
.select({ whitelabelSettings: organization.whitelabelSettings })
.from(organization)
.where(eq(organization.id, organizationId))
.limit(1)
if (!org) {
return NextResponse.json({ error: 'Organization not found' }, { status: 404 })
}
return NextResponse.json({
success: true,
data: (org.whitelabelSettings ?? {}) as OrganizationWhitelabelSettings,
})
} catch (error) {
logger.error('Failed to get whitelabel settings', { error })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
/**
* PUT /api/organizations/[id]/whitelabel
* Updates the organization's whitelabel settings.
* Requires enterprise plan and owner/admin role.
*/
export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id: organizationId } = await params
const body = await request.json()
const parsed = updateWhitelabelSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.errors[0]?.message ?? 'Invalid request body' },
{ status: 400 }
)
}
const [memberEntry] = await db
.select({ role: member.role })
.from(member)
.where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id)))
.limit(1)
if (!memberEntry) {
return NextResponse.json(
{ error: 'Forbidden - Not a member of this organization' },
{ status: 403 }
)
}
if (memberEntry.role !== 'owner' && memberEntry.role !== 'admin') {
return NextResponse.json(
{ error: 'Forbidden - Only organization owners and admins can update whitelabel settings' },
{ status: 403 }
)
}
const hasEnterprisePlan = await isOrganizationOnEnterprisePlan(organizationId)
if (!hasEnterprisePlan) {
return NextResponse.json(
{ error: 'Whitelabeling is available on Enterprise plans only' },
{ status: 403 }
)
}
const [currentOrg] = await db
.select({ name: organization.name, whitelabelSettings: organization.whitelabelSettings })
.from(organization)
.where(eq(organization.id, organizationId))
.limit(1)
if (!currentOrg) {
return NextResponse.json({ error: 'Organization not found' }, { status: 404 })
}
const current: OrganizationWhitelabelSettings = currentOrg.whitelabelSettings ?? {}
const incoming = parsed.data
const merged: OrganizationWhitelabelSettings = { ...current }
for (const key of Object.keys(incoming) as Array<keyof typeof incoming>) {
const value = incoming[key]
if (value === null) {
delete merged[key as keyof OrganizationWhitelabelSettings]
} else if (value !== undefined) {
;(merged as Record<string, unknown>)[key] = value
}
}
const [updated] = await db
.update(organization)
.set({ whitelabelSettings: merged, updatedAt: new Date() })
.where(eq(organization.id, organizationId))
.returning({ whitelabelSettings: organization.whitelabelSettings })
if (!updated) {
return NextResponse.json({ error: 'Organization not found' }, { status: 404 })
}
recordAudit({
workspaceId: null,
actorId: session.user.id,
action: AuditAction.ORGANIZATION_UPDATED,
resourceType: AuditResourceType.ORGANIZATION,
resourceId: organizationId,
actorName: session.user.name ?? undefined,
actorEmail: session.user.email ?? undefined,
resourceName: currentOrg.name,
description: 'Updated organization whitelabel settings',
metadata: { changes: Object.keys(incoming) },
request,
})
return NextResponse.json({
success: true,
data: (updated.whitelabelSettings ?? {}) as OrganizationWhitelabelSettings,
})
} catch (error) {
logger.error('Failed to update whitelabel settings', { error })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,13 +9,7 @@ import { cn } from '@/lib/core/utils/cn'
import { CHAT_ACCEPT_ATTRIBUTE } from '@/lib/uploads/utils/validation'
import { ContextMentionIcon } from '@/app/workspace/[workspaceId]/home/components/context-mention-icon'
import { useAvailableResources } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown'
import type {
PlusMenuHandle,
SpeechRecognitionErrorEvent,
SpeechRecognitionEvent,
SpeechRecognitionInstance,
WindowWithSpeech,
} from '@/app/workspace/[workspaceId]/home/components/user-input/components'
import type { PlusMenuHandle } from '@/app/workspace/[workspaceId]/home/components/user-input/components'
import {
AnimatedPlaceholderEffect,
AttachedFilesList,
@@ -27,7 +21,6 @@ import {
OVERLAY_CLASSES,
PlusMenuDropdown,
SendButton,
SPEECH_RECOGNITION_LANG,
TEXTAREA_BASE_CLASSES,
} from '@/app/workspace/[workspaceId]/home/components/user-input/components'
import type {
@@ -46,6 +39,7 @@ import {
extractContextTokens,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils'
import { useWorkflowMap } from '@/hooks/queries/workflows'
import { useSpeechToText } from '@/hooks/use-speech-to-text'
import type { ChatContext } from '@/stores/panel'
export type { FileAttachmentForApi } from '@/app/workspace/[workspaceId]/home/types'
@@ -235,10 +229,29 @@ export function UserInput({
const canSubmit = (value.trim().length > 0 || hasFiles) && !isSending
const [isListening, setIsListening] = useState(false)
const recognitionRef = useRef<SpeechRecognitionInstance | null>(null)
const prefixRef = useRef('')
const valueRef = useRef(value)
const sttPrefixRef = useRef('')
const handleTranscript = useCallback((text: string) => {
const prefix = sttPrefixRef.current
const newVal = prefix ? `${prefix} ${text}` : text
setValue(newVal)
valueRef.current = newVal
}, [])
const {
isListening,
isSupported: isSttSupported,
toggleListening: rawToggle,
resetTranscript,
} = useSpeechToText({ onTranscript: handleTranscript })
const toggleListening = useCallback(() => {
if (!isListening) {
sttPrefixRef.current = valueRef.current
}
rawToggle()
}, [isListening, rawToggle])
const filesRef = useRef(files)
filesRef.current = files
@@ -249,12 +262,6 @@ export function UserInput({
const isSendingRef = useRef(isSending)
isSendingRef.current = isSending
useEffect(() => {
return () => {
recognitionRef.current?.abort()
}
}, [])
useEffect(() => {
valueRef.current = value
}, [value])
@@ -404,84 +411,6 @@ export function UserInput({
[textareaRef]
)
const startRecognition = useCallback((): boolean => {
const w = window as WindowWithSpeech
const SpeechRecognitionAPI = w.SpeechRecognition || w.webkitSpeechRecognition
if (!SpeechRecognitionAPI) return false
const recognition = new SpeechRecognitionAPI()
recognition.continuous = true
recognition.interimResults = true
recognition.lang = SPEECH_RECOGNITION_LANG
recognition.onresult = (event: SpeechRecognitionEvent) => {
let transcript = ''
for (let i = 0; i < event.results.length; i++) {
transcript += event.results[i][0].transcript
}
const prefix = prefixRef.current
const newVal = prefix ? `${prefix} ${transcript}` : transcript
setValue(newVal)
valueRef.current = newVal
}
recognition.onend = () => {
if (recognitionRef.current === recognition) {
prefixRef.current = valueRef.current
try {
recognition.start()
} catch {
recognitionRef.current = null
setIsListening(false)
}
}
}
recognition.onerror = (e: SpeechRecognitionErrorEvent) => {
if (recognitionRef.current !== recognition) return
if (e.error === 'aborted' || e.error === 'not-allowed') {
recognitionRef.current = null
setIsListening(false)
}
}
recognitionRef.current = recognition
try {
recognition.start()
return true
} catch {
recognitionRef.current = null
return false
}
}, [])
const restartRecognition = useCallback(
(newPrefix: string) => {
if (!recognitionRef.current) return
prefixRef.current = newPrefix
recognitionRef.current.abort()
recognitionRef.current = null
if (!startRecognition()) {
setIsListening(false)
}
},
[startRecognition]
)
const toggleListening = useCallback(() => {
if (isListening) {
recognitionRef.current?.stop()
recognitionRef.current = null
setIsListening(false)
return
}
prefixRef.current = valueRef.current
if (startRecognition()) {
setIsListening(true)
}
}, [isListening, startRecognition])
const handleSubmit = useCallback(() => {
const currentFiles = filesRef.current
const currentContext = contextRef.current
@@ -503,14 +432,16 @@ export function UserInput({
currentContext.selectedContexts.length > 0 ? currentContext.selectedContexts : undefined
)
setValue('')
restartRecognition('')
valueRef.current = ''
sttPrefixRef.current = ''
resetTranscript()
currentFiles.clearAttachedFiles()
currentContext.clearContexts()
if (textareaRef.current) {
textareaRef.current.style.height = 'auto'
}
}, [onSubmit, restartRecognition, textareaRef])
}, [onSubmit, textareaRef, resetTranscript])
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
@@ -589,32 +520,27 @@ export function UserInput({
[handleSubmit, mentionTokensWithContext, value, textareaRef]
)
const handleInputChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newValue = e.target.value
const caret = e.target.selectionStart ?? newValue.length
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newValue = e.target.value
const caret = e.target.selectionStart ?? newValue.length
if (
caret > 0 &&
newValue.charAt(caret - 1) === '@' &&
(caret === 1 || /\s/.test(newValue.charAt(caret - 2)))
) {
const before = newValue.slice(0, caret - 1)
const after = newValue.slice(caret)
const adjusted = `${before}${after}`
setValue(adjusted)
atInsertPosRef.current = caret - 1
const anchor = getCaretAnchor(e.target, caret - 1)
plusMenuRef.current?.open(anchor)
restartRecognition(adjusted)
return
}
if (
caret > 0 &&
newValue.charAt(caret - 1) === '@' &&
(caret === 1 || /\s/.test(newValue.charAt(caret - 2)))
) {
const before = newValue.slice(0, caret - 1)
const after = newValue.slice(caret)
const adjusted = `${before}${after}`
setValue(adjusted)
atInsertPosRef.current = caret - 1
const anchor = getCaretAnchor(e.target, caret - 1)
plusMenuRef.current?.open(anchor)
return
}
setValue(newValue)
restartRecognition(newValue)
},
[restartRecognition]
)
setValue(newValue)
}, [])
const handleSelectAdjust = useCallback(() => {
const textarea = textareaRef.current
@@ -803,7 +729,7 @@ export function UserInput({
/>
</div>
<div className='flex items-center gap-1.5'>
<MicButton isListening={isListening} onToggle={toggleListening} />
{isSttSupported && <MicButton isListening={isListening} onToggle={toggleListening} />}
<SendButton
isSending={isSending}
canSubmit={canSubmit}

View File

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

View File

@@ -1,3 +1,4 @@
import { cookies } from 'next/headers'
import { ToastProvider } from '@/components/emcn'
import { NavTour } from '@/app/workspace/[workspaceId]/components/product-tour'
import { ImpersonationBanner } from '@/app/workspace/[workspaceId]/impersonation-banner'
@@ -7,31 +8,45 @@ import { SettingsLoader } from '@/app/workspace/[workspaceId]/providers/settings
import { WorkspacePermissionsProvider } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { WorkspaceScopeSync } from '@/app/workspace/[workspaceId]/providers/workspace-scope-sync'
import { Sidebar } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar'
import {
BRAND_COOKIE_NAME,
type BrandCache,
BrandingProvider,
} from '@/ee/whitelabeling/components/branding-provider'
export default async function WorkspaceLayout({ children }: { children: React.ReactNode }) {
const cookieStore = await cookies()
let initialCache: BrandCache | null = null
try {
const raw = cookieStore.get(BRAND_COOKIE_NAME)?.value
if (raw) initialCache = JSON.parse(decodeURIComponent(raw))
} catch {}
export default function WorkspaceLayout({ children }: { children: React.ReactNode }) {
return (
<ToastProvider>
<SettingsLoader />
<ProviderModelsLoader />
<GlobalCommandsProvider>
<div className='flex h-screen w-full flex-col overflow-hidden bg-[var(--surface-1)]'>
<ImpersonationBanner />
<WorkspacePermissionsProvider>
<WorkspaceScopeSync />
<div className='flex min-h-0 flex-1'>
<div className='shrink-0' suppressHydrationWarning>
<Sidebar />
</div>
<div className='flex min-w-0 flex-1 flex-col p-[8px] pl-0'>
<div className='flex-1 overflow-hidden rounded-[8px] border border-[var(--border)] bg-[var(--bg)]'>
{children}
<BrandingProvider initialCache={initialCache}>
<ToastProvider>
<SettingsLoader />
<ProviderModelsLoader />
<GlobalCommandsProvider>
<div className='flex h-screen w-full flex-col overflow-hidden bg-[var(--surface-1)]'>
<ImpersonationBanner />
<WorkspacePermissionsProvider>
<WorkspaceScopeSync />
<div className='flex min-h-0 flex-1'>
<div className='shrink-0' suppressHydrationWarning>
<Sidebar />
</div>
<div className='flex min-w-0 flex-1 flex-col p-[8px] pl-0'>
<div className='flex-1 overflow-hidden rounded-[8px] border border-[var(--border)] bg-[var(--bg)]'>
{children}
</div>
</div>
</div>
</div>
<NavTour />
</WorkspacePermissionsProvider>
</div>
</GlobalCommandsProvider>
</ToastProvider>
<NavTour />
</WorkspacePermissionsProvider>
</div>
</GlobalCommandsProvider>
</ToastProvider>
</BrandingProvider>
)
}

View File

@@ -156,6 +156,13 @@ const AccessControl = dynamic(
const SSO = dynamic(() => import('@/ee/sso/components/sso-settings').then((m) => m.SSO), {
loading: () => <SettingsSectionSkeleton />,
})
const WhitelabelingSettings = dynamic(
() =>
import('@/ee/whitelabeling/components/whitelabeling-settings').then(
(m) => m.WhitelabelingSettings
),
{ loading: () => <SettingsSectionSkeleton /> }
)
interface SettingsPageProps {
section: SettingsSection
@@ -198,6 +205,7 @@ export function SettingsPage({ section }: SettingsPageProps) {
{isBillingEnabled && effectiveSection === 'subscription' && <Subscription />}
{isBillingEnabled && effectiveSection === 'team' && <TeamManagement />}
{effectiveSection === 'sso' && <SSO />}
{effectiveSection === 'whitelabeling' && <WhitelabelingSettings />}
{effectiveSection === 'byok' && <BYOK />}
{effectiveSection === 'copilot' && <Copilot />}
{effectiveSection === 'mcp' && <MCP initialServerId={mcpServerId} />}

View File

@@ -75,52 +75,59 @@ export function useProfilePictureUpload({
}
}, [])
const handleFileChange = useCallback(
async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (file) {
const validationError = validateFile(file)
if (validationError) {
onError?.(validationError)
return
}
const processFile = useCallback(
async (file: File) => {
const validationError = validateFile(file)
if (validationError) {
onError?.(validationError)
return
}
setFileName(file.name)
setFileName(file.name)
const newPreviewUrl = URL.createObjectURL(file)
const newPreviewUrl = URL.createObjectURL(file)
if (previewRef.current) URL.revokeObjectURL(previewRef.current)
setPreviewUrl(newPreviewUrl)
previewRef.current = newPreviewUrl
if (previewRef.current) {
URL.revokeObjectURL(previewRef.current)
}
setPreviewUrl(newPreviewUrl)
previewRef.current = newPreviewUrl
setIsUploading(true)
try {
const serverUrl = await uploadFileToServer(file)
URL.revokeObjectURL(newPreviewUrl)
previewRef.current = null
setPreviewUrl(serverUrl)
onUpload?.(serverUrl)
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Failed to upload profile picture'
onError?.(errorMessage)
URL.revokeObjectURL(newPreviewUrl)
previewRef.current = null
setPreviewUrl(currentImage || null)
} finally {
setIsUploading(false)
}
setIsUploading(true)
try {
const serverUrl = await uploadFileToServer(file)
URL.revokeObjectURL(newPreviewUrl)
previewRef.current = null
setPreviewUrl(serverUrl)
onUpload?.(serverUrl)
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Failed to upload profile picture'
onError?.(errorMessage)
URL.revokeObjectURL(newPreviewUrl)
previewRef.current = null
setPreviewUrl(currentImage || null)
} finally {
setIsUploading(false)
}
},
[onUpload, onError, uploadFileToServer, validateFile, currentImage]
)
const handleFileChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (file) processFile(file)
},
[processFile]
)
const handleFileDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault()
const file = e.dataTransfer.files[0]
if (file) processFile(file)
},
[processFile]
)
const handleRemove = useCallback(() => {
if (previewRef.current) {
URL.revokeObjectURL(previewRef.current)
@@ -148,6 +155,7 @@ export function useProfilePictureUpload({
fileInputRef,
handleThumbnailClick,
handleFileChange,
handleFileDrop,
handleRemove,
isUploading,
}

View File

@@ -7,6 +7,7 @@ import {
Lock,
LogIn,
Mail,
Palette,
Send,
Server,
Settings,
@@ -31,6 +32,7 @@ export type SettingsSection =
| 'subscription'
| 'team'
| 'sso'
| 'whitelabeling'
| 'copilot'
| 'mcp'
| 'custom-tools'
@@ -162,6 +164,15 @@ export const allNavigationItems: NavigationItem[] = [
requiresEnterprise: true,
selfHostedOverride: isSSOEnabled,
},
{
id: 'whitelabeling',
label: 'Whitelabeling',
icon: Palette,
section: 'enterprise',
requiresHosted: true,
requiresEnterprise: true,
selfHostedOverride: isBillingEnabled,
},
{
id: 'admin',
label: 'Admin',

View File

@@ -9,7 +9,7 @@ export interface HighlightContext {
highlightAll?: boolean
}
const SYSTEM_PREFIXES = new Set(['start', 'loop', 'parallel', 'variable'])
const SYSTEM_PREFIXES = new Set(['loop', 'parallel', 'variable'])
/**
* Formats text by highlighting block references (<...>) and environment variables ({{...}})

View File

@@ -83,7 +83,7 @@ import {
useImportWorkflow,
useImportWorkspace,
} from '@/app/workspace/[workspaceId]/w/hooks'
import { getBrandConfig } from '@/ee/whitelabeling'
import { useOrgBrandConfig } from '@/ee/whitelabeling/components/branding-provider'
import { useFolderMap, useFolders } from '@/hooks/queries/folders'
import { useKnowledgeBasesQuery } from '@/hooks/queries/kb/knowledge'
import { useTablesList } from '@/hooks/queries/tables'
@@ -337,7 +337,7 @@ export const SIDEBAR_SCROLL_EVENT = 'sidebar-scroll-to-item'
* @returns Sidebar with workflows panel
*/
export const Sidebar = memo(function Sidebar() {
const brand = getBrandConfig()
const brand = useOrgBrandConfig()
const params = useParams()
const workspaceId = params.workspaceId as string
const workflowId = params.workflowId as string | undefined
@@ -1251,7 +1251,16 @@ export const Sidebar = memo(function Sidebar() {
tabIndex={isCollapsed ? -1 : undefined}
aria-label={brand.name}
>
{brand.logoUrl ? (
{brand.wordmarkUrl ? (
<Image
src={brand.wordmarkUrl}
alt={brand.name}
height={16}
width={80}
className='h-[16px] w-auto flex-shrink-0 object-contain object-left'
unoptimized
/>
) : brand.logoUrl ? (
<Image
src={brand.logoUrl}
alt={brand.name}

View File

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

View File

@@ -20,7 +20,7 @@ const buttonVariants = cva(
'bg-[var(--text-error)] text-white hover-hover:text-white hover-hover:brightness-106',
secondary: 'bg-[var(--brand-secondary)] text-[var(--text-primary)]',
tertiary:
'bg-[var(--brand-accent)] text-[var(--text-inverse)] hover-hover:text-[var(--text-inverse)] hover-hover:bg-[#2DAC72] dark:bg-[var(--brand-accent)] dark:hover-hover:bg-[#2DAC72] dark:text-[var(--text-inverse)] dark:hover-hover:text-[var(--text-inverse)]',
'bg-[var(--brand-accent)] text-[var(--text-inverse)] hover-hover:text-[var(--text-inverse)] hover-hover:bg-[var(--brand-accent-hover)] dark:bg-[var(--brand-accent)] dark:hover-hover:bg-[var(--brand-accent-hover)] dark:text-[var(--text-inverse)] dark:hover-hover:text-[var(--text-inverse)]',
ghost: 'text-[var(--text-secondary)] hover-hover:text-[var(--text-primary)]',
subtle:
'text-[var(--text-body)] hover-hover:text-[var(--text-body)] hover-hover:bg-[var(--surface-4)]',

View File

@@ -26,12 +26,14 @@ export const getBrandConfig = (): BrandConfig => {
const hasCustomBrand = Boolean(
getEnv('NEXT_PUBLIC_BRAND_NAME') ||
getEnv('NEXT_PUBLIC_BRAND_LOGO_URL') ||
getEnv('NEXT_PUBLIC_BRAND_WORDMARK_URL') ||
getEnv('NEXT_PUBLIC_BRAND_PRIMARY_COLOR')
)
return {
name: getEnv('NEXT_PUBLIC_BRAND_NAME') || defaultBrandConfig.name,
logoUrl: getEnv('NEXT_PUBLIC_BRAND_LOGO_URL') || defaultBrandConfig.logoUrl,
wordmarkUrl: getEnv('NEXT_PUBLIC_BRAND_WORDMARK_URL') || defaultBrandConfig.wordmarkUrl,
faviconUrl: getEnv('NEXT_PUBLIC_BRAND_FAVICON_URL') || defaultBrandConfig.faviconUrl,
customCssUrl: getEnv('NEXT_PUBLIC_CUSTOM_CSS_URL') || defaultBrandConfig.customCssUrl,
supportEmail: getEnv('NEXT_PUBLIC_SUPPORT_EMAIL') || defaultBrandConfig.supportEmail,

View File

@@ -0,0 +1,124 @@
'use client'
import { createContext, useContext, useEffect, useMemo, useState } from 'react'
import type { BrandConfig } from '@/lib/branding/types'
import { getBrandConfig } from '@/ee/whitelabeling/branding'
import { useWhitelabelSettings } from '@/ee/whitelabeling/hooks/whitelabel'
import { generateOrgThemeCSS, mergeOrgBrandConfig } from '@/ee/whitelabeling/org-branding-utils'
import { useOrganizations } from '@/hooks/queries/organization'
export const BRAND_COOKIE_NAME = 'sim-wl'
const BRAND_COOKIE_MAX_AGE = 30 * 24 * 60 * 60
/**
* Brand assets and theme CSS cached in a cookie between page loads.
* Written client-side after org settings resolve; read server-side in the
* workspace layout so the correct branding is baked into the initial HTML.
*/
export interface BrandCache {
logoUrl?: string
wordmarkUrl?: string
/** Pre-generated `:root { ... }` CSS from the last resolved org settings. */
themeCSS?: string
}
function writeBrandCookie(cache: BrandCache | null): void {
try {
if (cache && Object.keys(cache).length > 0) {
document.cookie = `${BRAND_COOKIE_NAME}=${encodeURIComponent(JSON.stringify(cache))}; path=/; max-age=${BRAND_COOKIE_MAX_AGE}; SameSite=Lax`
} else {
document.cookie = `${BRAND_COOKIE_NAME}=; path=/; max-age=0; SameSite=Lax`
}
} catch {}
}
interface BrandingContextValue {
config: BrandConfig
}
const BrandingContext = createContext<BrandingContextValue>({
config: getBrandConfig(),
})
interface BrandingProviderProps {
children: React.ReactNode
/**
* Brand cache read server-side from the `sim-wl` cookie by the workspace
* layout. When present, the server renders the correct org branding from the
* first byte — no flash of any kind on page load or hard refresh.
*/
initialCache?: BrandCache | null
}
/**
* Provides merged branding (instance env vars + org DB settings) to the workspace.
* Injects a `<style>` tag with CSS variable overrides when org colors are configured.
*
* Flow:
* - First visit: org logo loads after the API call resolves (one-time flash).
* - All subsequent visits: the workspace layout reads the `sim-wl` cookie
* server-side and passes it as `initialCache`. The server renders the correct
* brand in the initial HTML — no flash of any kind.
*/
export function BrandingProvider({ children, initialCache }: BrandingProviderProps) {
const [cache, setCache] = useState<BrandCache | null>(initialCache ?? null)
const { data: orgsData, isLoading: orgsLoading } = useOrganizations()
const orgId = orgsData?.activeOrganization?.id
const { data: orgSettings, isLoading: settingsLoading } = useWhitelabelSettings(orgId)
useEffect(() => {
if (orgsLoading) return
if (!orgId) {
writeBrandCookie(null)
setCache(null)
return
}
if (settingsLoading) return
const themeCSS = orgSettings ? generateOrgThemeCSS(orgSettings) : null
const next: BrandCache = {}
if (orgSettings?.logoUrl) next.logoUrl = orgSettings.logoUrl
if (orgSettings?.wordmarkUrl) next.wordmarkUrl = orgSettings.wordmarkUrl
if (themeCSS) next.themeCSS = themeCSS
const newCache = Object.keys(next).length > 0 ? next : null
writeBrandCookie(newCache)
setCache(newCache)
}, [orgsLoading, orgId, settingsLoading, orgSettings])
const brandConfig = useMemo(() => {
const base = mergeOrgBrandConfig(orgSettings ?? null, getBrandConfig())
if (!orgSettings && cache) {
return {
...base,
...(cache.logoUrl && { logoUrl: cache.logoUrl }),
...(cache.wordmarkUrl && { wordmarkUrl: cache.wordmarkUrl }),
}
}
return base
}, [orgSettings, cache])
const themeCSS = useMemo(() => {
if (orgSettings) return generateOrgThemeCSS(orgSettings)
if (cache?.themeCSS) return cache.themeCSS
return ''
}, [orgSettings, cache])
return (
<BrandingContext.Provider value={{ config: brandConfig }}>
{themeCSS && <style>{themeCSS}</style>}
{children}
</BrandingContext.Provider>
)
}
/**
* Returns the merged brand config (org settings overlaid on instance defaults).
* Use this inside the workspace instead of `getBrandConfig()`.
*/
export function useOrgBrandConfig(): BrandConfig {
return useContext(BrandingContext).config
}

View File

@@ -0,0 +1,536 @@
'use client'
import { useCallback, useState } from 'react'
import { createLogger } from '@sim/logger'
import { Loader2, X } from 'lucide-react'
import Image from 'next/image'
import { Button, Input, Label, Switch } from '@/components/emcn'
import { useSession } from '@/lib/auth/auth-client'
import { getSubscriptionAccessState } from '@/lib/billing/client/utils'
import { HEX_COLOR_REGEX } from '@/lib/branding'
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
import { cn } from '@/lib/core/utils/cn'
import { getUserRole } from '@/lib/workspaces/organization/utils'
import { useProfilePictureUpload } from '@/app/workspace/[workspaceId]/settings/hooks/use-profile-picture-upload'
import {
useUpdateWhitelabelSettings,
useWhitelabelSettings,
type WhitelabelSettingsPayload,
} from '@/ee/whitelabeling/hooks/whitelabel'
import { useOrganizations } from '@/hooks/queries/organization'
import { useSubscriptionData } from '@/hooks/queries/subscription'
const logger = createLogger('WhitelabelingSettings')
interface DropZoneProps {
onDrop: (e: React.DragEvent) => void
children: React.ReactNode
className?: string
}
function DropZone({ onDrop, children, className }: DropZoneProps) {
const [isDragging, setIsDragging] = useState(false)
const handleDragOver = useCallback((e: React.DragEvent) => {
if (e.dataTransfer.types.includes('Files')) {
e.preventDefault()
setIsDragging(true)
}
}, [])
const handleDragLeave = useCallback((e: React.DragEvent) => {
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
setIsDragging(false)
}
}, [])
const handleDrop = useCallback(
(e: React.DragEvent) => {
setIsDragging(false)
onDrop(e)
},
[onDrop]
)
return (
<div
className={cn('relative', className)}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{children}
{isDragging && (
<div className='pointer-events-none absolute inset-0 z-10 flex items-center justify-center rounded-lg border-[1.5px] border-[var(--brand)] border-dashed bg-[color-mix(in_srgb,var(--brand)_8%,transparent)]'>
<span className='font-medium text-[12px] text-[var(--brand)]'>Drop image</span>
</div>
)}
</div>
)
}
interface ColorInputProps {
label: string
value: string
onChange: (value: string) => void
placeholder?: string
}
function ColorInput({ label, value, onChange, placeholder = '#000000' }: ColorInputProps) {
const isValidHex = !value || HEX_COLOR_REGEX.test(value)
return (
<div className='flex flex-col gap-1.5'>
<Label className='text-[13px] text-[var(--text-primary)]'>{label}</Label>
<div className='flex items-center gap-2'>
<div className='relative flex h-[36px] w-[36px] shrink-0 items-center justify-center overflow-hidden rounded-md border border-[var(--border)] bg-[var(--surface-2)]'>
{value && isValidHex ? (
<div className='h-full w-full rounded-md' style={{ backgroundColor: value }} />
) : (
<div className='h-full w-full rounded-md bg-[var(--surface-3)]' />
)}
</div>
<Input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className={cn(
'h-[36px] font-mono text-[13px]',
!isValidHex && 'border-red-500 focus-visible:ring-red-500'
)}
maxLength={7}
/>
</div>
{!isValidHex && (
<p className='text-[12px] text-red-500'>Must be a valid hex color (e.g. #701ffc)</p>
)}
</div>
)
}
interface SettingRowProps {
label: string
description?: string
children: React.ReactNode
}
function SettingRow({ label, description, children }: SettingRowProps) {
return (
<div className='flex flex-col gap-1.5'>
<Label className='text-[13px] text-[var(--text-primary)]'>{label}</Label>
{description && <p className='text-[12px] text-[var(--text-muted)]'>{description}</p>}
{children}
</div>
)
}
function SectionTitle({ children }: { children: React.ReactNode }) {
return <h3 className='mb-4 font-medium text-[15px] text-[var(--text-primary)]'>{children}</h3>
}
export function WhitelabelingSettings() {
const { data: session } = useSession()
const { data: orgsData } = useOrganizations()
const { data: subscriptionData } = useSubscriptionData()
const activeOrganization = orgsData?.activeOrganization
const orgId = activeOrganization?.id
const { data: savedSettings, isLoading } = useWhitelabelSettings(orgId)
const updateSettings = useUpdateWhitelabelSettings()
const userEmail = session?.user?.email
const userRole = getUserRole(activeOrganization, userEmail)
const canManage = userRole === 'owner' || userRole === 'admin'
const subscriptionAccess = getSubscriptionAccessState(subscriptionData?.data)
const hasEnterprisePlan = subscriptionAccess.hasUsableEnterpriseAccess
const [brandName, setBrandName] = useState('')
const [primaryColor, setPrimaryColor] = useState('')
const [primaryHoverColor, setPrimaryHoverColor] = useState('')
const [accentColor, setAccentColor] = useState('')
const [accentHoverColor, setAccentHoverColor] = useState('')
const [supportEmail, setSupportEmail] = useState('')
const [documentationUrl, setDocumentationUrl] = useState('')
const [termsUrl, setTermsUrl] = useState('')
const [privacyUrl, setPrivacyUrl] = useState('')
const [hidePoweredBySim, setHidePoweredBySim] = useState(false)
const [logoUrl, setLogoUrl] = useState<string | null>(null)
const [wordmarkUrl, setWordmarkUrl] = useState<string | null>(null)
const [formInitialized, setFormInitialized] = useState(false)
const [saveError, setSaveError] = useState<string | null>(null)
const [saveSuccess, setSaveSuccess] = useState(false)
if (savedSettings && !formInitialized) {
setBrandName(savedSettings.brandName ?? '')
setPrimaryColor(savedSettings.primaryColor ?? '')
setPrimaryHoverColor(savedSettings.primaryHoverColor ?? '')
setAccentColor(savedSettings.accentColor ?? '')
setAccentHoverColor(savedSettings.accentHoverColor ?? '')
setSupportEmail(savedSettings.supportEmail ?? '')
setDocumentationUrl(savedSettings.documentationUrl ?? '')
setTermsUrl(savedSettings.termsUrl ?? '')
setPrivacyUrl(savedSettings.privacyUrl ?? '')
setHidePoweredBySim(savedSettings.hidePoweredBySim ?? false)
setLogoUrl(savedSettings.logoUrl ?? null)
setWordmarkUrl(savedSettings.wordmarkUrl ?? null)
setFormInitialized(true)
}
const logoUpload = useProfilePictureUpload({
currentImage: logoUrl,
onUpload: (url) => setLogoUrl(url),
onError: (error) => setSaveError(error),
})
const wordmarkUpload = useProfilePictureUpload({
currentImage: wordmarkUrl,
onUpload: (url) => setWordmarkUrl(url),
onError: (error) => setSaveError(error),
})
const handleSave = useCallback(async () => {
if (!orgId) return
setSaveError(null)
setSaveSuccess(false)
const colorFields: Array<[string, string]> = [
['Primary color', primaryColor],
['Primary hover color', primaryHoverColor],
['Accent color', accentColor],
['Accent hover color', accentHoverColor],
]
for (const [fieldName, value] of colorFields) {
if (value && !HEX_COLOR_REGEX.test(value)) {
setSaveError(`${fieldName} must be a valid hex color (e.g. #701ffc)`)
return
}
}
const settings: WhitelabelSettingsPayload = {
brandName: brandName || null,
logoUrl: logoUpload.previewUrl || null,
wordmarkUrl: wordmarkUpload.previewUrl || null,
primaryColor: primaryColor || null,
primaryHoverColor: primaryHoverColor || null,
accentColor: accentColor || null,
accentHoverColor: accentHoverColor || null,
supportEmail: supportEmail || null,
documentationUrl: documentationUrl || null,
termsUrl: termsUrl || null,
privacyUrl: privacyUrl || null,
hidePoweredBySim,
}
try {
await updateSettings.mutateAsync({ orgId, settings })
setSaveSuccess(true)
setTimeout(() => setSaveSuccess(false), 3000)
} catch (error) {
logger.error('Failed to save whitelabel settings', { error })
setSaveError(error instanceof Error ? error.message : 'Failed to save settings')
}
}, [
orgId,
brandName,
logoUpload.previewUrl,
wordmarkUpload.previewUrl,
primaryColor,
primaryHoverColor,
accentColor,
accentHoverColor,
supportEmail,
documentationUrl,
termsUrl,
privacyUrl,
hidePoweredBySim,
])
if (isBillingEnabled) {
if (!activeOrganization) {
return (
<div className='flex h-full items-center justify-center text-[var(--text-muted)] text-sm'>
You must be part of an organization to configure whitelabeling.
</div>
)
}
if (!hasEnterprisePlan) {
return (
<div className='flex h-full items-center justify-center text-[var(--text-muted)] text-sm'>
Whitelabeling is available on Enterprise plans only.
</div>
)
}
if (!canManage) {
return (
<div className='flex h-full items-center justify-center text-[var(--text-muted)] text-sm'>
Only organization owners and admins can configure whitelabeling settings.
</div>
)
}
}
if (isLoading) {
return (
<div className='flex flex-col gap-8'>
{[...Array(3)].map((_, i) => (
<div key={i} className='flex flex-col gap-3'>
<div className='h-4 w-32 animate-pulse rounded bg-[var(--surface-3)]' />
<div className='h-9 w-full animate-pulse rounded-lg bg-[var(--surface-3)]' />
</div>
))}
</div>
)
}
const isUploading = logoUpload.isUploading || wordmarkUpload.isUploading
return (
<div className='flex flex-col gap-8'>
<section>
<SectionTitle>Brand Identity</SectionTitle>
<div className='flex flex-col gap-5'>
<div className='grid grid-cols-2 gap-4'>
<SettingRow
label='Logo'
description='Shown in the collapsed sidebar. Square image recommended (PNG or JPEG, max 5MB).'
>
<DropZone onDrop={logoUpload.handleFileDrop} className='flex items-center gap-4'>
<button
type='button'
onClick={logoUpload.handleThumbnailClick}
disabled={logoUpload.isUploading}
className='group relative flex h-16 w-16 shrink-0 items-center justify-center overflow-hidden rounded-xl border border-[var(--border)] bg-[var(--surface-2)] transition-colors hover:bg-[var(--surface-3)] disabled:opacity-50'
>
{logoUpload.isUploading ? (
<Loader2 className='h-5 w-5 animate-spin text-[var(--text-muted)]' />
) : logoUpload.previewUrl ? (
<Image
src={logoUpload.previewUrl}
alt='Logo'
fill
className='object-contain p-1'
unoptimized
/>
) : (
<span className='text-[11px] text-[var(--text-muted)]'>Logo</span>
)}
</button>
<div className='flex gap-2'>
<Button
variant='outline'
size='sm'
onClick={logoUpload.handleThumbnailClick}
disabled={logoUpload.isUploading}
className='text-[13px]'
>
{logoUpload.previewUrl ? 'Change' : 'Upload'}
</Button>
{logoUpload.previewUrl && (
<Button
variant='ghost'
size='sm'
onClick={logoUpload.handleRemove}
className='text-[13px] text-[var(--text-muted)] hover:text-[var(--text-primary)]'
>
<X className='h-3.5 w-3.5' />
</Button>
)}
</div>
<input
ref={logoUpload.fileInputRef}
type='file'
accept='image/png,image/jpeg,image/jpg'
onChange={logoUpload.handleFileChange}
className='hidden'
/>
</DropZone>
</SettingRow>
<SettingRow
label='Wordmark'
description='Shown in the expanded sidebar. Wide image recommended (PNG or JPEG, max 5MB).'
>
<DropZone onDrop={wordmarkUpload.handleFileDrop} className='flex items-center gap-4'>
<button
type='button'
onClick={wordmarkUpload.handleThumbnailClick}
disabled={wordmarkUpload.isUploading}
className='group relative flex h-16 w-40 shrink-0 items-center justify-center overflow-hidden rounded-xl border border-[var(--border)] bg-[var(--surface-2)] transition-colors hover:bg-[var(--surface-3)] disabled:opacity-50'
>
{wordmarkUpload.isUploading ? (
<Loader2 className='h-5 w-5 animate-spin text-[var(--text-muted)]' />
) : wordmarkUpload.previewUrl ? (
<Image
src={wordmarkUpload.previewUrl}
alt='Wordmark'
fill
className='object-contain p-2'
unoptimized
/>
) : (
<span className='text-[11px] text-[var(--text-muted)]'>Wordmark</span>
)}
</button>
<div className='flex gap-2'>
<Button
variant='outline'
size='sm'
onClick={wordmarkUpload.handleThumbnailClick}
disabled={wordmarkUpload.isUploading}
className='text-[13px]'
>
{wordmarkUpload.previewUrl ? 'Change' : 'Upload'}
</Button>
{wordmarkUpload.previewUrl && (
<Button
variant='ghost'
size='sm'
onClick={wordmarkUpload.handleRemove}
className='text-[13px] text-[var(--text-muted)] hover:text-[var(--text-primary)]'
>
<X className='h-3.5 w-3.5' />
</Button>
)}
</div>
<input
ref={wordmarkUpload.fileInputRef}
type='file'
accept='image/png,image/jpeg,image/jpg'
onChange={wordmarkUpload.handleFileChange}
className='hidden'
/>
</DropZone>
</SettingRow>
</div>
<SettingRow
label='Brand name'
description='Replaces "Sim" in the sidebar and select UI elements.'
>
<Input
value={brandName}
onChange={(e) => setBrandName(e.target.value)}
placeholder='Your Company'
className='h-[36px] max-w-[320px] text-[13px]'
maxLength={64}
/>
</SettingRow>
</div>
</section>
<section>
<SectionTitle>Colors</SectionTitle>
<div className='grid grid-cols-2 gap-4'>
<ColorInput
label='Primary color'
value={primaryColor}
onChange={setPrimaryColor}
placeholder='#701ffc'
/>
<ColorInput
label='Primary hover color'
value={primaryHoverColor}
onChange={setPrimaryHoverColor}
placeholder='#802fff'
/>
<ColorInput
label='Accent color'
value={accentColor}
onChange={setAccentColor}
placeholder='#9d54ff'
/>
<ColorInput
label='Accent hover color'
value={accentHoverColor}
onChange={setAccentHoverColor}
placeholder='#a66fff'
/>
</div>
</section>
<section>
<SectionTitle>Links</SectionTitle>
<div className='flex flex-col gap-4'>
<SettingRow label='Support email'>
<Input
type='email'
value={supportEmail}
onChange={(e) => setSupportEmail(e.target.value)}
placeholder='support@yourcompany.com'
className='h-[36px] text-[13px]'
/>
</SettingRow>
<SettingRow label='Documentation URL'>
<Input
type='url'
value={documentationUrl}
onChange={(e) => setDocumentationUrl(e.target.value)}
placeholder='https://docs.yourcompany.com'
className='h-[36px] text-[13px]'
/>
</SettingRow>
<SettingRow label='Terms of service URL'>
<Input
type='url'
value={termsUrl}
onChange={(e) => setTermsUrl(e.target.value)}
placeholder='https://yourcompany.com/terms'
className='h-[36px] text-[13px]'
/>
</SettingRow>
<SettingRow label='Privacy policy URL'>
<Input
type='url'
value={privacyUrl}
onChange={(e) => setPrivacyUrl(e.target.value)}
placeholder='https://yourcompany.com/privacy'
className='h-[36px] text-[13px]'
/>
</SettingRow>
</div>
</section>
<section>
<SectionTitle>Advanced</SectionTitle>
<div className='flex items-center justify-between rounded-lg border border-[var(--border)] bg-[var(--surface-2)] px-4 py-3'>
<div className='flex flex-col gap-0.5'>
<span className='text-[13px] text-[var(--text-primary)]'>
Hide "Powered by Sim" branding
</span>
<span className='text-[12px] text-[var(--text-muted)]'>
Removes the Sim logo from deployed chats and forms.
</span>
</div>
<Switch checked={hidePoweredBySim} onCheckedChange={setHidePoweredBySim} />
</div>
</section>
<div className='flex items-center gap-3'>
<Button
onClick={handleSave}
disabled={updateSettings.isPending || isUploading}
className='text-[13px]'
>
{updateSettings.isPending ? (
<>
<Loader2 className='mr-2 h-3.5 w-3.5 animate-spin' />
Saving
</>
) : (
'Save changes'
)}
</Button>
{saveSuccess && (
<span className='text-[13px] text-green-500'>Settings saved successfully.</span>
)}
{saveError && <span className='text-[13px] text-red-500'>{saveError}</span>}
</div>
</div>
)
}

View File

@@ -0,0 +1,83 @@
'use client'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import type { OrganizationWhitelabelSettings } from '@/lib/branding/types'
import { organizationKeys } from '@/hooks/queries/organization'
/** PUT payload — string fields accept null to clear a previously-set value. */
export type WhitelabelSettingsPayload = {
[K in keyof OrganizationWhitelabelSettings]: OrganizationWhitelabelSettings[K] extends
| string
| undefined
? string | null
: OrganizationWhitelabelSettings[K]
}
/**
* Query key factories for whitelabel-related queries
*/
export const whitelabelKeys = {
all: ['whitelabel'] as const,
settings: (orgId: string) => [...whitelabelKeys.all, 'settings', orgId] as const,
}
async function fetchWhitelabelSettings(
orgId: string,
signal?: AbortSignal
): Promise<OrganizationWhitelabelSettings> {
const response = await fetch(`/api/organizations/${orgId}/whitelabel`, { signal })
if (!response.ok) {
const error = await response.json().catch(() => ({}))
throw new Error(error.error ?? 'Failed to fetch whitelabel settings')
}
const { data } = await response.json()
return data as OrganizationWhitelabelSettings
}
/**
* Hook to fetch whitelabel settings for an organization.
*/
export function useWhitelabelSettings(orgId: string | undefined) {
return useQuery({
queryKey: whitelabelKeys.settings(orgId ?? ''),
queryFn: ({ signal }) => fetchWhitelabelSettings(orgId as string, signal),
enabled: Boolean(orgId),
staleTime: 60 * 1000,
})
}
interface UpdateWhitelabelVariables {
orgId: string
settings: WhitelabelSettingsPayload
}
/**
* Hook to update whitelabel settings for an organization.
*/
export function useUpdateWhitelabelSettings() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ orgId, settings }: UpdateWhitelabelVariables) => {
const response = await fetch(`/api/organizations/${orgId}/whitelabel`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settings),
})
if (!response.ok) {
const error = await response.json().catch(() => ({}))
throw new Error(error.error ?? 'Failed to update whitelabel settings')
}
const { data } = await response.json()
return data as OrganizationWhitelabelSettings
},
onSettled: (_data, _error, { orgId }) => {
queryClient.invalidateQueries({ queryKey: whitelabelKeys.settings(orgId) })
queryClient.invalidateQueries({ queryKey: organizationKeys.detail(orgId) })
},
})
}

View File

@@ -1,4 +1,6 @@
export type { OrganizationWhitelabelSettings } from '@/lib/branding/types'
export type { BrandConfig, ThemeColors } from './branding'
export { getBrandConfig, useBrandConfig } from './branding'
export { generateThemeCSS } from './inject-theme'
export { generateBrandedMetadata, generateStructuredData } from './metadata'
export { generateOrgThemeCSS, mergeOrgBrandConfig } from './org-branding-utils'

View File

@@ -31,6 +31,7 @@ export function generateThemeCSS(): string {
if (process.env.NEXT_PUBLIC_BRAND_PRIMARY_HOVER_COLOR) {
cssVars.push(`--brand-hover: ${process.env.NEXT_PUBLIC_BRAND_PRIMARY_HOVER_COLOR};`)
cssVars.push(`--brand-accent-hover: ${process.env.NEXT_PUBLIC_BRAND_PRIMARY_HOVER_COLOR};`)
cssVars.push(
`--auth-primary-btn-hover-bg: ${process.env.NEXT_PUBLIC_BRAND_PRIMARY_HOVER_COLOR};`
)

View File

@@ -0,0 +1,96 @@
import type { BrandConfig, OrganizationWhitelabelSettings } from '@/lib/branding/types'
/**
* Merge org-level whitelabel settings over the instance-level brand config.
* Org settings take priority for any field they define.
*/
export function mergeOrgBrandConfig(
orgSettings: OrganizationWhitelabelSettings | null,
instanceConfig: BrandConfig
): BrandConfig {
if (!orgSettings) {
return instanceConfig
}
const hasOrgBrand = Boolean(
orgSettings.brandName ||
orgSettings.logoUrl ||
orgSettings.wordmarkUrl ||
orgSettings.primaryColor
)
return {
...instanceConfig,
name: orgSettings.brandName || instanceConfig.name,
logoUrl: orgSettings.logoUrl || instanceConfig.logoUrl,
wordmarkUrl: orgSettings.wordmarkUrl || instanceConfig.wordmarkUrl,
supportEmail: orgSettings.supportEmail || instanceConfig.supportEmail,
documentationUrl: orgSettings.documentationUrl || instanceConfig.documentationUrl,
termsUrl: orgSettings.termsUrl || instanceConfig.termsUrl,
privacyUrl: orgSettings.privacyUrl || instanceConfig.privacyUrl,
theme: {
...instanceConfig.theme,
...(orgSettings.primaryColor && { primaryColor: orgSettings.primaryColor }),
...(orgSettings.primaryHoverColor && { primaryHoverColor: orgSettings.primaryHoverColor }),
...(orgSettings.accentColor && { accentColor: orgSettings.accentColor }),
...(orgSettings.accentHoverColor && { accentHoverColor: orgSettings.accentHoverColor }),
},
isWhitelabeled: instanceConfig.isWhitelabeled || hasOrgBrand,
}
}
function isDarkBackground(hex: string): boolean {
let clean = hex.replace('#', '')
if (clean.length === 3) {
clean = clean
.split('')
.map((c) => c + c)
.join('')
}
const r = Number.parseInt(clean.slice(0, 2), 16)
const g = Number.parseInt(clean.slice(2, 4), 16)
const b = Number.parseInt(clean.slice(4, 6), 16)
return (0.299 * r + 0.587 * g + 0.114 * b) / 255 < 0.5
}
function getContrastTextColor(hex: string): string {
return isDarkBackground(hex) ? '#ffffff' : '#000000'
}
/**
* Generate CSS variable overrides from org whitelabel settings.
* Returns an empty string when no color overrides are set.
*/
export function generateOrgThemeCSS(settings: OrganizationWhitelabelSettings): string {
const vars: string[] = []
if (settings.primaryColor) {
vars.push(`--brand: ${settings.primaryColor};`)
vars.push(`--brand-accent: ${settings.primaryColor};`)
vars.push(`--auth-primary-btn-bg: ${settings.primaryColor};`)
vars.push(`--auth-primary-btn-border: ${settings.primaryColor};`)
vars.push(`--auth-primary-btn-hover-bg: ${settings.primaryColor};`)
vars.push(`--auth-primary-btn-hover-border: ${settings.primaryColor};`)
const textColor = getContrastTextColor(settings.primaryColor)
vars.push(`--auth-primary-btn-text: ${textColor};`)
vars.push(`--auth-primary-btn-hover-text: ${textColor};`)
}
if (settings.primaryHoverColor) {
vars.push(`--brand-hover: ${settings.primaryHoverColor};`)
vars.push(`--brand-accent-hover: ${settings.primaryHoverColor};`)
vars.push(`--auth-primary-btn-hover-bg: ${settings.primaryHoverColor};`)
vars.push(`--auth-primary-btn-hover-border: ${settings.primaryHoverColor};`)
vars.push(`--auth-primary-btn-hover-text: ${getContrastTextColor(settings.primaryHoverColor)};`)
}
if (settings.accentColor) {
vars.push(`--brand-link: ${settings.accentColor};`)
}
if (settings.accentHoverColor) {
vars.push(`--brand-link-hover: ${settings.accentHoverColor};`)
}
return vars.length > 0 ? `:root { ${vars.join(' ')} }` : ''
}

View File

@@ -0,0 +1,37 @@
import { db } from '@sim/db'
import { organization } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import type { BrandConfig, OrganizationWhitelabelSettings } from '@/lib/branding/types'
import { getBrandConfig } from '@/ee/whitelabeling/branding'
import { mergeOrgBrandConfig } from '@/ee/whitelabeling/org-branding-utils'
const logger = createLogger('OrgBranding')
/**
* Fetch whitelabel settings for an organization from the database.
*/
export async function getOrgWhitelabelSettings(
orgId: string
): Promise<OrganizationWhitelabelSettings | null> {
try {
const [org] = await db
.select({ whitelabelSettings: organization.whitelabelSettings })
.from(organization)
.where(eq(organization.id, orgId))
.limit(1)
return org?.whitelabelSettings ?? null
} catch (error) {
logger.error('Failed to fetch org whitelabel settings', { error, orgId })
return null
}
}
/**
* Get the merged brand config for an org, combining instance env vars with org DB settings.
*/
export async function getOrgBrandConfig(orgId: string): Promise<BrandConfig> {
const orgSettings = await getOrgWhitelabelSettings(orgId)
return mergeOrgBrandConfig(orgSettings, getBrandConfig())
}

View File

@@ -22,8 +22,6 @@ export interface ParallelScope {
parallelId: string
totalBranches: number
branchOutputs: Map<number, NormalizedBlockOutput[]>
completedCount: number
totalExpectedNodes: number
items?: any[]
/** Error message if parallel validation failed (e.g., exceeded max branches) */
validationError?: string

View File

@@ -58,9 +58,7 @@ export class NodeExecutionOrchestrator {
const parallelId = node.metadata.parallelId
if (parallelId && !this.parallelOrchestrator.getParallelScope(ctx, parallelId)) {
const parallelConfig = this.dag.parallelConfigs.get(parallelId)
const nodesInParallel = parallelConfig?.nodes?.length || 1
await this.parallelOrchestrator.initializeParallelScope(ctx, parallelId, nodesInParallel)
await this.parallelOrchestrator.initializeParallelScope(ctx, parallelId)
}
if (node.metadata.isSentinel) {
@@ -157,8 +155,7 @@ export class NodeExecutionOrchestrator {
if (!this.parallelOrchestrator.getParallelScope(ctx, parallelId)) {
const parallelConfig = this.dag.parallelConfigs.get(parallelId)
if (parallelConfig) {
const nodesInParallel = parallelConfig.nodes?.length || 1
await this.parallelOrchestrator.initializeParallelScope(ctx, parallelId, nodesInParallel)
await this.parallelOrchestrator.initializeParallelScope(ctx, parallelId)
}
}
@@ -237,20 +234,9 @@ export class NodeExecutionOrchestrator {
): Promise<void> {
const scope = this.parallelOrchestrator.getParallelScope(ctx, parallelId)
if (!scope) {
const parallelConfig = this.dag.parallelConfigs.get(parallelId)
const nodesInParallel = parallelConfig?.nodes?.length || 1
await this.parallelOrchestrator.initializeParallelScope(ctx, parallelId, nodesInParallel)
await this.parallelOrchestrator.initializeParallelScope(ctx, parallelId)
}
const allComplete = this.parallelOrchestrator.handleParallelBranchCompletion(
ctx,
parallelId,
node.id,
output
)
if (allComplete) {
await this.parallelOrchestrator.aggregateParallelResults(ctx, parallelId)
}
this.parallelOrchestrator.handleParallelBranchCompletion(ctx, parallelId, node.id, output)
this.state.setBlockOutput(node.id, output)
}

View File

@@ -107,7 +107,7 @@ describe('ParallelOrchestrator', () => {
)
const ctx = createContext()
const initializePromise = orchestrator.initializeParallelScope(ctx, 'parallel-1', 1)
const initializePromise = orchestrator.initializeParallelScope(ctx, 'parallel-1')
await Promise.resolve()
expect(onBlockStart).toHaveBeenCalledTimes(1)

View File

@@ -47,17 +47,13 @@ export class ParallelOrchestrator {
private contextExtensions: ContextExtensions | null = null
) {}
async initializeParallelScope(
ctx: ExecutionContext,
parallelId: string,
terminalNodesCount = 1
): Promise<ParallelScope> {
async initializeParallelScope(ctx: ExecutionContext, parallelId: string): Promise<ParallelScope> {
const parallelConfig = this.dag.parallelConfigs.get(parallelId)
if (!parallelConfig) {
throw new Error(`Parallel config not found: ${parallelId}`)
}
if (terminalNodesCount === 0 || parallelConfig.nodes.length === 0) {
if (parallelConfig.nodes.length === 0) {
const errorMessage =
'Parallel has no executable blocks inside. Add or enable at least one block in the parallel.'
logger.error(errorMessage, { parallelId })
@@ -108,8 +104,6 @@ export class ParallelOrchestrator {
parallelId,
totalBranches: 0,
branchOutputs: new Map(),
completedCount: 0,
totalExpectedNodes: 0,
items: [],
isEmpty: true,
}
@@ -186,8 +180,6 @@ export class ParallelOrchestrator {
parallelId,
totalBranches: branchCount,
branchOutputs: new Map(),
completedCount: 0,
totalExpectedNodes: branchCount * terminalNodesCount,
items,
}
@@ -253,8 +245,6 @@ export class ParallelOrchestrator {
parallelId,
totalBranches: 0,
branchOutputs: new Map(),
completedCount: 0,
totalExpectedNodes: 0,
items: [],
validationError: errorMessage,
}
@@ -277,32 +267,34 @@ export class ParallelOrchestrator {
return resolveArrayInput(ctx, config.distribution, this.resolver)
}
/**
* Stores a node's output in the branch outputs for later aggregation.
* Aggregation is triggered by the sentinel-end node via the edge mechanism,
* not by counting individual node completions. This avoids incorrect completion
* detection when branches have conditional paths (error edges, conditions).
*/
handleParallelBranchCompletion(
ctx: ExecutionContext,
parallelId: string,
nodeId: string,
output: NormalizedBlockOutput
): boolean {
): void {
const scope = ctx.parallelExecutions?.get(parallelId)
if (!scope) {
logger.warn('Parallel scope not found for branch completion', { parallelId, nodeId })
return false
return
}
const branchIndex = extractBranchIndex(nodeId)
if (branchIndex === null) {
logger.warn('Could not extract branch index from node ID', { nodeId })
return false
return
}
if (!scope.branchOutputs.has(branchIndex)) {
scope.branchOutputs.set(branchIndex, [])
}
scope.branchOutputs.get(branchIndex)!.push(output)
scope.completedCount++
const allComplete = scope.completedCount >= scope.totalExpectedNodes
return allComplete
}
async aggregateParallelResults(

View File

@@ -228,8 +228,6 @@ export interface ExecutionContext {
parallelId: string
totalBranches: number
branchOutputs: Map<number, any[]>
completedCount: number
totalExpectedNodes: number
parallelType?: 'count' | 'collection'
items?: any[]
}

View File

@@ -43,8 +43,6 @@ describe('getIterationContext', () => {
parallelId: 'p1',
totalBranches: 3,
branchOutputs: new Map(),
completedCount: 0,
totalExpectedNodes: 3,
},
],
]),
@@ -135,8 +133,6 @@ describe('buildUnifiedParentIterations', () => {
parallelId: 'outer-p',
totalBranches: 4,
branchOutputs: new Map(),
completedCount: 0,
totalExpectedNodes: 4,
},
],
]),
@@ -164,8 +160,6 @@ describe('buildUnifiedParentIterations', () => {
parallelId: 'parallel-1',
totalBranches: 5,
branchOutputs: new Map(),
completedCount: 0,
totalExpectedNodes: 5,
},
],
]),
@@ -232,8 +226,6 @@ describe('buildUnifiedParentIterations', () => {
parallelId: 'outer-p',
totalBranches: 3,
branchOutputs: new Map(),
completedCount: 0,
totalExpectedNodes: 3,
},
],
]),
@@ -275,8 +267,6 @@ describe('buildUnifiedParentIterations', () => {
parallelId: 'P1',
totalBranches: 2,
branchOutputs: new Map(),
completedCount: 0,
totalExpectedNodes: 2,
},
],
[
@@ -285,8 +275,6 @@ describe('buildUnifiedParentIterations', () => {
parallelId: 'P2',
totalBranches: 2,
branchOutputs: new Map(),
completedCount: 0,
totalExpectedNodes: 2,
},
],
[
@@ -295,8 +283,6 @@ describe('buildUnifiedParentIterations', () => {
parallelId: 'P2__obranch-1',
totalBranches: 2,
branchOutputs: new Map(),
completedCount: 0,
totalExpectedNodes: 2,
},
],
]),
@@ -363,8 +349,6 @@ describe('buildUnifiedParentIterations', () => {
parallelId: 'parallel-1',
totalBranches: 5,
branchOutputs: new Map(),
completedCount: 0,
totalExpectedNodes: 5,
},
],
]),
@@ -423,8 +407,6 @@ describe('buildUnifiedParentIterations', () => {
parallelId: 'parallel-1',
totalBranches: 3,
branchOutputs: new Map(),
completedCount: 0,
totalExpectedNodes: 3,
},
],
]),
@@ -478,8 +460,6 @@ describe('buildContainerIterationContext', () => {
parallelId: 'parallel-1',
totalBranches: 5,
branchOutputs: new Map(),
completedCount: 0,
totalExpectedNodes: 5,
},
],
]),
@@ -541,8 +521,6 @@ describe('buildContainerIterationContext', () => {
parallelId: 'P2__obranch-1',
totalBranches: 5,
branchOutputs: new Map(),
completedCount: 0,
totalExpectedNodes: 5,
},
],
]),
@@ -568,8 +546,6 @@ describe('buildContainerIterationContext', () => {
parallelId: 'outer-parallel',
totalBranches: 3,
branchOutputs: new Map(),
completedCount: 0,
totalExpectedNodes: 3,
},
],
]),

View File

@@ -6,6 +6,11 @@ import type { ResolutionContext } from './reference'
vi.mock('@sim/logger', () => loggerMock)
interface BlockDef {
id: string
name: string
}
/**
* Creates a minimal workflow for testing.
*/
@@ -18,7 +23,8 @@ function createTestWorkflow(
distribution?: any
parallelType?: 'count' | 'collection'
}
> = {}
> = {},
blockDefs: BlockDef[] = []
) {
const normalizedParallels: Record<
string,
@@ -37,9 +43,18 @@ function createTestWorkflow(
parallelType: parallel.parallelType,
}
}
const blocks = blockDefs.map((b) => ({
id: b.id,
position: { x: 0, y: 0 },
config: { tool: 'test', params: {} },
inputs: {},
outputs: {},
metadata: { id: 'function', name: b.name },
enabled: true,
}))
return {
version: '1.0',
blocks: [],
blocks,
connections: [],
loops: {},
parallels: normalizedParallels,
@@ -54,8 +69,6 @@ function createParallelScope(items: any[]) {
parallelId: 'parallel-1',
totalBranches: items.length,
branchOutputs: new Map(),
completedCount: 0,
totalExpectedNodes: 1,
items,
}
}
@@ -65,13 +78,16 @@ function createParallelScope(items: any[]) {
*/
function createTestContext(
currentNodeId: string,
parallelExecutions?: Map<string, any>
parallelExecutions?: Map<string, any>,
blockOutputs?: Record<string, any>
): ResolutionContext {
return {
executionContext: {
parallelExecutions: parallelExecutions ?? new Map(),
},
executionState: {},
executionState: {
getBlockOutput: (id: string) => blockOutputs?.[id],
},
currentNodeId,
} as ResolutionContext
}
@@ -385,4 +401,119 @@ describe('ParallelResolver', () => {
expect(resolver.resolve('<parallel.currentItem>', createTestContext('block-3₍1₎'))).toBe('p4')
})
})
describe('named parallel references', () => {
it.concurrent('should resolve result from anywhere after parallel completes', () => {
const workflow = createTestWorkflow(
{ 'parallel-1': { nodes: ['block-1'], distribution: ['a', 'b'] } },
[{ id: 'parallel-1', name: 'Parallel 1' }]
)
const resolver = new ParallelResolver(workflow)
const results = [[{ response: 'a' }], [{ response: 'b' }]]
const ctx = createTestContext('block-outside', new Map(), {
'parallel-1': { results },
})
expect(resolver.resolve('<parallel1.result>', ctx)).toEqual(results)
expect(resolver.resolve('<parallel1.results>', ctx)).toEqual(results)
})
it.concurrent('should resolve result with nested path', () => {
const workflow = createTestWorkflow(
{ 'parallel-1': { nodes: ['block-1'], distribution: ['a', 'b'] } },
[{ id: 'parallel-1', name: 'Parallel 1' }]
)
const resolver = new ParallelResolver(workflow)
const results = [[{ response: 'a' }], [{ response: 'b' }]]
const ctx = createTestContext('block-outside', new Map(), {
'parallel-1': { results },
})
expect(resolver.resolve('<parallel1.result.0>', ctx)).toEqual([{ response: 'a' }])
expect(resolver.resolve('<parallel1.result.1.0.response>', ctx)).toBe('b')
})
it.concurrent('should resolve result with empty currentNodeId', () => {
const workflow = createTestWorkflow(
{ 'parallel-1': { nodes: ['block-1'], distribution: ['a', 'b'] } },
[{ id: 'parallel-1', name: 'Parallel 1' }]
)
const resolver = new ParallelResolver(workflow)
const results = [[{ output: 'x' }], [{ output: 'y' }]]
const ctx = createTestContext('', new Map(), {
'parallel-1': { results },
})
expect(resolver.resolve('<parallel1.results>', ctx)).toEqual(results)
})
it.concurrent('should return undefined when no output stored yet', () => {
const workflow = createTestWorkflow(
{ 'parallel-1': { nodes: ['block-1'], distribution: ['a'] } },
[{ id: 'parallel-1', name: 'Parallel 1' }]
)
const resolver = new ParallelResolver(workflow)
const ctx = createTestContext('block-outside', new Map())
expect(resolver.resolve('<parallel1.results>', ctx)).toBeUndefined()
})
it.concurrent('should resolve iteration properties via named reference', () => {
const workflow = createTestWorkflow(
{
'parallel-1': {
nodes: ['block-1'],
distribution: ['x', 'y', 'z'],
parallelType: 'collection',
},
},
[{ id: 'parallel-1', name: 'Parallel 1' }]
)
const resolver = new ParallelResolver(workflow)
const ctx = createTestContext('block-1₍1₎')
expect(resolver.resolve('<parallel1.index>', ctx)).toBe(1)
expect(resolver.resolve('<parallel1.currentItem>', ctx)).toBe('y')
expect(resolver.resolve('<parallel1.items>', ctx)).toEqual(['x', 'y', 'z'])
})
it.concurrent('should throw InvalidFieldError for unknown property on named ref', () => {
const workflow = createTestWorkflow(
{
'parallel-1': {
nodes: ['block-1'],
distribution: ['a'],
parallelType: 'collection',
},
},
[{ id: 'parallel-1', name: 'Parallel 1' }]
)
const resolver = new ParallelResolver(workflow)
const ctx = createTestContext('block-1₍0₎')
expect(() => resolver.resolve('<parallel1.unknownProp>', ctx)).toThrow(InvalidFieldError)
})
it.concurrent('should not resolve named ref when no matching block exists', () => {
const workflow = createTestWorkflow({ 'parallel-1': { nodes: ['block-1'] } }, [
{ id: 'parallel-1', name: 'Parallel 1' },
])
const resolver = new ParallelResolver(workflow)
expect(resolver.canResolve('<parallel99.index>')).toBe(false)
})
it.concurrent('should resolve generic parallel results from inside a branch', () => {
const workflow = createTestWorkflow({
'parallel-1': { nodes: ['block-1'], distribution: ['a', 'b'] },
})
const resolver = new ParallelResolver(workflow)
const results = [[{ response: 'a' }], [{ response: 'b' }]]
const ctx = createTestContext('block-1₍0₎', new Map(), {
'parallel-1': { results },
})
expect(resolver.resolve('<parallel.results>', ctx)).toEqual(results)
expect(resolver.resolve('<parallel.result>', ctx)).toEqual(results)
})
})
})

View File

@@ -28,6 +28,7 @@ export class ParallelResolver implements Resolver {
}
}
private static OUTPUT_PROPERTIES = new Set(['result', 'results'])
private static KNOWN_PROPERTIES = new Set(['index', 'currentItem', 'items'])
canResolve(reference: string): boolean {
@@ -73,6 +74,10 @@ export class ParallelResolver implements Resolver {
)
}
if (rest.length > 0 && ParallelResolver.OUTPUT_PROPERTIES.has(rest[0])) {
return this.resolveOutput(targetParallelId, rest.slice(1), context)
}
// Look up config using the original (non-cloned) ID
const originalParallelId = stripOuterBranchSuffix(targetParallelId)
const parallelConfig = this.workflow.parallels?.[originalParallelId]
@@ -116,7 +121,9 @@ export class ParallelResolver implements Resolver {
if (!ParallelResolver.KNOWN_PROPERTIES.has(property)) {
const isCollection = parallelConfig.parallelType === 'collection'
const availableFields = isCollection ? ['index', 'currentItem', 'items'] : ['index']
const availableFields = isCollection
? ['index', 'currentItem', 'items', 'result']
: ['index', 'result']
throw new InvalidFieldError(firstPart, property, availableFields)
}
@@ -216,6 +223,22 @@ export class ParallelResolver implements Resolver {
return undefined
}
private resolveOutput(
parallelId: string,
pathParts: string[],
context: ResolutionContext
): unknown {
const output = context.executionState.getBlockOutput(parallelId)
if (!output || typeof output !== 'object') {
return undefined
}
const value = (output as Record<string, unknown>).results
if (pathParts.length > 0) {
return navigatePath(value, pathParts)
}
return value
}
private getDistributionItems(parallelConfig: SerializedParallel): unknown[] {
const rawItems = parallelConfig.distribution ?? []

View File

@@ -12,7 +12,7 @@ export interface ConnectorData {
sourceConfig: Record<string, unknown>
syncMode: string
syncIntervalMinutes: number
status: 'active' | 'paused' | 'syncing' | 'error'
status: 'active' | 'paused' | 'syncing' | 'error' | 'disabled'
lastSyncAt: string | null
lastSyncError: string | null
lastSyncDocCount: number | null

View File

@@ -0,0 +1,384 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { arrayBufferToBase64, floatTo16BitPCM } from '@/lib/speech/audio'
import {
CHUNK_SEND_INTERVAL_MS,
ELEVENLABS_WS_URL,
MAX_SESSION_MS,
SAMPLE_RATE,
} from '@/lib/speech/config'
const logger = createLogger('useSpeechToText')
export { MAX_SESSION_MS } from '@/lib/speech/config'
export type PermissionState = 'prompt' | 'granted' | 'denied'
interface UseSpeechToTextProps {
onTranscript: (text: string) => void
language?: string
}
interface UseSpeechToTextReturn {
isListening: boolean
isSupported: boolean
permissionState: PermissionState
toggleListening: () => void
resetTranscript: () => void
}
export function useSpeechToText({
onTranscript,
language,
}: UseSpeechToTextProps): UseSpeechToTextReturn {
const [isListening, setIsListening] = useState(false)
const [isSupported, setIsSupported] = useState(false)
const [permissionState, setPermissionState] = useState<PermissionState>('prompt')
const onTranscriptRef = useRef(onTranscript)
const languageRef = useRef(language)
const mountedRef = useRef(true)
const startingRef = useRef(false)
const wsRef = useRef<WebSocket | null>(null)
const streamRef = useRef<MediaStream | null>(null)
const audioContextRef = useRef<AudioContext | null>(null)
const processorRef = useRef<ScriptProcessorNode | null>(null)
const pcmBufferRef = useRef<Float32Array[]>([])
const sendIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
const sessionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const stopStreamingRef = useRef<() => void>(() => {})
const isFirstChunkRef = useRef(true)
const committedTextRef = useRef('')
onTranscriptRef.current = onTranscript
languageRef.current = language
useEffect(() => {
const browserOk =
typeof window !== 'undefined' &&
typeof AudioContext !== 'undefined' &&
typeof WebSocket !== 'undefined' &&
typeof navigator?.mediaDevices?.getUserMedia === 'function'
if (!browserOk) {
setIsSupported(false)
return
}
fetch('/api/settings/voice', { credentials: 'include' })
.then((r) => (r.ok ? r.json() : { sttAvailable: false }))
.then((data) => {
if (mountedRef.current) setIsSupported(data.sttAvailable === true)
})
.catch(() => {
if (mountedRef.current) setIsSupported(false)
})
}, [])
const flushAudioBuffer = useCallback(() => {
const ws = wsRef.current
if (!ws || ws.readyState !== WebSocket.OPEN) return
const chunks = pcmBufferRef.current
if (chunks.length === 0) return
pcmBufferRef.current = []
let totalLength = 0
for (const chunk of chunks) totalLength += chunk.length
const merged = new Float32Array(totalLength)
let offset = 0
for (const chunk of chunks) {
merged.set(chunk, offset)
offset += chunk.length
}
const pcm16 = floatTo16BitPCM(merged)
const message: Record<string, unknown> = {
message_type: 'input_audio_chunk',
audio_base_64: arrayBufferToBase64(pcm16),
sample_rate: SAMPLE_RATE,
commit: false,
}
if (isFirstChunkRef.current) {
isFirstChunkRef.current = false
if (committedTextRef.current) {
message.previous_text = committedTextRef.current
}
}
ws.send(JSON.stringify(message))
}, [])
const cleanup = useCallback(() => {
if (sessionTimerRef.current) {
clearTimeout(sessionTimerRef.current)
sessionTimerRef.current = null
}
if (sendIntervalRef.current) {
clearInterval(sendIntervalRef.current)
sendIntervalRef.current = null
}
if (processorRef.current) {
processorRef.current.disconnect()
processorRef.current = null
}
if (audioContextRef.current && audioContextRef.current.state !== 'closed') {
audioContextRef.current.close().catch(() => {})
audioContextRef.current = null
}
if (streamRef.current) {
streamRef.current.getTracks().forEach((track) => track.stop())
streamRef.current = null
}
if (wsRef.current) {
if (
wsRef.current.readyState === WebSocket.OPEN ||
wsRef.current.readyState === WebSocket.CONNECTING
) {
wsRef.current.close()
}
wsRef.current = null
}
pcmBufferRef.current = []
isFirstChunkRef.current = true
}, [])
const startStreaming = useCallback(async () => {
if (startingRef.current) return false
startingRef.current = true
try {
const tokenResponse = await fetch('/api/speech/token', {
method: 'POST',
credentials: 'include',
})
if (!tokenResponse.ok) {
const body = await tokenResponse.json().catch(() => ({}))
throw new Error(body.error || 'Failed to get speech token')
}
const { token } = await tokenResponse.json()
if (!mountedRef.current) return false
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
channelCount: 1,
sampleRate: SAMPLE_RATE,
},
})
if (!mountedRef.current) {
stream.getTracks().forEach((track) => track.stop())
return false
}
setPermissionState('granted')
streamRef.current = stream
const params = new URLSearchParams({
token,
model_id: 'scribe_v2_realtime',
audio_format: 'pcm_16000',
commit_strategy: 'vad',
vad_silence_threshold_secs: '1.0',
})
if (languageRef.current) {
params.set('language_code', languageRef.current)
}
const ws = new WebSocket(`${ELEVENLABS_WS_URL}?${params.toString()}`)
wsRef.current = ws
committedTextRef.current = ''
isFirstChunkRef.current = true
await new Promise<void>((resolve, reject) => {
ws.onopen = () => resolve()
ws.onerror = () => reject(new Error('WebSocket connection failed'))
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data)
if (msg.message_type === 'partial_transcript' && msg.text) {
if (mountedRef.current) {
const full = committedTextRef.current
? `${committedTextRef.current} ${msg.text}`
: msg.text
onTranscriptRef.current(full)
}
} else if (
(msg.message_type === 'committed_transcript' ||
msg.message_type === 'committed_transcript_with_timestamps') &&
msg.text
) {
committedTextRef.current = committedTextRef.current
? `${committedTextRef.current} ${msg.text}`
: msg.text
if (mountedRef.current) {
onTranscriptRef.current(committedTextRef.current)
}
} else if (
msg.message_type === 'error' ||
msg.message_type === 'auth_error' ||
msg.message_type === 'quota_exceeded'
) {
logger.error('ElevenLabs STT error', { type: msg.message_type, error: msg.error })
}
} catch {
// Ignore non-JSON messages
}
}
ws.onclose = () => {
if (mountedRef.current) {
setIsListening(false)
}
cleanup()
}
})
if (!mountedRef.current) {
ws.close()
stream.getTracks().forEach((track) => track.stop())
return false
}
const audioContext = new AudioContext({ sampleRate: SAMPLE_RATE })
audioContextRef.current = audioContext
const source = audioContext.createMediaStreamSource(stream)
const processor = audioContext.createScriptProcessor(4096, 1, 1)
processorRef.current = processor
processor.onaudioprocess = (e) => {
const input = e.inputBuffer.getChannelData(0)
pcmBufferRef.current.push(new Float32Array(input))
}
source.connect(processor)
processor.connect(audioContext.destination)
sendIntervalRef.current = setInterval(flushAudioBuffer, CHUNK_SEND_INTERVAL_MS)
setIsListening(true)
sessionTimerRef.current = setTimeout(() => {
logger.info('Voice input session reached max duration, stopping')
stopStreamingRef.current()
}, MAX_SESSION_MS)
return true
} catch (error) {
logger.error('Failed to start speech streaming', error)
cleanup()
if (error instanceof DOMException && error.name === 'NotAllowedError') {
setPermissionState('denied')
}
return false
} finally {
startingRef.current = false
}
}, [cleanup, flushAudioBuffer])
const stopStreaming = useCallback(() => {
if (sessionTimerRef.current) {
clearTimeout(sessionTimerRef.current)
sessionTimerRef.current = null
}
flushAudioBuffer()
const ws = wsRef.current
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(
JSON.stringify({
message_type: 'input_audio_chunk',
audio_base_64: '',
sample_rate: SAMPLE_RATE,
commit: true,
})
)
}
if (sendIntervalRef.current) {
clearInterval(sendIntervalRef.current)
sendIntervalRef.current = null
}
if (processorRef.current) {
processorRef.current.disconnect()
processorRef.current = null
}
if (audioContextRef.current && audioContextRef.current.state !== 'closed') {
audioContextRef.current.close().catch(() => {})
audioContextRef.current = null
}
if (streamRef.current) {
streamRef.current.getTracks().forEach((track) => track.stop())
streamRef.current = null
}
const wsToClose = wsRef.current
wsRef.current = null
if (wsToClose) {
setTimeout(() => {
if (
wsToClose.readyState === WebSocket.OPEN ||
wsToClose.readyState === WebSocket.CONNECTING
) {
wsToClose.close()
}
}, 2000)
}
setIsListening(false)
}, [flushAudioBuffer])
stopStreamingRef.current = stopStreaming
const resetTranscript = useCallback(() => {
committedTextRef.current = ''
isFirstChunkRef.current = true
}, [])
const toggleListening = useCallback(() => {
if (isListening) {
stopStreaming()
} else {
startStreaming()
}
}, [isListening, startStreaming, stopStreaming])
useEffect(() => {
mountedRef.current = true
return () => {
mountedRef.current = false
cleanup()
}
}, [cleanup])
return {
isListening,
isSupported,
permissionState,
toggleListening,
resetTranscript,
}
}

View File

@@ -8,6 +8,7 @@
import { createLogger } from '@sim/logger'
import { env } from '@/lib/core/config/env'
import { isHosted } from '@/lib/core/config/feature-flags'
import { getClientIp } from '@/lib/core/utils/request'
import { getBaseDomain } from '@/lib/core/utils/urls'
const logger = createLogger('ProfoundAnalytics')
@@ -101,10 +102,10 @@ export function sendToProfound(request: Request, statusCode: number): void {
host: getBaseDomain(),
path: url.pathname,
status_code: statusCode,
ip:
request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
request.headers.get('x-real-ip') ||
'0.0.0.0',
ip: (() => {
const resolved = getClientIp(request)
return resolved === 'unknown' ? '0.0.0.0' : resolved
})(),
user_agent: request.headers.get('user-agent') || '',
...(Object.keys(queryParams).length > 0 && { query_params: queryParams }),
...(request.headers.get('referer') && { referer: request.headers.get('referer')! }),

View File

@@ -2,6 +2,7 @@ import { auditLog, db } from '@sim/db'
import { user } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { getClientIp } from '@/lib/core/utils/request'
import { generateShortId } from '@/lib/core/utils/uuid'
const logger = createLogger('AuditLog')
@@ -236,10 +237,7 @@ export function recordAudit(params: AuditLogParams): void {
}
async function insertAuditLog(params: AuditLogParams): Promise<void> {
const ipAddress =
params.request?.headers.get('x-forwarded-for')?.split(',')[0].trim() ??
params.request?.headers.get('x-real-ip') ??
undefined
const ipAddress = params.request ? getClientIp(params.request) : undefined
const userAgent = params.request?.headers.get('user-agent') ?? undefined
let { actorName, actorEmail } = params

View File

@@ -3,6 +3,7 @@ import { jwtVerify, SignJWT } from 'jose'
import { type NextRequest, NextResponse } from 'next/server'
import { env } from '@/lib/core/config/env'
import { safeCompare } from '@/lib/core/security/encryption'
import { getClientIp } from '@/lib/core/utils/request'
const logger = createLogger('CronAuth')
@@ -73,7 +74,7 @@ export function verifyCronAuth(request: NextRequest, context?: string): NextResp
if (!env.CRON_SECRET) {
const contextInfo = context ? ` for ${context}` : ''
logger.warn(`CRON endpoint accessed but CRON_SECRET is not configured${contextInfo}`, {
ip: request.headers.get('x-forwarded-for') ?? request.headers.get('x-real-ip') ?? 'unknown',
ip: getClientIp(request),
userAgent: request.headers.get('user-agent') ?? 'unknown',
context,
})
@@ -87,7 +88,7 @@ export function verifyCronAuth(request: NextRequest, context?: string): NextResp
const contextInfo = context ? ` for ${context}` : ''
logger.warn(`Unauthorized CRON access attempt${contextInfo}`, {
providedAuth: authHeader,
ip: request.headers.get('x-forwarded-for') ?? request.headers.get('x-real-ip') ?? 'unknown',
ip: getClientIp(request),
userAgent: request.headers.get('user-agent') ?? 'unknown',
context,
})

View File

@@ -24,10 +24,10 @@ import {
import type { UserSubscriptionState } from '@/lib/billing/types'
import {
isAccessControlEnabled,
isBillingEnabled,
isCredentialSetsEnabled,
isHosted,
isInboxEnabled,
isProd,
isSsoEnabled,
} from '@/lib/core/config/feature-flags'
import { getBaseUrl } from '@/lib/core/utils/urls'
@@ -98,7 +98,7 @@ export async function hasPaidSubscription(referenceId: string): Promise<boolean>
*/
export async function isProPlan(userId: string): Promise<boolean> {
try {
if (!isProd) {
if (!isBillingEnabled) {
return true
}
@@ -125,7 +125,7 @@ export async function isProPlan(userId: string): Promise<boolean> {
*/
export async function isTeamPlan(userId: string): Promise<boolean> {
try {
if (!isProd) {
if (!isBillingEnabled) {
return true
}
@@ -149,7 +149,7 @@ export async function isTeamPlan(userId: string): Promise<boolean> {
*/
export async function isEnterprisePlan(userId: string): Promise<boolean> {
try {
if (!isProd) {
if (!isBillingEnabled) {
return true
}
@@ -177,7 +177,7 @@ export async function isEnterprisePlan(userId: string): Promise<boolean> {
*/
export async function isEnterpriseOrgAdminOrOwner(userId: string): Promise<boolean> {
try {
if (!isProd) {
if (!isBillingEnabled) {
return true
}
@@ -241,7 +241,7 @@ export async function isEnterpriseOrgAdminOrOwner(userId: string): Promise<boole
*/
export async function isTeamOrgAdminOrOwner(userId: string): Promise<boolean> {
try {
if (!isProd) {
if (!isBillingEnabled) {
return true
}
@@ -304,7 +304,7 @@ export async function isOrganizationOnTeamOrEnterprisePlan(
organizationId: string
): Promise<boolean> {
try {
if (!isProd) {
if (!isBillingEnabled) {
return true
}
@@ -340,7 +340,7 @@ export async function isOrganizationOnTeamOrEnterprisePlan(
*/
export async function isOrganizationOnEnterprisePlan(organizationId: string): Promise<boolean> {
try {
if (!isProd) {
if (!isBillingEnabled) {
return true
}
@@ -445,7 +445,7 @@ export async function hasInboxAccess(userId: string): Promise<boolean> {
if (isInboxEnabled) {
return true
}
if (!isProd) {
if (!isBillingEnabled) {
return true
}
const [sub, billingStatus] = await Promise.all([
@@ -490,7 +490,7 @@ export async function hasLiveSyncAccess(userId: string): Promise<boolean> {
*/
export async function hasExceededCostLimit(userId: string): Promise<boolean> {
try {
if (!isProd) {
if (!isBillingEnabled) {
return false
}
@@ -560,7 +560,7 @@ export async function getUserSubscriptionState(userId: string): Promise<UserSubs
// Determine plan types based on subscription (avoid redundant DB calls)
const isPro =
!isProd ||
!isBillingEnabled ||
!!(
subscription &&
(checkProPlan(subscription) ||
@@ -568,9 +568,9 @@ export async function getUserSubscriptionState(userId: string): Promise<UserSubs
checkEnterprisePlan(subscription))
)
const isTeam =
!isProd ||
!isBillingEnabled ||
!!(subscription && (checkTeamPlan(subscription) || checkEnterprisePlan(subscription)))
const isEnterprise = !isProd || !!(subscription && checkEnterprisePlan(subscription))
const isEnterprise = !isBillingEnabled || !!(subscription && checkEnterprisePlan(subscription))
const isFree = !isPro && !isTeam && !isEnterprise
// Determine plan name
@@ -581,7 +581,7 @@ export async function getUserSubscriptionState(userId: string): Promise<UserSubs
// Check cost limit using already-fetched user stats
let hasExceededLimit = false
if (isProd && statsRecords.length > 0) {
if (isBillingEnabled && statsRecords.length > 0) {
let limit = getFreeTierLimit() // Default free tier limit
if (subscription) {
// Team/Enterprise: Use organization limit

View File

@@ -23,6 +23,7 @@ export type UsageLogSource =
| 'mcp_copilot'
| 'mothership_block'
| 'knowledge-base'
| 'voice-input'
/**
* Metadata for 'model' category charges

View File

@@ -6,6 +6,7 @@ import type { BrandConfig } from './types'
export const defaultBrandConfig: BrandConfig = {
name: 'Sim',
logoUrl: undefined,
wordmarkUrl: undefined,
faviconUrl: undefined,
customCssUrl: undefined,
supportEmail: 'help@sim.ai',

View File

@@ -1,2 +1,3 @@
export { defaultBrandConfig } from './defaults'
export type { BrandConfig, ThemeColors } from './types'
export type { BrandConfig, OrganizationWhitelabelSettings, ThemeColors } from './types'
export { HEX_COLOR_REGEX } from './types'

View File

@@ -1,3 +1,6 @@
/** Matches 3- or 6-digit hex colors, e.g. `#abc` or `#701ffc`. */
export const HEX_COLOR_REGEX = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i
export interface ThemeColors {
primaryColor?: string
primaryHoverColor?: string
@@ -9,6 +12,7 @@ export interface ThemeColors {
export interface BrandConfig {
name: string
logoUrl?: string
wordmarkUrl?: string
faviconUrl?: string
customCssUrl?: string
supportEmail?: string
@@ -19,3 +23,22 @@ export interface BrandConfig {
/** Whether this instance has custom branding applied (any brand env var is set) */
isWhitelabeled: boolean
}
/**
* Per-organization whitelabel settings stored in the database.
* Only available for enterprise organizations on the hosted platform.
*/
export interface OrganizationWhitelabelSettings {
brandName?: string
logoUrl?: string
wordmarkUrl?: string
primaryColor?: string
primaryHoverColor?: string
accentColor?: string
accentHoverColor?: string
supportEmail?: string
documentationUrl?: string
termsUrl?: string
privacyUrl?: string
hidePoweredBySim?: boolean
}

View File

@@ -95,6 +95,8 @@ export const buildTimeCSPDirectives: CSPDirectives = {
? ['http://localhost:3002', 'ws://localhost:3002']
: []),
'https://api.browser-use.com',
'https://api.elevenlabs.io',
'wss://api.elevenlabs.io',
'https://api.exa.ai',
'https://api.firecrawl.dev',
'https://*.googleapis.com',
@@ -197,7 +199,7 @@ export function generateRuntimeCSP(): string {
img-src 'self' data: blob: https://*.googleusercontent.com https://*.google.com https://*.atlassian.com https://cdn.discordapp.com https://*.githubusercontent.com https://*.s3.amazonaws.com https://s3.amazonaws.com https://*.amazonaws.com https://*.blob.core.windows.net https://github.com/* https://collector.onedollarstats.com ${gtmImg} ${brandLogoDomain} ${brandFaviconDomain};
media-src 'self' blob:;
font-src 'self' https://fonts.gstatic.com;
connect-src 'self' ${appUrl} ${ollamaUrl} ${socketUrl} ${socketWsUrl} https://api.browser-use.com https://api.exa.ai https://api.firecrawl.dev https://*.googleapis.com https://*.amazonaws.com https://*.s3.amazonaws.com https://*.blob.core.windows.net https://api.github.com https://github.com/* https://*.atlassian.com https://*.supabase.co https://challenges.cloudflare.com https://collector.onedollarstats.com ${gtmConnect} ${dynamicDomainsStr};
connect-src 'self' ${appUrl} ${ollamaUrl} ${socketUrl} ${socketWsUrl} https://api.browser-use.com https://api.elevenlabs.io wss://api.elevenlabs.io https://api.exa.ai https://api.firecrawl.dev https://*.googleapis.com https://*.amazonaws.com https://*.s3.amazonaws.com https://*.blob.core.windows.net https://api.github.com https://github.com/* https://*.atlassian.com https://*.supabase.co https://challenges.cloudflare.com https://collector.onedollarstats.com ${gtmConnect} ${dynamicDomainsStr};
frame-src 'self' https://challenges.cloudflare.com https://drive.google.com https://docs.google.com https://*.google.com ${gtmFrame};
frame-ancestors 'self';
form-action 'self';

View File

@@ -6,6 +6,17 @@ export function generateRequestId(): string {
return generateId().slice(0, 8)
}
/**
* Extract the client IP from a request, checking `x-forwarded-for` then `x-real-ip`.
*/
export function getClientIp(request: { headers: { get(name: string): string | null } }): string {
return (
request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
request.headers.get('x-real-ip')?.trim() ||
'unknown'
)
}
/**
* No-operation function for use as default callback
*/

View File

@@ -46,6 +46,7 @@ const MAX_PAGES = 500
const MAX_SAFE_TITLE_LENGTH = 200
const STALE_PROCESSING_MINUTES = 45
const RETRY_WINDOW_DAYS = 7
const MAX_CONSECUTIVE_FAILURES = 10
/** Sanitizes a document title for use in S3 storage keys. */
function sanitizeStorageTitle(title: string): string {
@@ -230,7 +231,7 @@ async function resolveAccessToken(
connector: { credentialId: string | null; encryptedApiKey: string | null },
connectorConfig: { auth: ConnectorAuthConfig },
userId: string
): Promise<string | null> {
): Promise<string> {
if (connectorConfig.auth.mode === 'apiKey') {
if (!connector.encryptedApiKey) {
throw new Error('API key connector is missing encrypted API key')
@@ -243,11 +244,22 @@ async function resolveAccessToken(
throw new Error('OAuth connector is missing credential ID')
}
return refreshAccessTokenIfNeeded(
connector.credentialId,
userId,
`sync-${connector.credentialId}`
)
const requestId = `sync-${connector.credentialId}`
const token = await refreshAccessTokenIfNeeded(connector.credentialId, userId, requestId)
if (!token) {
logger.error(`[${requestId}] refreshAccessTokenIfNeeded returned null`, {
credentialId: connector.credentialId,
userId,
authMode: connectorConfig.auth.mode,
authProvider: connectorConfig.auth.provider,
})
throw new Error(
`Failed to obtain access token for credential ${connector.credentialId} (provider: ${connectorConfig.auth.provider})`
)
}
return token
}
/**
@@ -305,12 +317,6 @@ export async function executeSync(
const userId = kbRows[0].userId
const sourceConfig = connector.sourceConfig as Record<string, unknown>
let accessToken = await resolveAccessToken(connector, connectorConfig, userId)
if (!accessToken) {
throw new Error('Failed to obtain access token')
}
const lockResult = await db
.update(knowledgeConnector)
.set({ status: 'syncing', updatedAt: new Date() })
@@ -341,6 +347,8 @@ export async function executeSync(
let syncExitedCleanly = false
try {
let accessToken = await resolveAccessToken(connector, connectorConfig, userId)
const externalDocs: ExternalDocument[] = []
let cursor: string | undefined
let hasMore = true
@@ -357,8 +365,7 @@ export async function executeSync(
for (let pageNum = 0; hasMore && pageNum < MAX_PAGES; pageNum++) {
if (pageNum > 0 && connectorConfig.auth.mode === 'oauth') {
const refreshed = await resolveAccessToken(connector, connectorConfig, userId)
if (refreshed) accessToken = refreshed
accessToken = await resolveAccessToken(connector, connectorConfig, userId)
}
const page = await connectorConfig.listDocuments(
@@ -496,8 +503,7 @@ export async function executeSync(
if (deferredOps.length > 0) {
if (connectorConfig.auth.mode === 'oauth') {
const refreshed = await resolveAccessToken(connector, connectorConfig, userId)
if (refreshed) accessToken = refreshed
accessToken = await resolveAccessToken(connector, connectorConfig, userId)
}
const hydrated = await Promise.allSettled(
@@ -789,15 +795,25 @@ export async function executeSync(
const now = new Date()
const failures = (connector.consecutiveFailures ?? 0) + 1
const disabled = failures >= MAX_CONSECUTIVE_FAILURES
const backoffMinutes = Math.min(failures * 30, 1440)
const nextSync = new Date(now.getTime() + backoffMinutes * 60 * 1000)
const nextSync = disabled ? null : new Date(now.getTime() + backoffMinutes * 60 * 1000)
if (disabled) {
logger.warn('Connector disabled after repeated failures', {
connectorId,
consecutiveFailures: failures,
})
}
await db
.update(knowledgeConnector)
.set({
status: 'error',
status: disabled ? 'disabled' : 'error',
lastSyncAt: now,
lastSyncError: errorMessage,
lastSyncError: disabled
? 'Connector disabled after repeated sync failures. Please reconnect.'
: errorMessage,
nextSyncAt: nextSync,
consecutiveFailures: failures,
updatedAt: now,

View File

@@ -0,0 +1,25 @@
/**
* Convert Float32 PCM samples to 16-bit signed integer PCM.
* Required for ElevenLabs realtime STT (pcm_16000 format).
*/
export function floatTo16BitPCM(float32Array: Float32Array): ArrayBuffer {
const buffer = new ArrayBuffer(float32Array.length * 2)
const view = new DataView(buffer)
for (let i = 0; i < float32Array.length; i++) {
const s = Math.max(-1, Math.min(1, float32Array[i]))
view.setInt16(i * 2, s < 0 ? s * 0x8000 : s * 0x7fff, true)
}
return buffer
}
/**
* Encode an ArrayBuffer as a base64 string for WebSocket transport.
*/
export function arrayBufferToBase64(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer)
let binary = ''
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i])
}
return btoa(binary)
}

View File

@@ -0,0 +1,16 @@
import { env } from '@/lib/core/config/env'
export const ELEVENLABS_WS_URL = 'wss://api.elevenlabs.io/v1/speech-to-text/realtime'
export const SAMPLE_RATE = 16000
export const CHUNK_SEND_INTERVAL_MS = 250
export const MAX_SESSION_MS = 3 * 60 * 1000
export const MAX_CHAT_SESSION_MS = 1 * 60 * 1000
/**
* Whether a speech-to-text provider is configured.
* Currently checks for `ELEVENLABS_API_KEY`.
* To add a new provider: add its env check here.
*/
export function hasSTTService(): boolean {
return !!env.ELEVENLABS_API_KEY?.trim()
}

View File

@@ -725,7 +725,15 @@ export async function processPolledWebhookEvent(
try {
const preprocessResult = await checkWebhookPreprocessing(foundWorkflow, foundWebhook, requestId)
if (preprocessResult.error) {
return { success: false, error: 'Preprocessing failed', statusCode: 500 }
const errorResponse = preprocessResult.error
const statusCode = errorResponse.status
const errorBody = await errorResponse.json().catch(() => ({}))
const errorMessage = errorBody.error ?? 'Preprocessing failed'
logger.warn(`[${requestId}] Polled webhook preprocessing failed`, {
statusCode,
error: errorMessage,
})
return { success: false, error: errorMessage, statusCode }
}
if (foundWebhook.blockId) {

View File

@@ -1,5 +1,6 @@
import { createLogger } from '@sim/logger'
import { NextResponse } from 'next/server'
import { getClientIp } from '@/lib/core/utils/request'
import type {
AuthContext,
EventFilterContext,
@@ -30,10 +31,7 @@ export const genericHandler: WebhookProviderHandler = {
const allowedIps = providerConfig.allowedIps
if (allowedIps && Array.isArray(allowedIps) && allowedIps.length > 0) {
const clientIp =
request.headers.get('x-forwarded-for')?.split(',')[0].trim() ||
request.headers.get('x-real-ip') ||
'unknown'
const clientIp = getClientIp(request)
if (clientIp === 'unknown' || !allowedIps.includes(clientIp)) {
logger.warn(`[${requestId}] Forbidden webhook access attempt - IP not allowed: ${clientIp}`)

View File

@@ -1,6 +1,6 @@
import { normalizeName, REFERENCE } from '@/executor/constants'
export const SYSTEM_REFERENCE_PREFIXES = new Set(['start', 'loop', 'parallel', 'variable'])
export const SYSTEM_REFERENCE_PREFIXES = new Set(['loop', 'parallel', 'variable'])
const INVALID_REFERENCE_CHARS = /[+*/=<>!]/

View File

@@ -4,6 +4,7 @@ import { type NextRequest, NextResponse } from 'next/server'
import { sendToProfound } from './lib/analytics/profound'
import { isAuthDisabled, isHosted } from './lib/core/config/feature-flags'
import { generateRuntimeCSP } from './lib/core/security/csp'
import { getClientIp } from './lib/core/utils/request'
const logger = createLogger('Proxy')
@@ -114,7 +115,7 @@ function handleSecurityFiltering(request: NextRequest): NextResponse | null {
if (isSuspicious && !isWebhookEndpoint && !isMcpEndpoint && !isMcpOauthDiscoveryEndpoint) {
logger.warn('Blocked suspicious request', {
userAgent,
ip: request.headers.get('x-forwarded-for') || 'unknown',
ip: getClientIp(request),
url: request.url,
method: request.method,
pattern: SUSPICIOUS_UA_PATTERNS.find((pattern) => pattern.test(userAgent))?.toString(),

View File

@@ -21,7 +21,14 @@ export default defineConfig({
files: ['./lib/execution/isolated-vm-worker.cjs', './lib/execution/pptx-worker.cjs'],
}),
additionalPackages({
packages: ['unpdf', 'pdf-lib', 'isolated-vm', 'pptxgenjs'],
packages: [
'unpdf',
'pdf-lib',
'isolated-vm',
'pptxgenjs',
'react-dom',
'@react-email/render',
],
}),
],
},

View File

@@ -181,7 +181,8 @@ app:
# UI Branding & Whitelabeling Configuration
NEXT_PUBLIC_BRAND_NAME: "Sim" # Custom brand name
NEXT_PUBLIC_BRAND_LOGO_URL: "" # Custom logo URL (leave empty for default)
NEXT_PUBLIC_BRAND_LOGO_URL: "" # Custom logo URL — square icon, shown in collapsed sidebar
NEXT_PUBLIC_BRAND_WORDMARK_URL: "" # Custom wordmark URL — wide image, shown in expanded sidebar
NEXT_PUBLIC_BRAND_FAVICON_URL: "" # Custom favicon URL (leave empty for default)
NEXT_PUBLIC_BRAND_PRIMARY_COLOR: "" # Primary brand color (hex, e.g., "#701a75")
NEXT_PUBLIC_BRAND_ACCENT_COLOR: "" # Accent color (hex, e.g., "#9333ea")

View File

@@ -0,0 +1 @@
ALTER TYPE "public"."usage_log_source" ADD VALUE 'voice-input';

View File

@@ -0,0 +1 @@
ALTER TABLE "organization" ADD COLUMN "whitelabel_settings" json;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1303,6 +1303,20 @@
"when": 1775525922688,
"tag": "0186_greedy_jocasta",
"breakpoints": true
},
{
"idx": 187,
"version": "7",
"when": 1775630332078,
"tag": "0187_fuzzy_wendell_vaughn",
"breakpoints": true
},
{
"idx": 188,
"version": "7",
"when": 1775666653692,
"tag": "0188_short_luke_cage",
"breakpoints": true
}
]
}

View File

@@ -947,6 +947,19 @@ export const organization = pgTable('organization', {
slug: text('slug').notNull(),
logo: text('logo'),
metadata: json('metadata'),
whitelabelSettings: json('whitelabel_settings').$type<{
brandName?: string
logoUrl?: string
primaryColor?: string
primaryHoverColor?: string
accentColor?: string
accentHoverColor?: string
supportEmail?: string
documentationUrl?: string
termsUrl?: string
privacyUrl?: string
hidePoweredBySim?: boolean
}>(),
orgUsageLimit: decimal('org_usage_limit'),
storageUsedBytes: bigint('storage_used_bytes', { mode: 'number' }).notNull().default(0),
departedMemberUsage: decimal('departed_member_usage').notNull().default('0'),
@@ -2276,6 +2289,7 @@ export const usageLogSourceEnum = pgEnum('usage_log_source', [
'mcp_copilot',
'mothership_block',
'knowledge-base',
'voice-input',
])
export const usageLog = pgTable(

View File

@@ -0,0 +1,12 @@
import { describe, expect, it } from 'vitest'
import { createTableColumn } from './table.factory'
describe('table factory', () => {
it('generates default column names that match table naming rules', () => {
const generatedNames = Array.from({ length: 100 }, () => createTableColumn().name)
for (const name of generatedNames) {
expect(name).toMatch(/^[a-z_][a-z0-9_]*$/)
}
})
})

View File

@@ -0,0 +1,62 @@
import { customAlphabet, nanoid } from 'nanoid'
export type TableColumnType = 'string' | 'number' | 'boolean' | 'date' | 'json'
export interface TableColumnFixture {
name: string
type: TableColumnType
required?: boolean
unique?: boolean
}
export interface TableRowFixture {
id: string
data: Record<string, unknown>
position: number
createdAt: string
updatedAt: string
}
export interface TableColumnFactoryOptions {
name?: string
type?: TableColumnType
required?: boolean
unique?: boolean
}
export interface TableRowFactoryOptions {
id?: string
data?: Record<string, unknown>
position?: number
createdAt?: string
updatedAt?: string
}
const createTableColumnSuffix = customAlphabet('abcdefghijklmnopqrstuvwxyz0123456789_', 6)
/**
* Creates a table column fixture with sensible defaults.
*/
export function createTableColumn(options: TableColumnFactoryOptions = {}): TableColumnFixture {
return {
name: options.name ?? `column_${createTableColumnSuffix()}`,
type: options.type ?? 'string',
required: options.required,
unique: options.unique,
}
}
/**
* Creates a table row fixture with sensible defaults.
*/
export function createTableRow(options: TableRowFactoryOptions = {}): TableRowFixture {
const timestamp = new Date().toISOString()
return {
id: options.id ?? `row_${nanoid(8)}`,
data: options.data ?? {},
position: options.position ?? 0,
createdAt: options.createdAt ?? timestamp,
updatedAt: options.updatedAt ?? timestamp,
}
}

View File

@@ -1434,13 +1434,8 @@ function parseConstFieldContent(
* 1. Const reference (e.g., `outputs: GIT_REF_OUTPUT_PROPERTIES,`)
* 2. Inline object (e.g., `outputs: { id: { type: 'string', ... } }`)
*/
function extractOutputsFromToolContent(
content: string,
toolPrefix: string
): Record<string, any> {
const constMatch = content.match(
/(?<![a-zA-Z_])outputs\s*:\s*([A-Z][A-Z_0-9]+)\s*(?:,|\}|$)/
)
function extractOutputsFromToolContent(content: string, toolPrefix: string): Record<string, any> {
const constMatch = content.match(/(?<![a-zA-Z_])outputs\s*:\s*([A-Z][A-Z_0-9]+)\s*(?:,|\}|$)/)
if (constMatch) {
const resolved = resolveConstReference(constMatch[1], toolPrefix)
if (resolved && typeof resolved === 'object') {
@@ -1649,8 +1644,7 @@ function extractToolInfo(
lastExportMatch = m
}
if (lastExportMatch && lastExportMatch.index !== undefined) {
const bracePos =
lastExportMatch.index + lastExportMatch[0].length - 1
const bracePos = lastExportMatch.index + lastExportMatch[0].length - 1
const ep = findMatchingClose(fileContent, bracePos)
if (ep !== -1) {
fullToolBlock = fileContent.substring(bracePos, ep)
@@ -1665,8 +1659,7 @@ function extractToolInfo(
)
const baseToolMatch = fileContent.match(baseToolRegex)
if (baseToolMatch && baseToolMatch.index !== undefined) {
const baseStart =
baseToolMatch.index + baseToolMatch[0].length - 1
const baseStart = baseToolMatch.index + baseToolMatch[0].length - 1
const endIdx = findMatchingClose(fileContent, baseStart)
if (endIdx !== -1) {
const baseToolContent = fileContent.substring(baseStart, endIdx)

View File

@@ -1,5 +1,5 @@
{
"$schema": "https://v2-9-4.turborepo.dev/schema.json",
"$schema": "https://v2-9-5.turborepo.dev/schema.json",
"envMode": "loose",
"tasks": {
"transit": {