mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-09 23:17:59 -05:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97b6bcc43d | ||
|
|
42917ce641 | ||
|
|
5f6d219223 | ||
|
|
bab74307f4 | ||
|
|
16aaa37dad | ||
|
|
c6166a9483 | ||
|
|
0258a1b4ce | ||
|
|
4d4aefa346 | ||
|
|
a0cf003abf | ||
|
|
2e027dd77d |
@@ -3,8 +3,7 @@ import { eq, sql } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkInternalApiKey } from '@/lib/copilot/utils'
|
||||
import { env } from '@/lib/env'
|
||||
import { isBillingEnabled, isProd } from '@/lib/environment'
|
||||
import { isBillingEnabled } from '@/lib/environment'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { db } from '@/db'
|
||||
import { userStats } from '@/db/schema'
|
||||
@@ -17,6 +16,7 @@ const UpdateCostSchema = z.object({
|
||||
input: z.number().min(0, 'Input tokens must be a non-negative number'),
|
||||
output: z.number().min(0, 'Output tokens must be a non-negative number'),
|
||||
model: z.string().min(1, 'Model is required'),
|
||||
multiplier: z.number().min(0),
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -75,27 +75,27 @@ export async function POST(req: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
const { userId, input, output, model } = validation.data
|
||||
const { userId, input, output, model, multiplier } = validation.data
|
||||
|
||||
logger.info(`[${requestId}] Processing cost update`, {
|
||||
userId,
|
||||
input,
|
||||
output,
|
||||
model,
|
||||
multiplier,
|
||||
})
|
||||
|
||||
const finalPromptTokens = input
|
||||
const finalCompletionTokens = output
|
||||
const totalTokens = input + output
|
||||
|
||||
// Calculate cost using COPILOT_COST_MULTIPLIER (only in production, like normal executions)
|
||||
const copilotMultiplier = isProd ? env.COPILOT_COST_MULTIPLIER || 1 : 1
|
||||
// Calculate cost using provided multiplier (required)
|
||||
const costResult = calculateCost(
|
||||
model,
|
||||
finalPromptTokens,
|
||||
finalCompletionTokens,
|
||||
false,
|
||||
copilotMultiplier
|
||||
multiplier
|
||||
)
|
||||
|
||||
logger.info(`[${requestId}] Cost calculation result`, {
|
||||
@@ -104,7 +104,7 @@ export async function POST(req: NextRequest) {
|
||||
promptTokens: finalPromptTokens,
|
||||
completionTokens: finalCompletionTokens,
|
||||
totalTokens: totalTokens,
|
||||
copilotMultiplier,
|
||||
multiplier,
|
||||
costResult,
|
||||
})
|
||||
|
||||
@@ -127,6 +127,10 @@ export async function POST(req: NextRequest) {
|
||||
totalTokensUsed: totalTokens,
|
||||
totalCost: costToStore.toString(),
|
||||
currentPeriodCost: costToStore.toString(),
|
||||
// Copilot usage tracking
|
||||
totalCopilotCost: costToStore.toString(),
|
||||
totalCopilotTokens: totalTokens,
|
||||
totalCopilotCalls: 1,
|
||||
lastActive: new Date(),
|
||||
})
|
||||
|
||||
@@ -141,6 +145,10 @@ export async function POST(req: NextRequest) {
|
||||
totalTokensUsed: sql`total_tokens_used + ${totalTokens}`,
|
||||
totalCost: sql`total_cost + ${costToStore}`,
|
||||
currentPeriodCost: sql`current_period_cost + ${costToStore}`,
|
||||
// Copilot usage tracking increments
|
||||
totalCopilotCost: sql`total_copilot_cost + ${costToStore}`,
|
||||
totalCopilotTokens: sql`total_copilot_tokens + ${totalTokens}`,
|
||||
totalCopilotCalls: sql`total_copilot_calls + 1`,
|
||||
totalApiCalls: sql`total_api_calls`,
|
||||
lastActive: new Date(),
|
||||
}
|
||||
|
||||
70
apps/sim/app/api/copilot/api-keys/generate/route.ts
Normal file
70
apps/sim/app/api/copilot/api-keys/generate/route.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { createCipheriv, createHash, createHmac, randomBytes } from 'crypto'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { env } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { generateApiKey } from '@/lib/utils'
|
||||
import { db } from '@/db'
|
||||
import { copilotApiKeys } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('CopilotApiKeysGenerate')
|
||||
|
||||
function deriveKey(keyString: string): Buffer {
|
||||
return createHash('sha256').update(keyString, 'utf8').digest()
|
||||
}
|
||||
|
||||
function encryptRandomIv(plaintext: string, keyString: string): string {
|
||||
const key = deriveKey(keyString)
|
||||
const iv = randomBytes(16)
|
||||
const cipher = createCipheriv('aes-256-gcm', key, iv)
|
||||
let encrypted = cipher.update(plaintext, 'utf8', 'hex')
|
||||
encrypted += cipher.final('hex')
|
||||
const authTag = cipher.getAuthTag().toString('hex')
|
||||
return `${iv.toString('hex')}:${encrypted}:${authTag}`
|
||||
}
|
||||
|
||||
function computeLookup(plaintext: string, keyString: string): string {
|
||||
// Deterministic, constant-time comparable MAC: HMAC-SHA256(DB_KEY, plaintext)
|
||||
return createHmac('sha256', Buffer.from(keyString, 'utf8'))
|
||||
.update(plaintext, 'utf8')
|
||||
.digest('hex')
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (!env.AGENT_API_DB_ENCRYPTION_KEY) {
|
||||
logger.error('AGENT_API_DB_ENCRYPTION_KEY is not set')
|
||||
return NextResponse.json({ error: 'Server not configured' }, { status: 500 })
|
||||
}
|
||||
|
||||
const userId = session.user.id
|
||||
|
||||
// Generate and prefix the key (strip the generic sim_ prefix from the random part)
|
||||
const rawKey = generateApiKey().replace(/^sim_/, '')
|
||||
const plaintextKey = `sk-sim-copilot-${rawKey}`
|
||||
|
||||
// Encrypt with random IV for confidentiality
|
||||
const dbEncrypted = encryptRandomIv(plaintextKey, env.AGENT_API_DB_ENCRYPTION_KEY)
|
||||
|
||||
// Compute deterministic lookup value for O(1) search
|
||||
const lookup = computeLookup(plaintextKey, env.AGENT_API_DB_ENCRYPTION_KEY)
|
||||
|
||||
const [inserted] = await db
|
||||
.insert(copilotApiKeys)
|
||||
.values({ userId, apiKeyEncrypted: dbEncrypted, apiKeyLookup: lookup })
|
||||
.returning({ id: copilotApiKeys.id })
|
||||
|
||||
return NextResponse.json(
|
||||
{ success: true, key: { id: inserted.id, apiKey: plaintextKey } },
|
||||
{ status: 201 }
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error('Failed to generate copilot API key', { error })
|
||||
return NextResponse.json({ error: 'Failed to generate copilot API key' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
85
apps/sim/app/api/copilot/api-keys/route.ts
Normal file
85
apps/sim/app/api/copilot/api-keys/route.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { createDecipheriv, createHash } from 'crypto'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { env } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { db } from '@/db'
|
||||
import { copilotApiKeys } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('CopilotApiKeys')
|
||||
|
||||
function deriveKey(keyString: string): Buffer {
|
||||
return createHash('sha256').update(keyString, 'utf8').digest()
|
||||
}
|
||||
|
||||
function decryptWithKey(encryptedValue: string, keyString: string): string {
|
||||
const parts = encryptedValue.split(':')
|
||||
if (parts.length !== 3) {
|
||||
throw new Error('Invalid encrypted value format')
|
||||
}
|
||||
const [ivHex, encryptedHex, authTagHex] = parts
|
||||
const key = deriveKey(keyString)
|
||||
const iv = Buffer.from(ivHex, 'hex')
|
||||
const decipher = createDecipheriv('aes-256-gcm', key, iv)
|
||||
decipher.setAuthTag(Buffer.from(authTagHex, 'hex'))
|
||||
let decrypted = decipher.update(encryptedHex, 'hex', 'utf8')
|
||||
decrypted += decipher.final('utf8')
|
||||
return decrypted
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (!env.AGENT_API_DB_ENCRYPTION_KEY) {
|
||||
logger.error('AGENT_API_DB_ENCRYPTION_KEY is not set')
|
||||
return NextResponse.json({ error: 'Server not configured' }, { status: 500 })
|
||||
}
|
||||
|
||||
const userId = session.user.id
|
||||
|
||||
const rows = await db
|
||||
.select({ id: copilotApiKeys.id, apiKeyEncrypted: copilotApiKeys.apiKeyEncrypted })
|
||||
.from(copilotApiKeys)
|
||||
.where(eq(copilotApiKeys.userId, userId))
|
||||
|
||||
const keys = rows.map((row) => ({
|
||||
id: row.id,
|
||||
apiKey: decryptWithKey(row.apiKeyEncrypted, env.AGENT_API_DB_ENCRYPTION_KEY as string),
|
||||
}))
|
||||
|
||||
return NextResponse.json({ keys }, { status: 200 })
|
||||
} catch (error) {
|
||||
logger.error('Failed to get copilot API keys', { error })
|
||||
return NextResponse.json({ error: 'Failed to get keys' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const userId = session.user.id
|
||||
const url = new URL(request.url)
|
||||
const id = url.searchParams.get('id')
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: 'id is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
await db
|
||||
.delete(copilotApiKeys)
|
||||
.where(and(eq(copilotApiKeys.userId, userId), eq(copilotApiKeys.id, id)))
|
||||
|
||||
return NextResponse.json({ success: true }, { status: 200 })
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete copilot API key', { error })
|
||||
return NextResponse.json({ error: 'Failed to delete key' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
78
apps/sim/app/api/copilot/api-keys/validate/route.ts
Normal file
78
apps/sim/app/api/copilot/api-keys/validate/route.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { createHmac } from 'crypto'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { env } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { db } from '@/db'
|
||||
import { copilotApiKeys, userStats } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('CopilotApiKeysValidate')
|
||||
|
||||
function computeLookup(plaintext: string, keyString: string): string {
|
||||
// Deterministic MAC: HMAC-SHA256(DB_KEY, plaintext)
|
||||
return createHmac('sha256', Buffer.from(keyString, 'utf8'))
|
||||
.update(plaintext, 'utf8')
|
||||
.digest('hex')
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
if (!env.AGENT_API_DB_ENCRYPTION_KEY) {
|
||||
logger.error('AGENT_API_DB_ENCRYPTION_KEY is not set')
|
||||
return NextResponse.json({ error: 'Server not configured' }, { status: 500 })
|
||||
}
|
||||
|
||||
const body = await req.json().catch(() => null)
|
||||
const apiKey = typeof body?.apiKey === 'string' ? body.apiKey : undefined
|
||||
|
||||
if (!apiKey) {
|
||||
return new NextResponse(null, { status: 401 })
|
||||
}
|
||||
|
||||
const lookup = computeLookup(apiKey, env.AGENT_API_DB_ENCRYPTION_KEY)
|
||||
|
||||
// Find matching API key and its user
|
||||
const rows = await db
|
||||
.select({ id: copilotApiKeys.id, userId: copilotApiKeys.userId })
|
||||
.from(copilotApiKeys)
|
||||
.where(eq(copilotApiKeys.apiKeyLookup, lookup))
|
||||
.limit(1)
|
||||
|
||||
if (rows.length === 0) {
|
||||
return new NextResponse(null, { status: 401 })
|
||||
}
|
||||
|
||||
const { userId } = rows[0]
|
||||
|
||||
// Check usage for the associated user
|
||||
const usage = await db
|
||||
.select({
|
||||
currentPeriodCost: userStats.currentPeriodCost,
|
||||
totalCost: userStats.totalCost,
|
||||
currentUsageLimit: userStats.currentUsageLimit,
|
||||
})
|
||||
.from(userStats)
|
||||
.where(eq(userStats.userId, userId))
|
||||
.limit(1)
|
||||
|
||||
if (usage.length > 0) {
|
||||
const currentUsage = Number.parseFloat(
|
||||
(usage[0].currentPeriodCost?.toString() as string) ||
|
||||
(usage[0].totalCost as unknown as string) ||
|
||||
'0'
|
||||
)
|
||||
const limit = Number.parseFloat((usage[0].currentUsageLimit as unknown as string) || '0')
|
||||
|
||||
if (!Number.isNaN(limit) && limit > 0 && currentUsage >= limit) {
|
||||
// Usage exceeded
|
||||
return new NextResponse(null, { status: 402 })
|
||||
}
|
||||
}
|
||||
|
||||
// Valid and within usage limits
|
||||
return new NextResponse(null, { status: 200 })
|
||||
} catch (error) {
|
||||
logger.error('Error validating copilot API key', { error })
|
||||
return NextResponse.json({ error: 'Failed to validate key' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -104,7 +104,7 @@ describe('Copilot Chat API Route', () => {
|
||||
vi.doMock('@/lib/env', () => ({
|
||||
env: {
|
||||
SIM_AGENT_API_URL: 'http://localhost:8000',
|
||||
SIM_AGENT_API_KEY: 'test-sim-agent-key',
|
||||
COPILOT_API_KEY: 'test-sim-agent-key',
|
||||
},
|
||||
}))
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'crypto'
|
||||
import { and, desc, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
@@ -13,6 +14,7 @@ import { getCopilotModel } from '@/lib/copilot/config'
|
||||
import { TITLE_GENERATION_SYSTEM_PROMPT, TITLE_GENERATION_USER_PROMPT } from '@/lib/copilot/prompts'
|
||||
import { env } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { SIM_AGENT_API_URL_DEFAULT } from '@/lib/sim-agent'
|
||||
import { downloadFile } from '@/lib/uploads'
|
||||
import { downloadFromS3WithConfig } from '@/lib/uploads/s3/s3-client'
|
||||
import { S3_COPILOT_CONFIG, USE_S3_STORAGE } from '@/lib/uploads/setup'
|
||||
@@ -23,6 +25,37 @@ import { createAnthropicFileContent, isSupportedFileType } from './file-utils'
|
||||
|
||||
const logger = createLogger('CopilotChatAPI')
|
||||
|
||||
// Sim Agent API configuration
|
||||
const SIM_AGENT_API_URL = env.SIM_AGENT_API_URL || SIM_AGENT_API_URL_DEFAULT
|
||||
|
||||
function deriveKey(keyString: string): Buffer {
|
||||
return createHash('sha256').update(keyString, 'utf8').digest()
|
||||
}
|
||||
|
||||
function decryptWithKey(encryptedValue: string, keyString: string): string {
|
||||
const [ivHex, encryptedHex, authTagHex] = encryptedValue.split(':')
|
||||
if (!ivHex || !encryptedHex || !authTagHex) {
|
||||
throw new Error('Invalid encrypted format')
|
||||
}
|
||||
const key = deriveKey(keyString)
|
||||
const iv = Buffer.from(ivHex, 'hex')
|
||||
const decipher = createDecipheriv('aes-256-gcm', key, iv)
|
||||
decipher.setAuthTag(Buffer.from(authTagHex, 'hex'))
|
||||
let decrypted = decipher.update(encryptedHex, 'hex', 'utf8')
|
||||
decrypted += decipher.final('utf8')
|
||||
return decrypted
|
||||
}
|
||||
|
||||
function encryptWithKey(plaintext: string, keyString: string): string {
|
||||
const key = deriveKey(keyString)
|
||||
const iv = randomBytes(16)
|
||||
const cipher = createCipheriv('aes-256-gcm', key, iv)
|
||||
let encrypted = cipher.update(plaintext, 'utf8', 'hex')
|
||||
encrypted += cipher.final('hex')
|
||||
const authTag = cipher.getAuthTag().toString('hex')
|
||||
return `${iv.toString('hex')}:${encrypted}:${authTag}`
|
||||
}
|
||||
|
||||
// Schema for file attachments
|
||||
const FileAttachmentSchema = z.object({
|
||||
id: z.string(),
|
||||
@@ -48,10 +81,6 @@ const ChatMessageSchema = z.object({
|
||||
conversationId: z.string().optional(),
|
||||
})
|
||||
|
||||
// Sim Agent API configuration
|
||||
const SIM_AGENT_API_URL = env.SIM_AGENT_API_URL || 'http://localhost:8000'
|
||||
const SIM_AGENT_API_KEY = env.SIM_AGENT_API_KEY
|
||||
|
||||
/**
|
||||
* Generate a chat title using LLM
|
||||
*/
|
||||
@@ -179,6 +208,7 @@ export async function POST(req: NextRequest) {
|
||||
hasImplicitFeedback: !!implicitFeedback,
|
||||
provider: provider || 'openai',
|
||||
hasConversationId: !!conversationId,
|
||||
depth,
|
||||
})
|
||||
|
||||
// Handle chat context
|
||||
@@ -347,7 +377,7 @@ export async function POST(req: NextRequest) {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(SIM_AGENT_API_KEY && { 'x-api-key': SIM_AGENT_API_KEY }),
|
||||
...(env.COPILOT_API_KEY ? { 'x-api-key': env.COPILOT_API_KEY } : {}),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
messages: messagesForAgent,
|
||||
@@ -364,11 +394,17 @@ export async function POST(req: NextRequest) {
|
||||
})
|
||||
|
||||
if (!simAgentResponse.ok) {
|
||||
const errorText = await simAgentResponse.text()
|
||||
if (simAgentResponse.status === 401 || simAgentResponse.status === 402) {
|
||||
// Rethrow status only; client will render appropriate assistant message
|
||||
return new NextResponse(null, { status: simAgentResponse.status })
|
||||
}
|
||||
|
||||
const errorText = await simAgentResponse.text().catch(() => '')
|
||||
logger.error(`[${tracker.requestId}] Sim agent API error:`, {
|
||||
status: simAgentResponse.status,
|
||||
error: errorText,
|
||||
})
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: `Sim agent API error: ${simAgentResponse.statusText}` },
|
||||
{ status: simAgentResponse.status }
|
||||
|
||||
@@ -48,11 +48,6 @@ async function updateToolCallStatus(
|
||||
while (Date.now() - startTime < timeout) {
|
||||
const exists = await redis.exists(key)
|
||||
if (exists) {
|
||||
logger.info('Tool call found in Redis, updating status', {
|
||||
toolCallId,
|
||||
key,
|
||||
pollDuration: Date.now() - startTime,
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
@@ -79,27 +74,8 @@ async function updateToolCallStatus(
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
|
||||
// Log what we're about to update in Redis
|
||||
logger.info('About to update Redis with tool call data', {
|
||||
toolCallId,
|
||||
key,
|
||||
toolCallData,
|
||||
serializedData: JSON.stringify(toolCallData),
|
||||
providedStatus: status,
|
||||
providedMessage: message,
|
||||
messageIsUndefined: message === undefined,
|
||||
messageIsNull: message === null,
|
||||
})
|
||||
|
||||
await redis.set(key, JSON.stringify(toolCallData), 'EX', 86400) // Keep 24 hour expiry
|
||||
|
||||
logger.info('Tool call status updated in Redis', {
|
||||
toolCallId,
|
||||
key,
|
||||
status,
|
||||
message,
|
||||
pollDuration: Date.now() - startTime,
|
||||
})
|
||||
return true
|
||||
} catch (error) {
|
||||
logger.error('Failed to update tool call status in Redis', {
|
||||
@@ -131,13 +107,6 @@ export async function POST(req: NextRequest) {
|
||||
const body = await req.json()
|
||||
const { toolCallId, status, message } = ConfirmationSchema.parse(body)
|
||||
|
||||
logger.info(`[${tracker.requestId}] Tool call confirmation request`, {
|
||||
userId: authenticatedUserId,
|
||||
toolCallId,
|
||||
status,
|
||||
message,
|
||||
})
|
||||
|
||||
// Update the tool call status in Redis
|
||||
const updated = await updateToolCallStatus(toolCallId, status, message)
|
||||
|
||||
@@ -153,13 +122,6 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
|
||||
const duration = tracker.getDuration()
|
||||
logger.info(`[${tracker.requestId}] Tool call confirmation completed`, {
|
||||
userId: authenticatedUserId,
|
||||
toolCallId,
|
||||
status,
|
||||
internalStatus: status,
|
||||
duration,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
|
||||
@@ -69,12 +69,6 @@ async function pollRedisForTool(
|
||||
const pollInterval = 1000 // 1 second
|
||||
const startTime = Date.now()
|
||||
|
||||
logger.info('Starting to poll Redis for tool call status', {
|
||||
toolCallId,
|
||||
timeout,
|
||||
pollInterval,
|
||||
})
|
||||
|
||||
while (Date.now() - startTime < timeout) {
|
||||
try {
|
||||
const redisValue = await redis.get(key)
|
||||
@@ -112,23 +106,6 @@ async function pollRedisForTool(
|
||||
rawRedisValue: redisValue,
|
||||
})
|
||||
|
||||
logger.info('Tool call status resolved', {
|
||||
toolCallId,
|
||||
status,
|
||||
message,
|
||||
duration: Date.now() - startTime,
|
||||
rawRedisValue: redisValue,
|
||||
parsedAsJSON: redisValue
|
||||
? (() => {
|
||||
try {
|
||||
return JSON.parse(redisValue)
|
||||
} catch {
|
||||
return 'failed-to-parse'
|
||||
}
|
||||
})()
|
||||
: null,
|
||||
})
|
||||
|
||||
// Special logging for set environment variables tool when Redis status is found
|
||||
if (toolCallId && (status === 'accepted' || status === 'rejected')) {
|
||||
logger.info('SET_ENV_VARS: Redis polling found status update', {
|
||||
|
||||
@@ -178,7 +178,7 @@ export function findLocalFile(filename: string): string | null {
|
||||
* Create a file response with appropriate headers
|
||||
*/
|
||||
export function createFileResponse(file: FileResponse): NextResponse {
|
||||
return new NextResponse(file.buffer, {
|
||||
return new NextResponse(file.buffer as BodyInit, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': file.contentType,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { Resend } from 'resend'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { env } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getEmailDomain } from '@/lib/urls/utils'
|
||||
@@ -9,7 +10,6 @@ const resend = env.RESEND_API_KEY ? new Resend(env.RESEND_API_KEY) : null
|
||||
const logger = createLogger('HelpAPI')
|
||||
|
||||
const helpFormSchema = z.object({
|
||||
email: z.string().email('Invalid email address'),
|
||||
subject: z.string().min(1, 'Subject is required'),
|
||||
message: z.string().min(1, 'Message is required'),
|
||||
type: z.enum(['bug', 'feedback', 'feature_request', 'other']),
|
||||
@@ -19,6 +19,15 @@ export async function POST(req: NextRequest) {
|
||||
const requestId = crypto.randomUUID().slice(0, 8)
|
||||
|
||||
try {
|
||||
// Get user session
|
||||
const session = await getSession()
|
||||
if (!session?.user?.email) {
|
||||
logger.warn(`[${requestId}] Unauthorized help request attempt`)
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
|
||||
}
|
||||
|
||||
const email = session.user.email
|
||||
|
||||
// Check if Resend API key is configured
|
||||
if (!resend) {
|
||||
logger.error(`[${requestId}] RESEND_API_KEY not configured`)
|
||||
@@ -35,7 +44,6 @@ export async function POST(req: NextRequest) {
|
||||
const formData = await req.formData()
|
||||
|
||||
// Extract form fields
|
||||
const email = formData.get('email') as string
|
||||
const subject = formData.get('subject') as string
|
||||
const message = formData.get('message') as string
|
||||
const type = formData.get('type') as string
|
||||
@@ -47,7 +55,6 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
// Validate the form data
|
||||
const result = helpFormSchema.safeParse({
|
||||
email,
|
||||
subject,
|
||||
message,
|
||||
type,
|
||||
@@ -97,9 +104,9 @@ ${message}
|
||||
}
|
||||
|
||||
// Send email using Resend
|
||||
const { data, error } = await resend.emails.send({
|
||||
from: `Sim <noreply@${getEmailDomain()}>`,
|
||||
to: [`help@${getEmailDomain()}`],
|
||||
const { error } = await resend.emails.send({
|
||||
from: `Sim <noreply@${env.EMAIL_DOMAIN || getEmailDomain()}>`,
|
||||
to: [`help@${env.EMAIL_DOMAIN || getEmailDomain()}`],
|
||||
subject: `[${type.toUpperCase()}] ${subject}`,
|
||||
replyTo: email,
|
||||
text: emailText,
|
||||
@@ -121,7 +128,7 @@ ${message}
|
||||
// Send confirmation email to the user
|
||||
await resend.emails
|
||||
.send({
|
||||
from: `Sim <noreply@${getEmailDomain()}>`,
|
||||
from: `Sim <noreply@${env.EMAIL_DOMAIN || getEmailDomain()}>`,
|
||||
to: [email],
|
||||
subject: `Your ${type} request has been received: ${subject}`,
|
||||
text: `
|
||||
@@ -137,7 +144,7 @@ ${images.length > 0 ? `You attached ${images.length} image(s).` : ''}
|
||||
Best regards,
|
||||
The Sim Team
|
||||
`,
|
||||
replyTo: `help@${getEmailDomain()}`,
|
||||
replyTo: `help@${env.EMAIL_DOMAIN || getEmailDomain()}`,
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.warn(`[${requestId}] Failed to send confirmation email`, err)
|
||||
|
||||
@@ -17,12 +17,6 @@ export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('AutoLayoutAPI')
|
||||
|
||||
// Check API key configuration at module level
|
||||
const SIM_AGENT_API_KEY = process.env.SIM_AGENT_API_KEY
|
||||
if (!SIM_AGENT_API_KEY) {
|
||||
logger.warn('SIM_AGENT_API_KEY not configured - autolayout requests will fail')
|
||||
}
|
||||
|
||||
const AutoLayoutRequestSchema = z.object({
|
||||
strategy: z
|
||||
.enum(['smart', 'hierarchical', 'layered', 'force-directed'])
|
||||
@@ -125,15 +119,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
return NextResponse.json({ error: 'Could not load workflow data' }, { status: 500 })
|
||||
}
|
||||
|
||||
// Apply autolayout
|
||||
logger.info(
|
||||
`[${requestId}] Applying autolayout to ${Object.keys(currentWorkflowData.blocks).length} blocks`,
|
||||
{
|
||||
hasApiKey: !!SIM_AGENT_API_KEY,
|
||||
simAgentUrl: process.env.SIM_AGENT_API_URL || 'http://localhost:8000',
|
||||
}
|
||||
)
|
||||
|
||||
// Create workflow state for autolayout
|
||||
const workflowState = {
|
||||
blocks: currentWorkflowData.blocks,
|
||||
@@ -184,7 +169,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
resolveOutputType: resolveOutputType.toString(),
|
||||
},
|
||||
},
|
||||
apiKey: SIM_AGENT_API_KEY,
|
||||
})
|
||||
|
||||
// Log the full response for debugging
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { env } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getUserEntityPermissions } from '@/lib/permissions/utils'
|
||||
import { simAgentClient } from '@/lib/sim-agent'
|
||||
import { SIM_AGENT_API_URL_DEFAULT, simAgentClient } from '@/lib/sim-agent'
|
||||
import {
|
||||
loadWorkflowFromNormalizedTables,
|
||||
saveWorkflowToNormalizedTables,
|
||||
@@ -18,8 +19,7 @@ import { workflowCheckpoints, workflow as workflowTable } from '@/db/schema'
|
||||
import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils'
|
||||
|
||||
// Sim Agent API configuration
|
||||
const SIM_AGENT_API_URL = process.env.SIM_AGENT_API_URL || 'http://localhost:8000'
|
||||
const SIM_AGENT_API_KEY = process.env.SIM_AGENT_API_KEY
|
||||
const SIM_AGENT_API_URL = env.SIM_AGENT_API_URL || SIM_AGENT_API_URL_DEFAULT
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -74,7 +74,6 @@ async function createWorkflowCheckpoint(
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(SIM_AGENT_API_KEY && { 'x-api-key': SIM_AGENT_API_KEY }),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
workflowState: currentWorkflowData,
|
||||
@@ -288,7 +287,6 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(SIM_AGENT_API_KEY && { 'x-api-key': SIM_AGENT_API_KEY }),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
yamlContent,
|
||||
|
||||
@@ -8,9 +8,6 @@ import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/w
|
||||
|
||||
const logger = createLogger('WorkflowYamlAPI')
|
||||
|
||||
// Get API key at module level like working routes
|
||||
const SIM_AGENT_API_KEY = process.env.SIM_AGENT_API_KEY
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const requestId = crypto.randomUUID().slice(0, 8)
|
||||
|
||||
@@ -55,7 +52,6 @@ export async function POST(request: NextRequest) {
|
||||
resolveOutputType: resolveOutputType.toString(),
|
||||
},
|
||||
},
|
||||
apiKey: SIM_AGENT_API_KEY,
|
||||
})
|
||||
|
||||
if (!result.success || !result.data?.yaml) {
|
||||
|
||||
@@ -14,9 +14,6 @@ import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/w
|
||||
|
||||
const logger = createLogger('WorkflowYamlExportAPI')
|
||||
|
||||
// Get API key at module level like working routes
|
||||
const SIM_AGENT_API_KEY = process.env.SIM_AGENT_API_KEY
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const requestId = crypto.randomUUID().slice(0, 8)
|
||||
const url = new URL(request.url)
|
||||
@@ -176,7 +173,6 @@ export async function GET(request: NextRequest) {
|
||||
resolveOutputType: resolveOutputType.toString(),
|
||||
},
|
||||
},
|
||||
apiKey: SIM_AGENT_API_KEY,
|
||||
})
|
||||
|
||||
if (!result.success || !result.data?.yaml) {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { env } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { SIM_AGENT_API_URL_DEFAULT } from '@/lib/sim-agent'
|
||||
import { getAllBlocks } from '@/blocks/registry'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import { resolveOutputType } from '@/blocks/utils'
|
||||
@@ -16,8 +18,7 @@ import {
|
||||
const logger = createLogger('YamlAutoLayoutAPI')
|
||||
|
||||
// Sim Agent API configuration
|
||||
const SIM_AGENT_API_URL = process.env.SIM_AGENT_API_URL || 'http://localhost:8000'
|
||||
const SIM_AGENT_API_KEY = process.env.SIM_AGENT_API_KEY
|
||||
const SIM_AGENT_API_URL = env.SIM_AGENT_API_URL || SIM_AGENT_API_URL_DEFAULT
|
||||
|
||||
const AutoLayoutRequestSchema = z.object({
|
||||
workflowState: z.object({
|
||||
@@ -58,7 +59,6 @@ export async function POST(request: NextRequest) {
|
||||
logger.info(`[${requestId}] Applying auto layout`, {
|
||||
blockCount: Object.keys(workflowState.blocks).length,
|
||||
edgeCount: workflowState.edges.length,
|
||||
hasApiKey: !!SIM_AGENT_API_KEY,
|
||||
strategy: options?.strategy || 'smart',
|
||||
simAgentUrl: SIM_AGENT_API_URL,
|
||||
})
|
||||
@@ -102,7 +102,6 @@ export async function POST(request: NextRequest) {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(SIM_AGENT_API_KEY && { 'x-api-key': SIM_AGENT_API_KEY }),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
workflowState: {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { env } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { SIM_AGENT_API_URL_DEFAULT } from '@/lib/sim-agent'
|
||||
import { getAllBlocks } from '@/blocks/registry'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import { resolveOutputType } from '@/blocks/utils'
|
||||
@@ -16,8 +18,7 @@ import {
|
||||
const logger = createLogger('YamlDiffCreateAPI')
|
||||
|
||||
// Sim Agent API configuration
|
||||
const SIM_AGENT_API_URL = process.env.SIM_AGENT_API_URL || 'http://localhost:8000'
|
||||
const SIM_AGENT_API_KEY = process.env.SIM_AGENT_API_KEY
|
||||
const SIM_AGENT_API_URL = env.SIM_AGENT_API_URL || SIM_AGENT_API_URL_DEFAULT
|
||||
|
||||
const CreateDiffRequestSchema = z.object({
|
||||
yamlContent: z.string().min(1),
|
||||
@@ -89,7 +90,6 @@ export async function POST(request: NextRequest) {
|
||||
hasDiffAnalysis: !!diffAnalysis,
|
||||
hasOptions: !!options,
|
||||
options: options,
|
||||
hasApiKey: !!SIM_AGENT_API_KEY,
|
||||
hasCurrentWorkflowState: !!currentWorkflowState,
|
||||
currentBlockCount: currentWorkflowState
|
||||
? Object.keys(currentWorkflowState.blocks || {}).length
|
||||
@@ -117,7 +117,6 @@ export async function POST(request: NextRequest) {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(SIM_AGENT_API_KEY && { 'x-api-key': SIM_AGENT_API_KEY }),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
yamlContent,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { env } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { SIM_AGENT_API_URL_DEFAULT } from '@/lib/sim-agent'
|
||||
import { getAllBlocks } from '@/blocks/registry'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import { resolveOutputType } from '@/blocks/utils'
|
||||
@@ -16,8 +18,7 @@ import {
|
||||
const logger = createLogger('YamlDiffMergeAPI')
|
||||
|
||||
// Sim Agent API configuration
|
||||
const SIM_AGENT_API_URL = process.env.SIM_AGENT_API_URL || 'http://localhost:8000'
|
||||
const SIM_AGENT_API_KEY = process.env.SIM_AGENT_API_KEY
|
||||
const SIM_AGENT_API_URL = env.SIM_AGENT_API_URL || SIM_AGENT_API_URL_DEFAULT
|
||||
|
||||
const MergeDiffRequestSchema = z.object({
|
||||
existingDiff: z.object({
|
||||
@@ -64,7 +65,6 @@ export async function POST(request: NextRequest) {
|
||||
hasDiffAnalysis: !!diffAnalysis,
|
||||
hasOptions: !!options,
|
||||
options: options,
|
||||
hasApiKey: !!SIM_AGENT_API_KEY,
|
||||
})
|
||||
|
||||
// Gather block registry
|
||||
@@ -88,7 +88,6 @@ export async function POST(request: NextRequest) {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(SIM_AGENT_API_KEY && { 'x-api-key': SIM_AGENT_API_KEY }),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
existingDiff,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { env } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { SIM_AGENT_API_URL_DEFAULT } from '@/lib/sim-agent'
|
||||
import { getAllBlocks } from '@/blocks/registry'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import { resolveOutputType } from '@/blocks/utils'
|
||||
@@ -9,8 +11,7 @@ import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/w
|
||||
const logger = createLogger('YamlGenerateAPI')
|
||||
|
||||
// Sim Agent API configuration
|
||||
const SIM_AGENT_API_URL = process.env.SIM_AGENT_API_URL || 'http://localhost:8000'
|
||||
const SIM_AGENT_API_KEY = process.env.SIM_AGENT_API_KEY
|
||||
const SIM_AGENT_API_URL = env.SIM_AGENT_API_URL || SIM_AGENT_API_URL_DEFAULT
|
||||
|
||||
const GenerateRequestSchema = z.object({
|
||||
workflowState: z.any(), // Let the yaml service handle validation
|
||||
@@ -27,7 +28,6 @@ export async function POST(request: NextRequest) {
|
||||
logger.info(`[${requestId}] Generating YAML from workflow`, {
|
||||
blocksCount: workflowState.blocks ? Object.keys(workflowState.blocks).length : 0,
|
||||
edgesCount: workflowState.edges ? workflowState.edges.length : 0,
|
||||
hasApiKey: !!SIM_AGENT_API_KEY,
|
||||
})
|
||||
|
||||
// Gather block registry and utilities
|
||||
@@ -51,7 +51,6 @@ export async function POST(request: NextRequest) {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(SIM_AGENT_API_KEY && { 'x-api-key': SIM_AGENT_API_KEY }),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
workflowState,
|
||||
|
||||
@@ -1,26 +1,24 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { env } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { SIM_AGENT_API_URL_DEFAULT } from '@/lib/sim-agent'
|
||||
|
||||
const logger = createLogger('YamlHealthAPI')
|
||||
|
||||
// Sim Agent API configuration
|
||||
const SIM_AGENT_API_URL = process.env.SIM_AGENT_API_URL || 'http://localhost:8000'
|
||||
const SIM_AGENT_API_KEY = process.env.SIM_AGENT_API_KEY
|
||||
const SIM_AGENT_API_URL = env.SIM_AGENT_API_URL || SIM_AGENT_API_URL_DEFAULT
|
||||
|
||||
export async function GET() {
|
||||
const requestId = crypto.randomUUID().slice(0, 8)
|
||||
|
||||
try {
|
||||
logger.info(`[${requestId}] Checking YAML service health`, {
|
||||
hasApiKey: !!SIM_AGENT_API_KEY,
|
||||
})
|
||||
logger.info(`[${requestId}] Checking YAML service health`)
|
||||
|
||||
// Check sim-agent health
|
||||
const response = await fetch(`${SIM_AGENT_API_URL}/health`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(SIM_AGENT_API_KEY && { 'x-api-key': SIM_AGENT_API_KEY }),
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { env } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { SIM_AGENT_API_URL_DEFAULT } from '@/lib/sim-agent'
|
||||
import { getAllBlocks } from '@/blocks/registry'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import { resolveOutputType } from '@/blocks/utils'
|
||||
@@ -9,11 +11,10 @@ import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/w
|
||||
const logger = createLogger('YamlParseAPI')
|
||||
|
||||
// Sim Agent API configuration
|
||||
const SIM_AGENT_API_URL = process.env.SIM_AGENT_API_URL || 'http://localhost:8000'
|
||||
const SIM_AGENT_API_KEY = process.env.SIM_AGENT_API_KEY
|
||||
const SIM_AGENT_API_URL = env.SIM_AGENT_API_URL || SIM_AGENT_API_URL_DEFAULT
|
||||
|
||||
const ParseRequestSchema = z.object({
|
||||
yamlContent: z.string().min(1),
|
||||
yamlContent: z.string(),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
@@ -25,7 +26,6 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
logger.info(`[${requestId}] Parsing YAML`, {
|
||||
contentLength: yamlContent.length,
|
||||
hasApiKey: !!SIM_AGENT_API_KEY,
|
||||
})
|
||||
|
||||
// Gather block registry and utilities
|
||||
@@ -49,7 +49,6 @@ export async function POST(request: NextRequest) {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(SIM_AGENT_API_KEY && { 'x-api-key': SIM_AGENT_API_KEY }),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
yamlContent,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { env } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { SIM_AGENT_API_URL_DEFAULT } from '@/lib/sim-agent'
|
||||
import { getAllBlocks } from '@/blocks/registry'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import { resolveOutputType } from '@/blocks/utils'
|
||||
@@ -9,8 +11,7 @@ import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/w
|
||||
const logger = createLogger('YamlToWorkflowAPI')
|
||||
|
||||
// Sim Agent API configuration
|
||||
const SIM_AGENT_API_URL = process.env.SIM_AGENT_API_URL || 'http://localhost:8000'
|
||||
const SIM_AGENT_API_KEY = process.env.SIM_AGENT_API_KEY
|
||||
const SIM_AGENT_API_URL = env.SIM_AGENT_API_URL || SIM_AGENT_API_URL_DEFAULT
|
||||
|
||||
const ConvertRequestSchema = z.object({
|
||||
yamlContent: z.string().min(1),
|
||||
@@ -33,7 +34,6 @@ export async function POST(request: NextRequest) {
|
||||
logger.info(`[${requestId}] Converting YAML to workflow`, {
|
||||
contentLength: yamlContent.length,
|
||||
hasOptions: !!options,
|
||||
hasApiKey: !!SIM_AGENT_API_KEY,
|
||||
})
|
||||
|
||||
// Gather block registry and utilities
|
||||
@@ -57,7 +57,6 @@ export async function POST(request: NextRequest) {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(SIM_AGENT_API_KEY && { 'x-api-key': SIM_AGENT_API_KEY }),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
yamlContent,
|
||||
|
||||
@@ -86,7 +86,7 @@ const getStatusDisplay = (doc: DocumentData) => {
|
||||
</>
|
||||
),
|
||||
className:
|
||||
'inline-flex items-center rounded-md bg-[var(--brand-primary-hex)]/10 px-2 py-1 text-xs font-medium text-[var(--brand-primary-hex)] dark:bg-[var(--brand-primary-hex)]/20 dark:text-[var(--brand-primary-hex)]',
|
||||
'inline-flex items-center rounded-md bg-purple-100 px-2 py-1 text-xs font-medium text-[var(--brand-primary-hex)] dark:bg-purple-900/30 dark:text-[var(--brand-primary-hex)]',
|
||||
}
|
||||
case 'failed':
|
||||
return {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/u
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getDocumentIcon } from '@/app/workspace/[workspaceId]/knowledge/components'
|
||||
import { useKnowledgeUpload } from '@/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload'
|
||||
|
||||
const logger = createLogger('UploadModal')
|
||||
@@ -152,6 +153,19 @@ export function UploadModal({
|
||||
}
|
||||
}
|
||||
|
||||
const getFileIcon = (mimeType: string, filename: string) => {
|
||||
const IconComponent = getDocumentIcon(mimeType, filename)
|
||||
return <IconComponent className='h-10 w-8' />
|
||||
}
|
||||
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return `${Number.parseFloat((bytes / k ** i).toFixed(1))} ${sizes[i]}`
|
||||
}
|
||||
|
||||
// Calculate progress percentage
|
||||
const progressPercentage =
|
||||
uploadProgress.totalFiles > 0
|
||||
@@ -221,11 +235,11 @@ export function UploadModal({
|
||||
multiple
|
||||
/>
|
||||
<p className='text-sm'>
|
||||
{isDragging ? 'Drop more files here!' : 'Add more files'}
|
||||
{isDragging ? 'Drop more files here!' : 'Drop more files or click to browse'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='max-h-60 space-y-1.5 overflow-auto'>
|
||||
<div className='max-h-60 space-y-2 overflow-auto'>
|
||||
{files.map((file, index) => {
|
||||
const fileStatus = uploadProgress.fileStatuses?.[index]
|
||||
const isCurrentlyUploading = fileStatus?.status === 'uploading'
|
||||
@@ -233,26 +247,31 @@ export function UploadModal({
|
||||
const isFailed = fileStatus?.status === 'failed'
|
||||
|
||||
return (
|
||||
<div key={index} className='space-y-1.5 rounded-md border p-2'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div key={index} className='rounded-md border p-3'>
|
||||
<div className='flex items-center gap-3'>
|
||||
{getFileIcon(file.type, file.name)}
|
||||
<div className='min-w-0 flex-1'>
|
||||
<div className='flex items-center gap-2'>
|
||||
{isCurrentlyUploading && (
|
||||
<Loader2 className='h-4 w-4 animate-spin text-blue-500' />
|
||||
<Loader2 className='h-4 w-4 animate-spin text-[var(--brand-primary-hex)]' />
|
||||
)}
|
||||
{isCompleted && <Check className='h-4 w-4 text-green-500' />}
|
||||
{isFailed && <X className='h-4 w-4 text-red-500' />}
|
||||
{!isCurrentlyUploading && !isCompleted && !isFailed && (
|
||||
<div className='h-4 w-4' />
|
||||
)}
|
||||
<p className='truncate text-sm'>
|
||||
<span className='font-medium'>{file.name}</span>
|
||||
<span className='text-muted-foreground'>
|
||||
{' '}
|
||||
• {(file.size / 1024 / 1024).toFixed(2)} MB
|
||||
</span>
|
||||
</p>
|
||||
<p className='truncate font-medium text-sm'>{file.name}</p>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{formatFileSize(file.size)}
|
||||
</p>
|
||||
{isCurrentlyUploading && (
|
||||
<div className='min-w-0 max-w-32 flex-1'>
|
||||
<Progress value={fileStatus?.progress || 0} className='h-1' />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isFailed && fileStatus?.error && (
|
||||
<p className='mt-1 text-red-500 text-xs'>{fileStatus.error}</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type='button'
|
||||
@@ -260,17 +279,11 @@ export function UploadModal({
|
||||
size='sm'
|
||||
onClick={() => removeFile(index)}
|
||||
disabled={isUploading}
|
||||
className='h-8 w-8 p-0'
|
||||
className='h-8 w-8 p-0 text-muted-foreground hover:text-destructive'
|
||||
>
|
||||
<X className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
{isCurrentlyUploading && (
|
||||
<Progress value={fileStatus?.progress || 0} className='h-1' />
|
||||
)}
|
||||
{isFailed && fileStatus?.error && (
|
||||
<p className='text-red-500 text-xs'>{fileStatus.error}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
@@ -287,7 +300,11 @@ export function UploadModal({
|
||||
<Button variant='outline' onClick={handleClose} disabled={isUploading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleUpload} disabled={files.length === 0 || isUploading}>
|
||||
<Button
|
||||
onClick={handleUpload}
|
||||
disabled={files.length === 0 || isUploading}
|
||||
className='bg-[var(--brand-primary-hex)] font-[480] text-primary-foreground shadow-[0_0_0_0_var(--brand-primary-hex)] transition-all duration-200 hover:bg-[var(--brand-primary-hover-hex)] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)]'
|
||||
>
|
||||
{isUploading
|
||||
? uploadProgress.stage === 'uploading'
|
||||
? `Uploading ${uploadProgress.filesCompleted + 1}/${uploadProgress.totalFiles}...`
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { AlertCircle, CheckCircle2, X } from 'lucide-react'
|
||||
import { AlertCircle, X } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { z } from 'zod'
|
||||
@@ -109,6 +109,7 @@ export function CreateModal({ open, onOpenChange, onKnowledgeBaseCreated }: Crea
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
watch,
|
||||
formState: { errors },
|
||||
} = useForm<FormValues>({
|
||||
resolver: zodResolver(FormSchema),
|
||||
@@ -119,9 +120,32 @@ export function CreateModal({ open, onOpenChange, onKnowledgeBaseCreated }: Crea
|
||||
maxChunkSize: 1024,
|
||||
overlapSize: 200,
|
||||
},
|
||||
mode: 'onChange',
|
||||
mode: 'onSubmit',
|
||||
})
|
||||
|
||||
// Watch the name field to enable/disable the submit button
|
||||
const nameValue = watch('name')
|
||||
|
||||
// Reset state when modal opens/closes
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
// Reset states when modal opens
|
||||
setSubmitStatus(null)
|
||||
setFileError(null)
|
||||
setFiles([])
|
||||
setIsDragging(false)
|
||||
setDragCounter(0)
|
||||
// Reset form to default values
|
||||
reset({
|
||||
name: '',
|
||||
description: '',
|
||||
minChunkSize: 1,
|
||||
maxChunkSize: 1024,
|
||||
overlapSize: 200,
|
||||
})
|
||||
}
|
||||
}, [open, reset])
|
||||
|
||||
const processFiles = async (fileList: FileList | File[]) => {
|
||||
setFileError(null)
|
||||
|
||||
@@ -292,18 +316,6 @@ export function CreateModal({ open, onOpenChange, onKnowledgeBaseCreated }: Crea
|
||||
logger.info(`Started processing ${uploadedFiles.length} documents in the background`)
|
||||
}
|
||||
|
||||
setSubmitStatus({
|
||||
type: 'success',
|
||||
message: 'Your knowledge base has been created successfully!',
|
||||
})
|
||||
reset({
|
||||
name: '',
|
||||
description: '',
|
||||
minChunkSize: 1,
|
||||
maxChunkSize: 1024,
|
||||
overlapSize: 200,
|
||||
})
|
||||
|
||||
// Clean up file previews
|
||||
files.forEach((file) => URL.revokeObjectURL(file.preview))
|
||||
setFiles([])
|
||||
@@ -313,10 +325,8 @@ export function CreateModal({ open, onOpenChange, onKnowledgeBaseCreated }: Crea
|
||||
onKnowledgeBaseCreated(newKnowledgeBase)
|
||||
}
|
||||
|
||||
// Close modal after a short delay to show success message
|
||||
setTimeout(() => {
|
||||
onOpenChange(false)
|
||||
}, 1500)
|
||||
// Close modal immediately - no need for success message
|
||||
onOpenChange(false)
|
||||
} catch (error) {
|
||||
logger.error('Error creating knowledge base:', error)
|
||||
setSubmitStatus({
|
||||
@@ -357,31 +367,13 @@ export function CreateModal({ open, onOpenChange, onKnowledgeBaseCreated }: Crea
|
||||
className='scrollbar-thin scrollbar-thumb-muted-foreground/20 hover:scrollbar-thumb-muted-foreground/25 scrollbar-track-transparent min-h-0 flex-1 overflow-y-auto px-6'
|
||||
>
|
||||
<div className='flex min-h-full flex-col py-4'>
|
||||
{submitStatus && submitStatus.type === 'success' ? (
|
||||
<Alert className='mb-6 border-border border-green-200 bg-green-50 dark:border-green-900 dark:bg-green-950/30'>
|
||||
<div className='flex items-start gap-4 py-1'>
|
||||
<div className='mt-[-1.5px] flex-shrink-0'>
|
||||
<CheckCircle2 className='h-4 w-4 text-green-600 dark:text-green-400' />
|
||||
</div>
|
||||
<div className='mr-4 flex-1 space-y-2'>
|
||||
<AlertTitle className='-mt-0.5 flex items-center justify-between'>
|
||||
<span className='font-medium text-green-600 dark:text-green-400'>
|
||||
Success
|
||||
</span>
|
||||
</AlertTitle>
|
||||
<AlertDescription className='text-green-600 dark:text-green-400'>
|
||||
{submitStatus.message}
|
||||
</AlertDescription>
|
||||
</div>
|
||||
</div>
|
||||
</Alert>
|
||||
) : submitStatus && submitStatus.type === 'error' ? (
|
||||
{submitStatus && submitStatus.type === 'error' && (
|
||||
<Alert variant='destructive' className='mb-6'>
|
||||
<AlertCircle className='h-4 w-4' />
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{submitStatus.message}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
)}
|
||||
|
||||
{/* Form Fields Section - Fixed at top */}
|
||||
<div className='flex-shrink-0 space-y-4'>
|
||||
@@ -611,8 +603,8 @@ export function CreateModal({ open, onOpenChange, onKnowledgeBaseCreated }: Crea
|
||||
</Button>
|
||||
<Button
|
||||
type='submit'
|
||||
disabled={isSubmitting}
|
||||
className='bg-[var(--brand-primary-hex)] font-[480] text-primary-foreground shadow-[0_0_0_0_var(--brand-primary-hex)] transition-all duration-200 hover:bg-[var(--brand-primary-hover-hex)] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)]'
|
||||
disabled={isSubmitting || !nameValue?.trim()}
|
||||
className='bg-[var(--brand-primary-hex)] font-[480] text-primary-foreground shadow-[0_0_0_0_var(--brand-primary-hex)] transition-all duration-200 hover:bg-[var(--brand-primary-hover-hex)] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)] disabled:opacity-50 disabled:hover:shadow-none'
|
||||
>
|
||||
{isSubmitting ? 'Creating...' : 'Create Knowledge Base'}
|
||||
</Button>
|
||||
|
||||
@@ -999,14 +999,14 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
|
||||
return (
|
||||
<div className='fixed top-4 right-4 z-20 flex items-center gap-1'>
|
||||
{renderDisconnectionNotice()}
|
||||
{!isDev && renderToggleButton()}
|
||||
{isExpanded && !isDev && <ExportControls />}
|
||||
{isExpanded && !isDev && renderAutoLayoutButton()}
|
||||
{renderToggleButton()}
|
||||
{isExpanded && <ExportControls />}
|
||||
{isExpanded && renderAutoLayoutButton()}
|
||||
{!isDev && isExpanded && renderDuplicateButton()}
|
||||
{isDev && renderDuplicateButton()}
|
||||
{renderDeleteButton()}
|
||||
{!isDebugging && renderDebugModeToggle()}
|
||||
{renderPublishButton()}
|
||||
{isExpanded && renderPublishButton()}
|
||||
{renderDeployButton()}
|
||||
{isDebugging ? renderDebugControlsBar() : renderRunButton()}
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { isDev } from '@/lib/environment'
|
||||
import { useCopilotStore } from '@/stores/copilot/store'
|
||||
import { useChatStore } from '@/stores/panel/chat/store'
|
||||
import { useConsoleStore } from '@/stores/panel/console/store'
|
||||
@@ -305,16 +304,14 @@ export function Panel() {
|
||||
>
|
||||
Console
|
||||
</button>
|
||||
{!isDev && (
|
||||
<button
|
||||
onClick={() => handleTabClick('copilot')}
|
||||
className={`panel-tab-base inline-flex flex-1 cursor-pointer items-center justify-center rounded-[10px] border border-transparent py-1 font-[450] text-sm outline-none transition-colors duration-200 ${
|
||||
isOpen && activeTab === 'copilot' ? 'panel-tab-active' : 'panel-tab-inactive'
|
||||
}`}
|
||||
>
|
||||
Copilot
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleTabClick('copilot')}
|
||||
className={`panel-tab-base inline-flex flex-1 cursor-pointer items-center justify-center rounded-[10px] border border-transparent py-1 font-[450] text-sm outline-none transition-colors duration-200 ${
|
||||
isOpen && activeTab === 'copilot' ? 'panel-tab-active' : 'panel-tab-inactive'
|
||||
}`}
|
||||
>
|
||||
Copilot
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleTabClick('variables')}
|
||||
className={`panel-tab-base inline-flex flex-1 cursor-pointer items-center justify-center rounded-[10px] border border-transparent py-1 font-[450] text-sm outline-none transition-colors duration-200 ${
|
||||
|
||||
@@ -29,9 +29,7 @@ import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('HelpModal')
|
||||
|
||||
// Define form schema
|
||||
const formSchema = z.object({
|
||||
email: z.string().email('Please enter a valid email address'),
|
||||
subject: z.string().min(1, 'Subject is required'),
|
||||
message: z.string().min(1, 'Message is required'),
|
||||
type: z.enum(['bug', 'feedback', 'feature_request', 'other'], {
|
||||
@@ -77,17 +75,35 @@ export function HelpModal({ open, onOpenChange }: HelpModalProps) {
|
||||
} = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
email: '',
|
||||
subject: '',
|
||||
message: '',
|
||||
type: 'bug', // Set default value to 'bug'
|
||||
},
|
||||
mode: 'onChange',
|
||||
mode: 'onSubmit',
|
||||
})
|
||||
|
||||
// Reset state when modal opens/closes
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
// Reset states when modal opens
|
||||
setSubmitStatus(null)
|
||||
setErrorMessage('')
|
||||
setImageError(null)
|
||||
setImages([])
|
||||
setIsDragging(false)
|
||||
setIsProcessing(false)
|
||||
// Reset form to default values
|
||||
reset({
|
||||
subject: '',
|
||||
message: '',
|
||||
type: 'bug',
|
||||
})
|
||||
}
|
||||
}, [open, reset])
|
||||
|
||||
// Listen for the custom event to open the help modal
|
||||
useEffect(() => {
|
||||
const handleOpenHelp = (event: CustomEvent) => {
|
||||
const handleOpenHelp = () => {
|
||||
onOpenChange(true)
|
||||
}
|
||||
|
||||
@@ -268,8 +284,7 @@ export function HelpModal({ open, onOpenChange }: HelpModalProps) {
|
||||
// Create FormData to handle file uploads
|
||||
const formData = new FormData()
|
||||
|
||||
// Add form fields
|
||||
formData.append('email', data.email)
|
||||
// Add form fields (email will be retrieved server-side from session)
|
||||
formData.append('subject', data.subject)
|
||||
formData.append('message', data.message)
|
||||
formData.append('type', data.type)
|
||||
@@ -377,19 +392,6 @@ export function HelpModal({ open, onOpenChange }: HelpModalProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='email'>Email</Label>
|
||||
<Input
|
||||
id='email'
|
||||
placeholder='your.email@example.com'
|
||||
{...register('email')}
|
||||
className={`h-9 rounded-[8px] ${errors.email ? 'border-red-500' : ''}`}
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className='mt-1 text-red-500 text-sm'>{errors.email.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='subject'>Subject</Label>
|
||||
<Input
|
||||
@@ -408,7 +410,7 @@ export function HelpModal({ open, onOpenChange }: HelpModalProps) {
|
||||
<Textarea
|
||||
id='message'
|
||||
placeholder='Please provide details about your request...'
|
||||
rows={5}
|
||||
rows={6}
|
||||
{...register('message')}
|
||||
className={`rounded-[8px] ${errors.message ? 'border-red-500' : ''}`}
|
||||
/>
|
||||
@@ -426,9 +428,10 @@ export function HelpModal({ open, onOpenChange }: HelpModalProps) {
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
className={`flex items-center gap-4 ${
|
||||
isDragging ? 'rounded-md bg-primary/5 p-2' : ''
|
||||
className={`cursor-pointer rounded-lg border-2 border-muted-foreground/25 border-dashed p-6 text-center transition-colors hover:bg-muted/50 ${
|
||||
isDragging ? 'border-primary bg-primary/5' : ''
|
||||
}`}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
@@ -438,17 +441,12 @@ export function HelpModal({ open, onOpenChange }: HelpModalProps) {
|
||||
className='hidden'
|
||||
multiple
|
||||
/>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className='flex h-9 items-center justify-center gap-2 rounded-[8px]'
|
||||
>
|
||||
<Upload className='h-4 w-4' />
|
||||
Upload Images
|
||||
</Button>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Drop images here or click to upload. Max 20MB per image.
|
||||
<Upload className='mx-auto mb-2 h-8 w-8 text-muted-foreground' />
|
||||
<p className='text-sm'>
|
||||
{isDragging ? 'Drop images here!' : 'Drop images here or click to browse'}
|
||||
</p>
|
||||
<p className='mt-1 text-muted-foreground text-xs'>
|
||||
JPEG, PNG, WebP, GIF (max 20MB each)
|
||||
</p>
|
||||
</div>
|
||||
{imageError && <p className='mt-1 text-red-500 text-sm'>{imageError}</p>}
|
||||
@@ -494,18 +492,13 @@ export function HelpModal({ open, onOpenChange }: HelpModalProps) {
|
||||
{/* Overlay Footer */}
|
||||
<div className='absolute inset-x-0 bottom-0 bg-background'>
|
||||
<div className='flex w-full items-center justify-between px-6 py-4'>
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={handleClose}
|
||||
type='button'
|
||||
className='h-9 rounded-[8px]'
|
||||
>
|
||||
<Button variant='outline' onClick={handleClose} type='button'>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type='submit'
|
||||
disabled={isSubmitting || isProcessing}
|
||||
className='h-9 rounded-[8px]'
|
||||
className='bg-[var(--brand-primary-hex)] font-[480] text-primary-foreground shadow-[0_0_0_0_var(--brand-primary-hex)] transition-all duration-200 hover:bg-[var(--brand-primary-hover-hex)] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)] disabled:opacity-50 disabled:hover:shadow-none'
|
||||
>
|
||||
{isSubmitting ? 'Submitting...' : 'Submit'}
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Check, Copy, Eye, EyeOff, KeySquare, Plus, Trash2 } from 'lucide-react'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
Button,
|
||||
Card,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Input,
|
||||
Label,
|
||||
Skeleton,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('CopilotSettings')
|
||||
|
||||
interface CopilotKey {
|
||||
id: string
|
||||
apiKey: string
|
||||
}
|
||||
|
||||
export function Copilot() {
|
||||
const [keys, setKeys] = useState<CopilotKey[]>([])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [visible, setVisible] = useState<Record<string, boolean>>({})
|
||||
|
||||
// Create flow state
|
||||
const [showNewKeyDialog, setShowNewKeyDialog] = useState(false)
|
||||
const [newKey, setNewKey] = useState<CopilotKey | null>(null)
|
||||
const [copiedKeyIds, setCopiedKeyIds] = useState<Record<string, boolean>>({})
|
||||
const [newKeyCopySuccess, setNewKeyCopySuccess] = useState(false)
|
||||
|
||||
// Delete flow state
|
||||
const [deleteKey, setDeleteKey] = useState<CopilotKey | null>(null)
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||
|
||||
const hasKeys = keys.length > 0
|
||||
|
||||
const maskedValue = useCallback((value: string, show: boolean) => {
|
||||
if (show) return value
|
||||
if (!value) return ''
|
||||
const last6 = value.slice(-6)
|
||||
return `••••••••••${last6}`
|
||||
}, [])
|
||||
|
||||
const fetchKeys = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
const res = await fetch('/api/copilot/api-keys')
|
||||
if (!res.ok) throw new Error(`Failed to fetch: ${res.status}`)
|
||||
const data = await res.json()
|
||||
setKeys(Array.isArray(data.keys) ? data.keys : [])
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch copilot keys', { error })
|
||||
setKeys([])
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchKeys()
|
||||
}, [fetchKeys])
|
||||
|
||||
const onGenerate = async () => {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
const res = await fetch('/api/copilot/api-keys/generate', { method: 'POST' })
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}))
|
||||
throw new Error(body.error || 'Failed to generate API key')
|
||||
}
|
||||
const data = await res.json()
|
||||
// Show the new key dialog with the API key (only shown once)
|
||||
if (data?.key) {
|
||||
setNewKey(data.key)
|
||||
setShowNewKeyDialog(true)
|
||||
}
|
||||
await fetchKeys()
|
||||
} catch (error) {
|
||||
logger.error('Failed to generate copilot API key', { error })
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const onDelete = async (id: string) => {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
const res = await fetch(`/api/copilot/api-keys?id=${encodeURIComponent(id)}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}))
|
||||
throw new Error(body.error || 'Failed to delete API key')
|
||||
}
|
||||
await fetchKeys()
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete copilot API key', { error })
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const onCopy = async (value: string, keyId?: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value)
|
||||
if (keyId) {
|
||||
setCopiedKeyIds((prev) => ({ ...prev, [keyId]: true }))
|
||||
setTimeout(() => {
|
||||
setCopiedKeyIds((prev) => ({ ...prev, [keyId]: false }))
|
||||
}, 1500)
|
||||
} else {
|
||||
setNewKeyCopySuccess(true)
|
||||
setTimeout(() => setNewKeyCopySuccess(false), 1500)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Copy failed', { error })
|
||||
}
|
||||
}
|
||||
|
||||
// UI helpers
|
||||
const isFetching = isLoading && keys.length === 0
|
||||
|
||||
return (
|
||||
<div className='space-y-6 p-6'>
|
||||
<h2 className='font-semibold text-xl'>Copilot API Keys</h2>
|
||||
|
||||
<p className='text-muted-foreground text-sm leading-relaxed'>
|
||||
Copilot API keys let you authenticate requests to the Copilot endpoints. Keep keys secret
|
||||
and rotate them regularly.
|
||||
</p>
|
||||
<p className='text-muted-foreground text-xs italic'>
|
||||
For external deployments, set the <span className='font-mono'>COPILOT_API_KEY</span>{' '}
|
||||
environment variable on that instance to one of the keys generated here.
|
||||
</p>
|
||||
|
||||
{isFetching ? (
|
||||
<div className='mt-6 space-y-3'>
|
||||
<Card className='p-4'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div>
|
||||
<Skeleton className='mb-2 h-5 w-32' />
|
||||
<Skeleton className='h-4 w-48' />
|
||||
</div>
|
||||
<Skeleton className='h-8 w-8 rounded-md' />
|
||||
</div>
|
||||
</Card>
|
||||
<Card className='p-4'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div>
|
||||
<Skeleton className='mb-2 h-5 w-28' />
|
||||
<Skeleton className='h-4 w-40' />
|
||||
</div>
|
||||
<Skeleton className='h-8 w-8 rounded-md' />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
) : !hasKeys ? (
|
||||
<div className='mt-6 rounded-md border border-dashed p-8'>
|
||||
<div className='flex flex-col items-center justify-center text-center'>
|
||||
<div className='flex h-12 w-12 items-center justify-center rounded-full bg-muted'>
|
||||
<KeySquare className='h-6 w-6 text-primary' />
|
||||
</div>
|
||||
<h3 className='mt-4 font-medium text-lg'>No Copilot keys yet</h3>
|
||||
<p className='mt-2 max-w-sm text-muted-foreground text-sm'>
|
||||
Generate a Copilot API key to authenticate requests to the Copilot SDK and methods.
|
||||
</p>
|
||||
<Button
|
||||
variant='default'
|
||||
className='mt-4'
|
||||
onClick={onGenerate}
|
||||
size='sm'
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Plus className='mr-1.5 h-4 w-4' /> Generate Key
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className='mt-6 space-y-4'>
|
||||
{keys.map((k) => {
|
||||
const isVisible = !!visible[k.id]
|
||||
const value = maskedValue(k.apiKey, isVisible)
|
||||
return (
|
||||
<Card key={k.id} className='p-4 transition-shadow hover:shadow-sm'>
|
||||
<div className='flex items-center justify-between gap-4'>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<div className='rounded bg-muted/50 px-2 py-1 font-mono text-sm'>{value}</div>
|
||||
<p className='mt-1 text-muted-foreground text-xs'>
|
||||
Key ID: <span className='font-mono'>{k.id}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant='secondary'
|
||||
size='icon'
|
||||
onClick={() => setVisible((v) => ({ ...v, [k.id]: !isVisible }))}
|
||||
className='h-8 w-8'
|
||||
>
|
||||
{isVisible ? (
|
||||
<EyeOff className='h-4 w-4' />
|
||||
) : (
|
||||
<Eye className='h-4 w-4' />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{isVisible ? 'Hide' : 'Reveal'}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant='secondary'
|
||||
size='icon'
|
||||
onClick={() => onCopy(k.apiKey, k.id)}
|
||||
className='h-8 w-8'
|
||||
>
|
||||
{copiedKeyIds[k.id] ? (
|
||||
<Check className='h-4 w-4 text-green-500' />
|
||||
) : (
|
||||
<Copy className='h-4 w-4' />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Copy</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
onClick={() => {
|
||||
setDeleteKey(k)
|
||||
setShowDeleteDialog(true)
|
||||
}}
|
||||
className='h-8 w-8 text-destructive hover:bg-destructive/10'
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Delete</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* New Key Dialog */}
|
||||
<Dialog
|
||||
open={showNewKeyDialog}
|
||||
onOpenChange={(open) => {
|
||||
setShowNewKeyDialog(open)
|
||||
if (!open) setNewKey(null)
|
||||
}}
|
||||
>
|
||||
<DialogContent className='sm:max-w-md'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Your Copilot API key has been created</DialogTitle>
|
||||
<DialogDescription>
|
||||
This is the only time you will see the full key. Copy it now and store it securely.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{newKey && (
|
||||
<div className='space-y-4 py-3'>
|
||||
<div className='space-y-2'>
|
||||
<Label>API Key</Label>
|
||||
<div className='relative'>
|
||||
<Input
|
||||
readOnly
|
||||
value={newKey.apiKey}
|
||||
className='border-slate-300 bg-muted/50 pr-10 font-mono text-sm'
|
||||
/>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='-translate-y-1/2 absolute top-1/2 right-1 h-7 w-7'
|
||||
onClick={() => onCopy(newKey.apiKey)}
|
||||
>
|
||||
{newKeyCopySuccess ? (
|
||||
<Check className='h-4 w-4 text-green-500' />
|
||||
) : (
|
||||
<Copy className='h-4 w-4' />
|
||||
)}
|
||||
<span className='sr-only'>Copy to clipboard</span>
|
||||
</Button>
|
||||
</div>
|
||||
<p className='mt-1 text-muted-foreground text-xs'>
|
||||
For security, we don't store the complete key. You won't be able to view it again.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter className='sm:justify-end'>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShowNewKeyDialog(false)
|
||||
setNewKey(null)
|
||||
}}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<AlertDialogContent className='sm:max-w-md'>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Copilot API Key</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{deleteKey && (
|
||||
<>
|
||||
Are you sure you want to delete this Copilot API key? This action cannot be
|
||||
undone.
|
||||
</>
|
||||
)}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter className='gap-2 sm:justify-end'>
|
||||
<AlertDialogCancel onClick={() => setDeleteKey(null)}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => {
|
||||
if (deleteKey) {
|
||||
onDelete(deleteKey.id)
|
||||
}
|
||||
setShowDeleteDialog(false)
|
||||
setDeleteKey(null)
|
||||
}}
|
||||
className='bg-destructive text-destructive-foreground hover:bg-destructive/90'
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
export { Account } from './account/account'
|
||||
export { ApiKeys } from './api-keys/api-keys'
|
||||
export { Copilot } from './copilot/copilot'
|
||||
export { Credentials } from './credentials/credentials'
|
||||
export { EnvironmentVariables } from './environment/environment'
|
||||
export { General } from './general/general'
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
Bot,
|
||||
CreditCard,
|
||||
KeyRound,
|
||||
KeySquare,
|
||||
@@ -8,10 +9,13 @@ import {
|
||||
UserCircle,
|
||||
Users,
|
||||
} from 'lucide-react'
|
||||
import { isBillingEnabled } from '@/lib/environment'
|
||||
import { getEnv, isTruthy } from '@/lib/env'
|
||||
import { isHosted } from '@/lib/environment'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useSubscriptionStore } from '@/stores/subscription/store'
|
||||
|
||||
const isBillingEnabled = isTruthy(getEnv('NEXT_PUBLIC_BILLING_ENABLED'))
|
||||
|
||||
interface SettingsNavigationProps {
|
||||
activeSection: string
|
||||
onSectionChange: (
|
||||
@@ -24,6 +28,7 @@ interface SettingsNavigationProps {
|
||||
| 'subscription'
|
||||
| 'team'
|
||||
| 'privacy'
|
||||
| 'copilot'
|
||||
) => void
|
||||
hasOrganization: boolean
|
||||
}
|
||||
@@ -37,6 +42,7 @@ type NavigationItem = {
|
||||
| 'apikeys'
|
||||
| 'subscription'
|
||||
| 'team'
|
||||
| 'copilot'
|
||||
| 'privacy'
|
||||
label: string
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
@@ -70,6 +76,11 @@ const allNavigationItems: NavigationItem[] = [
|
||||
label: 'API Keys',
|
||||
icon: KeySquare,
|
||||
},
|
||||
{
|
||||
id: 'copilot',
|
||||
label: 'Copilot',
|
||||
icon: Bot,
|
||||
},
|
||||
{
|
||||
id: 'privacy',
|
||||
label: 'Privacy',
|
||||
@@ -99,6 +110,9 @@ export function SettingsNavigation({
|
||||
const subscription = getSubscriptionStatus()
|
||||
|
||||
const navigationItems = allNavigationItems.filter((item) => {
|
||||
if (item.id === 'copilot' && !isHosted) {
|
||||
return false
|
||||
}
|
||||
if (item.hideWhenBillingDisabled && !isBillingEnabled) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -3,12 +3,14 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import { Button, Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui'
|
||||
import { isBillingEnabled } from '@/lib/environment'
|
||||
import { getEnv, isTruthy } from '@/lib/env'
|
||||
import { isHosted } from '@/lib/environment'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
Account,
|
||||
ApiKeys,
|
||||
Copilot,
|
||||
Credentials,
|
||||
EnvironmentVariables,
|
||||
General,
|
||||
@@ -22,6 +24,8 @@ import { useGeneralStore } from '@/stores/settings/general/store'
|
||||
|
||||
const logger = createLogger('SettingsModal')
|
||||
|
||||
const isBillingEnabled = isTruthy(getEnv('NEXT_PUBLIC_BILLING_ENABLED'))
|
||||
|
||||
interface SettingsModalProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
@@ -36,6 +40,7 @@ type SettingsSection =
|
||||
| 'subscription'
|
||||
| 'team'
|
||||
| 'privacy'
|
||||
| 'copilot'
|
||||
|
||||
export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
||||
const [activeSection, setActiveSection] = useState<SettingsSection>('general')
|
||||
@@ -146,6 +151,11 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
||||
<TeamManagement />
|
||||
</div>
|
||||
)}
|
||||
{isHosted && (
|
||||
<div className={cn('h-full', activeSection === 'copilot' ? 'block' : 'hidden')}>
|
||||
<Copilot />
|
||||
</div>
|
||||
)}
|
||||
<div className={cn('h-full', activeSection === 'privacy' ? 'block' : 'hidden')}>
|
||||
<Privacy />
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { HelpCircle, LibraryBig, ScrollText, Search, Settings, Shapes } from 'lu
|
||||
import { useParams, usePathname, useRouter } from 'next/navigation'
|
||||
import { Button, ScrollArea, Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui'
|
||||
import { useSession } from '@/lib/auth-client'
|
||||
import { isBillingEnabled } from '@/lib/environment'
|
||||
import { getEnv, isTruthy } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { generateWorkspaceName } from '@/lib/naming'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -38,6 +38,8 @@ const logger = createLogger('Sidebar')
|
||||
|
||||
const SIDEBAR_GAP = 12 // 12px gap between components - easily editable
|
||||
|
||||
const isBillingEnabled = isTruthy(getEnv('NEXT_PUBLIC_BILLING_ENABLED'))
|
||||
|
||||
/**
|
||||
* Optimized auto-scroll hook for smooth drag operations
|
||||
* Extracted outside component for better performance
|
||||
|
||||
13
apps/sim/db/migrations/0074_abnormal_dreadnoughts.sql
Normal file
13
apps/sim/db/migrations/0074_abnormal_dreadnoughts.sql
Normal file
@@ -0,0 +1,13 @@
|
||||
CREATE TABLE "copilot_api_keys" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"api_key_encrypted" text NOT NULL,
|
||||
"api_key_lookup" text NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "user_stats" ADD COLUMN "total_copilot_cost" numeric DEFAULT '0' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "user_stats" ADD COLUMN "total_copilot_tokens" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "user_stats" ADD COLUMN "total_copilot_calls" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "copilot_api_keys" ADD CONSTRAINT "copilot_api_keys_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "copilot_api_keys_api_key_encrypted_hash_idx" ON "copilot_api_keys" USING hash ("api_key_encrypted");--> statement-breakpoint
|
||||
CREATE INDEX "copilot_api_keys_lookup_hash_idx" ON "copilot_api_keys" USING hash ("api_key_lookup");
|
||||
5968
apps/sim/db/migrations/meta/0074_snapshot.json
Normal file
5968
apps/sim/db/migrations/meta/0074_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -512,6 +512,13 @@
|
||||
"when": 1755286337930,
|
||||
"tag": "0073_hot_champions",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 74,
|
||||
"version": "7",
|
||||
"when": 1755304368539,
|
||||
"tag": "0074_abnormal_dreadnoughts",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -465,6 +465,10 @@ export const userStats = pgTable('user_stats', {
|
||||
billingPeriodStart: timestamp('billing_period_start').defaultNow(), // When current billing period started
|
||||
billingPeriodEnd: timestamp('billing_period_end'), // When current billing period ends
|
||||
lastPeriodCost: decimal('last_period_cost').default('0'), // Usage from previous billing period
|
||||
// Copilot usage tracking
|
||||
totalCopilotCost: decimal('total_copilot_cost').notNull().default('0'),
|
||||
totalCopilotTokens: integer('total_copilot_tokens').notNull().default(0),
|
||||
totalCopilotCalls: integer('total_copilot_calls').notNull().default(0),
|
||||
lastActive: timestamp('last_active').notNull().defaultNow(),
|
||||
})
|
||||
|
||||
@@ -1178,3 +1182,25 @@ export const copilotFeedback = pgTable(
|
||||
createdAtIdx: index('copilot_feedback_created_at_idx').on(table.createdAt),
|
||||
})
|
||||
)
|
||||
|
||||
export const copilotApiKeys = pgTable(
|
||||
'copilot_api_keys',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
apiKeyEncrypted: text('api_key_encrypted').notNull(),
|
||||
apiKeyLookup: text('api_key_lookup').notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
apiKeyEncryptedHashIdx: index('copilot_api_keys_api_key_encrypted_hash_idx').using(
|
||||
'hash',
|
||||
table.apiKeyEncrypted
|
||||
),
|
||||
apiKeyLookupHashIdx: index('copilot_api_keys_lookup_hash_idx').using(
|
||||
'hash',
|
||||
table.apiKeyLookup
|
||||
),
|
||||
})
|
||||
)
|
||||
|
||||
@@ -317,11 +317,13 @@ export class AgentBlockHandler implements BlockHandler {
|
||||
}
|
||||
|
||||
private addUserPrompt(messages: Message[], userPrompt: any) {
|
||||
let content = userPrompt
|
||||
let content: string
|
||||
if (typeof userPrompt === 'object' && userPrompt.input) {
|
||||
content = userPrompt.input
|
||||
content = String(userPrompt.input)
|
||||
} else if (typeof userPrompt === 'object') {
|
||||
content = JSON.stringify(userPrompt)
|
||||
} else {
|
||||
content = String(userPrompt)
|
||||
}
|
||||
|
||||
messages.push({ role: 'user', content })
|
||||
|
||||
@@ -1167,9 +1167,9 @@ export const auth = betterAuth({
|
||||
stripeClient,
|
||||
stripeWebhookSecret: env.STRIPE_WEBHOOK_SECRET || '',
|
||||
createCustomerOnSignUp: true,
|
||||
onCustomerCreate: async ({ customer, stripeCustomer, user }, request) => {
|
||||
onCustomerCreate: async ({ stripeCustomer, user }, request) => {
|
||||
logger.info('Stripe customer created', {
|
||||
customerId: customer.id,
|
||||
customerId: stripeCustomer.id,
|
||||
userId: user.id,
|
||||
})
|
||||
|
||||
|
||||
@@ -71,6 +71,7 @@ export interface SendMessageRequest {
|
||||
export interface ApiResponse {
|
||||
success: boolean
|
||||
error?: string
|
||||
status?: number
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -86,7 +87,7 @@ export interface StreamingResponse extends ApiResponse {
|
||||
async function handleApiError(response: Response, defaultMessage: string): Promise<string> {
|
||||
try {
|
||||
const data = await response.json()
|
||||
return data.error || defaultMessage
|
||||
return (data && (data.error || data.message)) || defaultMessage
|
||||
} catch {
|
||||
return `${defaultMessage} (${response.status})`
|
||||
}
|
||||
@@ -111,11 +112,19 @@ export async function sendStreamingMessage(
|
||||
|
||||
if (!response.ok) {
|
||||
const errorMessage = await handleApiError(response, 'Failed to send streaming message')
|
||||
throw new Error(errorMessage)
|
||||
return {
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
status: response.status,
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error('No response body received')
|
||||
return {
|
||||
success: false,
|
||||
error: 'No response body received',
|
||||
status: 500,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { env } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { SIM_AGENT_API_URL_DEFAULT } from '@/lib/sim-agent'
|
||||
import { getAllBlocks } from '@/blocks/registry'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import { resolveOutputType } from '@/blocks/utils'
|
||||
@@ -7,8 +8,7 @@ import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/w
|
||||
import { BaseCopilotTool } from '../base'
|
||||
|
||||
// Sim Agent API configuration
|
||||
const SIM_AGENT_API_URL = env.SIM_AGENT_API_URL || 'http://localhost:8000'
|
||||
const SIM_AGENT_API_KEY = env.SIM_AGENT_API_KEY
|
||||
const SIM_AGENT_API_URL = env.SIM_AGENT_API_URL || SIM_AGENT_API_URL_DEFAULT
|
||||
|
||||
interface BuildWorkflowParams {
|
||||
yamlContent: string
|
||||
@@ -71,7 +71,6 @@ async function buildWorkflow(params: BuildWorkflowParams): Promise<BuildWorkflow
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(SIM_AGENT_API_KEY && { 'x-api-key': SIM_AGENT_API_KEY }),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
yamlContent,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { env } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { SIM_AGENT_API_URL_DEFAULT } from '@/lib/sim-agent'
|
||||
import { getAllBlocks } from '@/blocks/registry'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import { resolveOutputType } from '@/blocks/utils'
|
||||
@@ -8,8 +9,7 @@ import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/w
|
||||
const logger = createLogger('EditWorkflowAPI')
|
||||
|
||||
// Sim Agent API configuration
|
||||
const SIM_AGENT_API_URL = env.SIM_AGENT_API_URL || 'http://localhost:8000'
|
||||
const SIM_AGENT_API_KEY = env.SIM_AGENT_API_KEY
|
||||
const SIM_AGENT_API_URL = env.SIM_AGENT_API_URL || SIM_AGENT_API_URL_DEFAULT
|
||||
|
||||
// Types for operations
|
||||
interface EditWorkflowOperation {
|
||||
@@ -46,7 +46,6 @@ async function applyOperationsToYaml(
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(SIM_AGENT_API_KEY && { 'x-api-key': SIM_AGENT_API_KEY }),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
yamlContent: currentYaml,
|
||||
@@ -481,7 +480,6 @@ async function editWorkflow(params: EditWorkflowParams): Promise<EditWorkflowRes
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(SIM_AGENT_API_KEY && { 'x-api-key': SIM_AGENT_API_KEY }),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
workflowState,
|
||||
|
||||
@@ -26,7 +26,6 @@ describe('Email Validation', () => {
|
||||
it.concurrent('should accept legitimate business emails', async () => {
|
||||
const legitimateEmails = [
|
||||
'test@gmail.com',
|
||||
'noreply@gmail.com',
|
||||
'no-reply@yahoo.com',
|
||||
'user12345@outlook.com',
|
||||
'longusernamehere@gmail.com',
|
||||
|
||||
@@ -24,8 +24,10 @@ export const env = createEnv({
|
||||
ALLOWED_LOGIN_DOMAINS: z.string().optional(), // Comma-separated list of allowed email domains for login
|
||||
ENCRYPTION_KEY: z.string().min(32), // Key for encrypting sensitive data
|
||||
INTERNAL_API_SECRET: z.string().min(32), // Secret for internal API authentication
|
||||
SIM_AGENT_API_KEY: z.string().min(1).optional(), // Secret for internal sim agent API authentication
|
||||
COPILOT_API_KEY: z.string().min(1).optional(), // Secret for internal sim agent API authentication
|
||||
SIM_AGENT_API_URL: z.string().url().optional(), // URL for internal sim agent API
|
||||
AGENT_API_DB_ENCRYPTION_KEY: z.string().min(32).optional(), // Key for encrypting sensitive data for sim agent
|
||||
AGENT_API_NETWORK_ENCRYPTION_KEY: z.string().min(32).optional(), // Key for encrypting sensitive data for sim agent
|
||||
|
||||
// Database & Storage
|
||||
POSTGRES_URL: z.string().url().optional(), // Alternative PostgreSQL connection string
|
||||
@@ -69,7 +71,6 @@ export const env = createEnv({
|
||||
// Monitoring & Analytics
|
||||
TELEMETRY_ENDPOINT: z.string().url().optional(), // Custom telemetry/analytics endpoint
|
||||
COST_MULTIPLIER: z.number().optional(), // Multiplier for cost calculations
|
||||
COPILOT_COST_MULTIPLIER: z.number().optional(), // Multiplier for copilot cost calculations
|
||||
SENTRY_ORG: z.string().optional(), // Sentry organization for error tracking
|
||||
SENTRY_PROJECT: z.string().optional(), // Sentry project for error tracking
|
||||
SENTRY_AUTH_TOKEN: z.string().optional(), // Sentry authentication token
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Environment utility functions for consistent environment detection across the application
|
||||
*/
|
||||
import { env, getEnv, isTruthy } from './env'
|
||||
import { env, isTruthy } from './env'
|
||||
|
||||
/**
|
||||
* Is the application running in production mode
|
||||
@@ -21,13 +21,14 @@ export const isTest = env.NODE_ENV === 'test'
|
||||
/**
|
||||
* Is this the hosted version of the application
|
||||
*/
|
||||
export const isHosted = env.NEXT_PUBLIC_APP_URL === 'https://www.sim.ai'
|
||||
export const isHosted =
|
||||
env.NEXT_PUBLIC_APP_URL === 'https://www.sim.ai' ||
|
||||
env.NEXT_PUBLIC_APP_URL === 'https://www.staging.sim.ai'
|
||||
|
||||
/**
|
||||
* Is billing enforcement enabled
|
||||
*/
|
||||
export const isBillingEnabled =
|
||||
isTruthy(getEnv('NEXT_PUBLIC_BILLING_ENABLED')) || isTruthy(env.BILLING_ENABLED)
|
||||
export const isBillingEnabled = isTruthy(env.BILLING_ENABLED)
|
||||
|
||||
/**
|
||||
* Get cost multiplier based on environment
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { env } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { SIM_AGENT_API_URL_DEFAULT } from '@/lib/sim-agent'
|
||||
|
||||
const logger = createLogger('SimAgentClient')
|
||||
|
||||
// Base URL for the sim-agent service
|
||||
const SIM_AGENT_BASE_URL =
|
||||
process.env.NODE_ENV === 'development'
|
||||
? 'http://localhost:8000'
|
||||
: process.env.NEXT_PUBLIC_SIM_AGENT_URL || 'https://sim-agent.vercel.app'
|
||||
const SIM_AGENT_BASE_URL = env.SIM_AGENT_API_URL || SIM_AGENT_API_URL_DEFAULT
|
||||
|
||||
export interface SimAgentRequest {
|
||||
workflowId: string
|
||||
@@ -28,31 +27,6 @@ class SimAgentClient {
|
||||
this.baseUrl = SIM_AGENT_BASE_URL
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the API key lazily to ensure environment variables are loaded
|
||||
*/
|
||||
private getApiKey(): string {
|
||||
// Only try server-side env var (never expose to client)
|
||||
let apiKey = process.env.SIM_AGENT_API_KEY || ''
|
||||
|
||||
// If not found, try importing env library as fallback
|
||||
if (!apiKey) {
|
||||
try {
|
||||
const { env } = require('@/lib/env')
|
||||
apiKey = env.SIM_AGENT_API_KEY || ''
|
||||
} catch (e) {
|
||||
// env library not available or failed to load
|
||||
}
|
||||
}
|
||||
|
||||
if (!apiKey && typeof window === 'undefined') {
|
||||
// Only warn on server-side where API key should be available
|
||||
logger.warn('SIM_AGENT_API_KEY not configured')
|
||||
}
|
||||
|
||||
return apiKey
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a request to the sim-agent service
|
||||
*/
|
||||
@@ -66,23 +40,20 @@ class SimAgentClient {
|
||||
} = {}
|
||||
): Promise<SimAgentResponse<T>> {
|
||||
const requestId = crypto.randomUUID().slice(0, 8)
|
||||
const { method = 'POST', body, headers = {}, apiKey: providedApiKey } = options
|
||||
const { method = 'POST', body, headers = {} } = options
|
||||
|
||||
try {
|
||||
const url = `${this.baseUrl}${endpoint}`
|
||||
|
||||
// Use provided API key or try to get it from environment
|
||||
const apiKey = providedApiKey || this.getApiKey()
|
||||
const requestHeaders: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...(apiKey && { 'x-api-key': apiKey }),
|
||||
...headers,
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Making request to sim-agent`, {
|
||||
url,
|
||||
method,
|
||||
hasApiKey: !!apiKey,
|
||||
hasBody: !!body,
|
||||
})
|
||||
|
||||
@@ -157,7 +128,6 @@ class SimAgentClient {
|
||||
getConfig() {
|
||||
return {
|
||||
baseUrl: this.baseUrl,
|
||||
hasApiKey: !!this.getApiKey(),
|
||||
environment: process.env.NODE_ENV,
|
||||
}
|
||||
}
|
||||
|
||||
1
apps/sim/lib/sim-agent/constants.ts
Normal file
1
apps/sim/lib/sim-agent/constants.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const SIM_AGENT_API_URL_DEFAULT = 'https://agent.sim.ai'
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
export type { SimAgentRequest, SimAgentResponse } from './client'
|
||||
export { SimAgentClient, simAgentClient } from './client'
|
||||
export { SIM_AGENT_API_URL_DEFAULT } from './constants'
|
||||
|
||||
// Import for default export
|
||||
import { simAgentClient } from './client'
|
||||
|
||||
@@ -208,13 +208,13 @@ const nextConfig: NextConfig = {
|
||||
source: '/((?!api|_next|_vercel|favicon|static|.*\\..*).*)',
|
||||
destination: 'https://www.sim.ai/$1',
|
||||
permanent: true,
|
||||
has: [{ type: 'host', key: 'host', value: 'simstudio.ai' }],
|
||||
has: [{ type: 'host' as const, value: 'simstudio.ai' }],
|
||||
},
|
||||
{
|
||||
source: '/((?!api|_next|_vercel|favicon|static|.*\\..*).*)',
|
||||
destination: 'https://www.sim.ai/$1',
|
||||
permanent: true,
|
||||
has: [{ type: 'host', key: 'host', value: 'www.simstudio.ai' }],
|
||||
has: [{ type: 'host' as const, value: 'www.simstudio.ai' }],
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -238,6 +238,13 @@ function createErrorMessage(messageId: string, content: string): CopilotMessage
|
||||
role: 'assistant',
|
||||
content,
|
||||
timestamp: new Date().toISOString(),
|
||||
contentBlocks: [
|
||||
{
|
||||
type: 'text',
|
||||
content,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1803,6 +1810,12 @@ async function* parseSSEStream(
|
||||
}
|
||||
}
|
||||
|
||||
// Auth/usage assistant response messages for Copilot
|
||||
const COPILOT_AUTH_REQUIRED_MESSAGE =
|
||||
'*Authorization failed. An API key must be configured in order to use the copilot. You can configure an API key at [sim.ai](https://sim.ai).*'
|
||||
const COPILOT_USAGE_EXCEEDED_MESSAGE =
|
||||
'*Usage limit exceeded, please upgrade your plan at [sim.ai](https://sim.ai) to continue using the copilot*'
|
||||
|
||||
/**
|
||||
* Copilot store using the new unified API
|
||||
*/
|
||||
@@ -2249,7 +2262,28 @@ export const useCopilotStore = create<CopilotStore>()(
|
||||
logger.info('Message sending was aborted by user')
|
||||
return // Don't throw or update state, abort handler already did
|
||||
}
|
||||
throw new Error(result.error || 'Failed to send message')
|
||||
|
||||
// Handle specific upstream statuses
|
||||
let displayError = result.error || 'Failed to send message'
|
||||
if (result.status === 401) {
|
||||
displayError = COPILOT_AUTH_REQUIRED_MESSAGE
|
||||
} else if (result.status === 402) {
|
||||
displayError = COPILOT_USAGE_EXCEEDED_MESSAGE
|
||||
}
|
||||
|
||||
const errorMessage = createErrorMessage(streamingMessage.id, displayError)
|
||||
|
||||
// Show as a normal assistant response without global error for auth/usage cases
|
||||
const isAuthOrUsage = result.status === 401 || result.status === 402
|
||||
|
||||
set((state) => ({
|
||||
messages: state.messages.map((msg) =>
|
||||
msg.id === streamingMessage.id ? errorMessage : msg
|
||||
),
|
||||
error: isAuthOrUsage ? null : displayError,
|
||||
isSendingMessage: false,
|
||||
abortController: null,
|
||||
}))
|
||||
}
|
||||
} catch (error) {
|
||||
// Check if this was an abort
|
||||
@@ -2504,7 +2538,25 @@ export const useCopilotStore = create<CopilotStore>()(
|
||||
logger.info('Implicit feedback sending was aborted by user')
|
||||
return
|
||||
}
|
||||
throw new Error(result.error || 'Failed to send implicit feedback')
|
||||
// Handle specific upstream statuses as normal assistant responses
|
||||
let displayError = result.error || 'Failed to send implicit feedback'
|
||||
if (result.status === 401) {
|
||||
displayError = COPILOT_AUTH_REQUIRED_MESSAGE
|
||||
} else if (result.status === 402) {
|
||||
displayError = COPILOT_USAGE_EXCEEDED_MESSAGE
|
||||
}
|
||||
|
||||
const errorMessage = createErrorMessage(newAssistantMessage.id, displayError)
|
||||
|
||||
const isAuthOrUsage = result.status === 401 || result.status === 402
|
||||
set((state) => ({
|
||||
messages: state.messages.map((msg) =>
|
||||
msg.id === newAssistantMessage.id ? errorMessage : msg
|
||||
),
|
||||
error: isAuthOrUsage ? null : displayError,
|
||||
isSendingMessage: false,
|
||||
abortController: null,
|
||||
}))
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
|
||||
@@ -426,8 +426,12 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
|
||||
setActiveWorkflow: async (id: string) => {
|
||||
const { workflows, activeWorkflowId } = get()
|
||||
|
||||
if (activeWorkflowId === id) {
|
||||
logger.info(`Already active workflow ${id}, skipping switch`)
|
||||
// Check if workflow is already active AND has data loaded
|
||||
const workflowStoreState = useWorkflowStore.getState()
|
||||
const hasWorkflowData = Object.keys(workflowStoreState.blocks).length > 0
|
||||
|
||||
if (activeWorkflowId === id && hasWorkflowData) {
|
||||
logger.info(`Already active workflow ${id} with data loaded, skipping switch`)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user