Improve(chat): added multiple output option for chat panel & chat deploy (#310)

* improvement(chat): added multiple output selection for chat panel & chat deploy

* added tests, changed subdomain-check to subdomains/validate to be more RESTful

* added more tests

* added even more tests

* remove unused route

* acknowledged PR comments, updated UI for output selector
This commit is contained in:
Waleed Latif
2025-04-29 20:33:29 -07:00
committed by GitHub
parent 0dcd5ee30a
commit 5d4ddbc79d
20 changed files with 3418 additions and 254 deletions
+359
View File
@@ -0,0 +1,359 @@
/**
* Tests for chat subdomain API route
*
* @vitest-environment node
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createMockRequest } from '@/app/api/__test-utils__/utils'
describe('Chat Subdomain API Route', () => {
const mockWorkflowSingleOutput = {
id: 'response-id',
content: 'Test response',
timestamp: new Date().toISOString(),
type: 'workflow'
}
// Mock functions
const mockAddCorsHeaders = vi.fn().mockImplementation((response) => response)
const mockValidateChatAuth = vi.fn().mockResolvedValue({ authorized: true })
const mockSetChatAuthCookie = vi.fn()
const mockExecuteWorkflowForChat = vi.fn().mockResolvedValue(mockWorkflowSingleOutput)
// Mock database return values
const mockChatResult = [
{
id: 'chat-id',
workflowId: 'workflow-id',
userId: 'user-id',
isActive: true,
authType: 'public',
title: 'Test Chat',
description: 'Test chat description',
customizations: {
welcomeMessage: 'Welcome to the test chat',
primaryColor: '#000000'
},
outputConfigs: [
{ blockId: 'block-1', path: 'output' }
]
}
]
const mockWorkflowResult = [
{
isDeployed: true
}
]
beforeEach(() => {
vi.resetModules()
// Mock chat API utils
vi.doMock('../utils', () => ({
addCorsHeaders: mockAddCorsHeaders,
validateChatAuth: mockValidateChatAuth,
setChatAuthCookie: mockSetChatAuthCookie,
validateAuthToken: vi.fn().mockReturnValue(true),
executeWorkflowForChat: mockExecuteWorkflowForChat,
}))
// Mock logger
vi.doMock('@/lib/logs/console-logger', () => ({
createLogger: vi.fn().mockReturnValue({
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
}),
}))
// Mock database
vi.doMock('@/db', () => {
const mockLimitChat = vi.fn().mockReturnValue(mockChatResult)
const mockWhereChat = vi.fn().mockReturnValue({ limit: mockLimitChat })
const mockLimitWorkflow = vi.fn().mockReturnValue(mockWorkflowResult)
const mockWhereWorkflow = vi.fn().mockReturnValue({ limit: mockLimitWorkflow })
const mockFrom = vi.fn()
.mockImplementation((table) => {
// Check which table is being queried
if (table === 'workflow') {
return { where: mockWhereWorkflow }
}
return { where: mockWhereChat }
})
const mockSelect = vi.fn().mockReturnValue({ from: mockFrom })
return {
db: {
select: mockSelect
}
}
})
// Mock API response helpers
vi.doMock('@/app/api/workflows/utils', () => ({
createErrorResponse: vi.fn().mockImplementation((message, status = 400, code) => {
return new Response(
JSON.stringify({
error: code || 'Error',
message
}),
{ status }
)
}),
createSuccessResponse: vi.fn().mockImplementation((data) => {
return new Response(
JSON.stringify(data),
{ status: 200 }
)
})
}))
})
afterEach(() => {
vi.clearAllMocks()
})
describe('GET endpoint', () => {
it('should return chat info for a valid subdomain', async () => {
const req = createMockRequest('GET')
const params = Promise.resolve({ subdomain: 'test-chat' })
const { GET } = await import('./route')
const response = await GET(req, { params })
expect(response.status).toBe(200)
const data = await response.json()
expect(data).toHaveProperty('id', 'chat-id')
expect(data).toHaveProperty('title', 'Test Chat')
expect(data).toHaveProperty('description', 'Test chat description')
expect(data).toHaveProperty('customizations')
expect(data.customizations).toHaveProperty('welcomeMessage', 'Welcome to the test chat')
})
it('should return 404 for non-existent subdomain', async () => {
vi.doMock('@/db', () => {
const mockLimit = vi.fn().mockReturnValue([])
const mockWhere = vi.fn().mockReturnValue({ limit: mockLimit })
const mockFrom = vi.fn().mockReturnValue({ where: mockWhere })
const mockSelect = vi.fn().mockReturnValue({ from: mockFrom })
return {
db: {
select: mockSelect
}
}
})
const req = createMockRequest('GET')
const params = Promise.resolve({ subdomain: 'nonexistent' })
const { GET } = await import('./route')
const response = await GET(req, { params })
expect(response.status).toBe(404)
const data = await response.json()
expect(data).toHaveProperty('error')
expect(data).toHaveProperty('message', 'Chat not found')
})
it('should return 403 for inactive chat', async () => {
vi.doMock('@/db', () => {
const mockLimit = vi.fn().mockReturnValue([
{
id: 'chat-id',
isActive: false,
authType: 'public',
}
])
const mockWhere = vi.fn().mockReturnValue({ limit: mockLimit })
const mockFrom = vi.fn().mockReturnValue({ where: mockWhere })
const mockSelect = vi.fn().mockReturnValue({ from: mockFrom })
return {
db: {
select: mockSelect
}
}
})
const req = createMockRequest('GET')
const params = Promise.resolve({ subdomain: 'inactive-chat' })
const { GET } = await import('./route')
const response = await GET(req, { params })
expect(response.status).toBe(403)
const data = await response.json()
expect(data).toHaveProperty('error')
expect(data).toHaveProperty('message', 'This chat is currently unavailable')
})
it('should return 401 when authentication is required', async () => {
const originalValidateChatAuth = mockValidateChatAuth.getMockImplementation()
mockValidateChatAuth.mockImplementationOnce(async () => ({
authorized: false,
error: 'auth_required_password'
}))
const req = createMockRequest('GET')
const params = Promise.resolve({ subdomain: 'password-protected-chat' })
const { GET } = await import('./route')
const response = await GET(req, { params })
expect(response.status).toBe(401)
const data = await response.json()
expect(data).toHaveProperty('error')
expect(data).toHaveProperty('message', 'auth_required_password')
if (originalValidateChatAuth) {
mockValidateChatAuth.mockImplementation(originalValidateChatAuth)
}
})
})
describe('POST endpoint', () => {
it('should handle authentication requests without messages', async () => {
const req = createMockRequest('POST', { password: 'test-password' })
const params = Promise.resolve({ subdomain: 'password-protected-chat' })
const { POST } = await import('./route')
const response = await POST(req, { params })
expect(response.status).toBe(200)
const data = await response.json()
expect(data).toHaveProperty('authenticated', true)
expect(mockSetChatAuthCookie).toHaveBeenCalled()
})
it('should return 400 for requests without message', async () => {
const req = createMockRequest('POST', {})
const params = Promise.resolve({ subdomain: 'test-chat' })
const { POST } = await import('./route')
const response = await POST(req, { params })
expect(response.status).toBe(400)
const data = await response.json()
expect(data).toHaveProperty('error')
expect(data).toHaveProperty('message', 'No message provided')
})
it('should return 401 for unauthorized access', async () => {
const originalValidateChatAuth = mockValidateChatAuth.getMockImplementation()
mockValidateChatAuth.mockImplementationOnce(async () => ({
authorized: false,
error: 'Authentication required'
}))
const req = createMockRequest('POST', { message: 'Hello' })
const params = Promise.resolve({ subdomain: 'protected-chat' })
const { POST } = await import('./route')
const response = await POST(req, { params })
expect(response.status).toBe(401)
const data = await response.json()
expect(data).toHaveProperty('error')
expect(data).toHaveProperty('message', 'Authentication required')
if (originalValidateChatAuth) {
mockValidateChatAuth.mockImplementation(originalValidateChatAuth)
}
})
it('should return 503 when workflow is not available', async () => {
vi.doMock('@/db', () => {
const mockLimitChat = vi.fn().mockReturnValue([
{
id: 'chat-id',
workflowId: 'unavailable-workflow',
isActive: true,
authType: 'public',
}
])
const mockWhereChat = vi.fn().mockReturnValue({ limit: mockLimitChat })
// Second call returns non-deployed workflow
const mockLimitWorkflow = vi.fn().mockReturnValue([
{
isDeployed: false
}
])
const mockWhereWorkflow = vi.fn().mockReturnValue({ limit: mockLimitWorkflow })
// Mock from function to return different where implementations
const mockFrom = vi.fn()
.mockImplementationOnce(() => ({ where: mockWhereChat })) // First call (chat)
.mockImplementationOnce(() => ({ where: mockWhereWorkflow })) // Second call (workflow)
const mockSelect = vi.fn().mockReturnValue({ from: mockFrom })
return {
db: {
select: mockSelect
}
}
})
const req = createMockRequest('POST', { message: 'Hello' })
const params = Promise.resolve({ subdomain: 'test-chat' })
const { POST } = await import('./route')
const response = await POST(req, { params })
expect(response.status).toBe(503)
const data = await response.json()
expect(data).toHaveProperty('error')
expect(data).toHaveProperty('message', 'Chat workflow is not available')
})
it('should handle workflow execution errors gracefully', async () => {
const originalExecuteWorkflow = mockExecuteWorkflowForChat.getMockImplementation()
mockExecuteWorkflowForChat.mockImplementationOnce(async () => {
throw new Error('Execution failed')
})
const req = createMockRequest('POST', { message: 'Trigger error' })
const params = Promise.resolve({ subdomain: 'test-chat' })
const { POST } = await import('./route')
const response = await POST(req, { params })
expect(response.status).toBe(503)
const data = await response.json()
expect(data).toHaveProperty('error')
expect(data).toHaveProperty('message', 'Chat workflow is not available')
if (originalExecuteWorkflow) {
mockExecuteWorkflowForChat.mockImplementation(originalExecuteWorkflow)
}
})
})
})
+56 -18
View File
@@ -34,8 +34,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
authType: chat.authType,
password: chat.password,
allowedEmails: chat.allowedEmails,
outputBlockId: chat.outputBlockId,
outputPath: chat.outputPath,
outputConfigs: chat.outputConfigs,
})
.from(chat)
.where(eq(chat.subdomain, subdomain))
@@ -102,36 +101,74 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
// If it's text or another primitive, make sure it's accessible
let formattedResult: any = { output: null }
if (result && result.content) {
if (typeof result.content === 'object') {
// For objects like { text: "some content" }
if (result.content.text) {
formattedResult.output = result.content.text
if (result) {
// Check if we have multiple outputs
if (result.multipleOutputs && Array.isArray(result.contents)) {
// Format multiple outputs in a way that they can be displayed as separate messages
// Join all contents, ensuring each is on a new line if they're strings
const formattedContents = result.contents.map(content => {
if (typeof content === 'string') {
return content
}
try {
return JSON.stringify(content)
} catch (error) {
logger.warn(`[${requestId}] Error stringifying content:`, error)
return "[Object cannot be serialized]"
}
})
// Set output to be the joined contents
formattedResult = {
...result,
output: formattedContents.join('\n\n') // Separate each output with double newline
}
// Keep the original contents for clients that can handle structured data
formattedResult.multipleOutputs = true
formattedResult.contents = result.contents
} else if (result.content) {
// Handle single output cases
if (typeof result.content === 'object') {
// For objects like { text: "some content" }
if (result.content.text) {
formattedResult.output = result.content.text
} else {
// Keep the original structure but also add an output field
try {
formattedResult = {
...result,
output: JSON.stringify(result.content)
}
} catch (error) {
logger.warn(`[${requestId}] Error stringifying content:`, error)
formattedResult = {
...result,
output: "[Object cannot be serialized]"
}
}
}
} else {
// Keep the original structure but also add an output field
// For direct string content
formattedResult = {
...result,
output: JSON.stringify(result.content)
output: result.content
}
}
} else {
// For direct string content
// Fallback if no content
formattedResult = {
...result,
output: result.content
output: "No output returned from workflow"
}
}
} else {
// Fallback if no content
formattedResult = {
...result,
output: "No output returned from workflow"
}
}
logger.info(`[${requestId}] Returning formatted chat response:`, {
hasOutput: !!formattedResult.output,
outputType: typeof formattedResult.output
outputType: typeof formattedResult.output,
isMultipleOutputs: !!formattedResult.multipleOutputs
})
// Add CORS headers before returning the response
@@ -166,6 +203,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
authType: chat.authType,
password: chat.password,
allowedEmails: chat.allowedEmails,
outputConfigs: chat.outputConfigs,
})
.from(chat)
.where(eq(chat.subdomain, subdomain))
+9 -8
View File
@@ -27,8 +27,10 @@ const chatUpdateSchema = z.object({
authType: z.enum(["public", "password", "email"]).optional(),
password: z.string().optional(),
allowedEmails: z.array(z.string()).optional(),
outputBlockId: z.string().optional(),
outputPath: z.string().optional(),
outputConfigs: z.array(z.object({
blockId: z.string(),
path: z.string()
})).optional(),
})
/**
@@ -132,8 +134,7 @@ export async function PATCH(
authType,
password,
allowedEmails,
outputBlockId,
outputPath
outputConfigs
} = validatedData
// Check if subdomain is changing and if it's available
@@ -209,16 +210,16 @@ export async function PATCH(
}
// Handle output fields
if (outputBlockId !== undefined) updateData.outputBlockId = outputBlockId
if (outputPath !== undefined) updateData.outputPath = outputPath
if (outputConfigs) {
updateData.outputConfigs = outputConfigs
}
logger.info('Updating chat deployment with values:', {
chatId,
authType: updateData.authType,
hasPassword: updateData.password !== undefined,
emailCount: updateData.allowedEmails?.length,
outputBlockId: updateData.outputBlockId,
outputPath: updateData.outputPath
outputConfigsCount: updateData.outputConfigs ? updateData.outputConfigs.length : undefined
})
// Update the chat deployment
+304
View File
@@ -0,0 +1,304 @@
/**
* Tests for chat API route
*
* @vitest-environment node
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { NextRequest } from 'next/server'
describe('Chat API Route', () => {
const mockSelect = vi.fn()
const mockFrom = vi.fn()
const mockWhere = vi.fn()
const mockLimit = vi.fn()
const mockInsert = vi.fn()
const mockValues = vi.fn()
const mockReturning = vi.fn()
const mockCreateSuccessResponse = vi.fn()
const mockCreateErrorResponse = vi.fn()
const mockEncryptSecret = vi.fn()
beforeEach(() => {
vi.resetModules()
mockSelect.mockReturnValue({ from: mockFrom })
mockFrom.mockReturnValue({ where: mockWhere })
mockWhere.mockReturnValue({ limit: mockLimit })
mockInsert.mockReturnValue({ values: mockValues })
mockValues.mockReturnValue({ returning: mockReturning })
vi.doMock('@/db', () => ({
db: {
select: mockSelect,
insert: mockInsert,
},
}))
vi.doMock('@/db/schema', () => ({
chat: { userId: 'userId', subdomain: 'subdomain' },
workflow: { id: 'id', userId: 'userId', isDeployed: 'isDeployed' },
}))
vi.doMock('@/lib/logs/console-logger', () => ({
createLogger: vi.fn().mockReturnValue({
info: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
debug: vi.fn(),
}),
}))
vi.doMock('@/app/api/workflows/utils', () => ({
createSuccessResponse: mockCreateSuccessResponse.mockImplementation((data) => {
return new Response(JSON.stringify(data), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
}),
createErrorResponse: mockCreateErrorResponse.mockImplementation((message, status = 500) => {
return new Response(JSON.stringify({ error: message }), {
status,
headers: { 'Content-Type': 'application/json' },
})
}),
}))
vi.doMock('@/lib/utils', () => ({
encryptSecret: mockEncryptSecret.mockResolvedValue({ encrypted: 'encrypted-password' }),
}))
vi.doMock('uuid', () => ({
v4: vi.fn().mockReturnValue('test-uuid'),
}))
})
afterEach(() => {
vi.clearAllMocks()
})
describe('GET', () => {
it('should return 401 when user is not authenticated', async () => {
vi.doMock('@/lib/auth', () => ({
getSession: vi.fn().mockResolvedValue(null),
}))
const req = new NextRequest('http://localhost:3000/api/chat')
const { GET } = await import('./route')
const response = await GET(req)
expect(response.status).toBe(401)
expect(mockCreateErrorResponse).toHaveBeenCalledWith('Unauthorized', 401)
})
it('should return chat deployments for authenticated user', async () => {
vi.doMock('@/lib/auth', () => ({
getSession: vi.fn().mockResolvedValue({
user: { id: 'user-id' },
}),
}))
const mockDeployments = [{ id: 'deployment-1' }, { id: 'deployment-2' }]
mockWhere.mockResolvedValue(mockDeployments)
const req = new NextRequest('http://localhost:3000/api/chat')
const { GET } = await import('./route')
const response = await GET(req)
expect(response.status).toBe(200)
expect(mockCreateSuccessResponse).toHaveBeenCalledWith({ deployments: mockDeployments })
expect(mockWhere).toHaveBeenCalled()
})
it('should handle errors when fetching deployments', async () => {
vi.doMock('@/lib/auth', () => ({
getSession: vi.fn().mockResolvedValue({
user: { id: 'user-id' },
}),
}))
mockWhere.mockRejectedValue(new Error('Database error'))
const req = new NextRequest('http://localhost:3000/api/chat')
const { GET } = await import('./route')
const response = await GET(req)
expect(response.status).toBe(500)
expect(mockCreateErrorResponse).toHaveBeenCalledWith('Database error', 500)
})
})
describe('POST', () => {
it('should return 401 when user is not authenticated', async () => {
vi.doMock('@/lib/auth', () => ({
getSession: vi.fn().mockResolvedValue(null),
}))
const req = new NextRequest('http://localhost:3000/api/chat', {
method: 'POST',
body: JSON.stringify({}),
})
const { POST } = await import('./route')
const response = await POST(req)
expect(response.status).toBe(401)
expect(mockCreateErrorResponse).toHaveBeenCalledWith('Unauthorized', 401)
})
it('should validate request data', async () => {
vi.doMock('@/lib/auth', () => ({
getSession: vi.fn().mockResolvedValue({
user: { id: 'user-id' },
}),
}))
const invalidData = { title: 'Test Chat' } // Missing required fields
const req = new NextRequest('http://localhost:3000/api/chat', {
method: 'POST',
body: JSON.stringify(invalidData),
})
const { POST } = await import('./route')
const response = await POST(req)
expect(response.status).toBe(400)
})
it('should reject if subdomain already exists', async () => {
vi.doMock('@/lib/auth', () => ({
getSession: vi.fn().mockResolvedValue({
user: { id: 'user-id' },
}),
}))
const validData = {
workflowId: 'workflow-123',
subdomain: 'test-chat',
title: 'Test Chat',
customizations: {
primaryColor: '#000000',
welcomeMessage: 'Hello',
},
}
mockLimit.mockResolvedValueOnce([{ id: 'existing-chat' }]) // Subdomain exists
const req = new NextRequest('http://localhost:3000/api/chat', {
method: 'POST',
body: JSON.stringify(validData),
})
const { POST } = await import('./route')
const response = await POST(req)
expect(response.status).toBe(400)
expect(mockCreateErrorResponse).toHaveBeenCalledWith('Subdomain already in use', 400)
})
it('should reject if workflow not found or not owned by user', async () => {
vi.doMock('@/lib/auth', () => ({
getSession: vi.fn().mockResolvedValue({
user: { id: 'user-id' },
}),
}))
const validData = {
workflowId: 'workflow-123',
subdomain: 'test-chat',
title: 'Test Chat',
customizations: {
primaryColor: '#000000',
welcomeMessage: 'Hello',
},
}
mockLimit.mockResolvedValueOnce([]) // Subdomain is available
mockLimit.mockResolvedValueOnce([]) // Workflow not found
const req = new NextRequest('http://localhost:3000/api/chat', {
method: 'POST',
body: JSON.stringify(validData),
})
const { POST } = await import('./route')
const response = await POST(req)
expect(response.status).toBe(404)
expect(mockCreateErrorResponse).toHaveBeenCalledWith('Workflow not found or access denied', 404)
})
it('should reject if workflow is not deployed', async () => {
vi.doMock('@/lib/auth', () => ({
getSession: vi.fn().mockResolvedValue({
user: { id: 'user-id' },
}),
}))
const validData = {
workflowId: 'workflow-123',
subdomain: 'test-chat',
title: 'Test Chat',
customizations: {
primaryColor: '#000000',
welcomeMessage: 'Hello',
},
}
mockLimit.mockResolvedValueOnce([]) // Subdomain is available
mockLimit.mockResolvedValueOnce([{ isDeployed: false }]) // Workflow exists but not deployed
const req = new NextRequest('http://localhost:3000/api/chat', {
method: 'POST',
body: JSON.stringify(validData),
})
const { POST } = await import('./route')
const response = await POST(req)
expect(response.status).toBe(400)
expect(mockCreateErrorResponse).toHaveBeenCalledWith('Workflow must be deployed before creating a chat', 400)
})
it('should successfully create a chat deployment', async () => {
vi.doMock('@/lib/auth', () => ({
getSession: vi.fn().mockResolvedValue({
user: { id: 'user-id' },
}),
}))
// Mock environment variables
vi.stubGlobal('process', {
...process,
env: {
...process.env,
NODE_ENV: 'development'
}
})
const validData = {
workflowId: 'workflow-123',
subdomain: 'test-chat',
title: 'Test Chat',
customizations: {
primaryColor: '#000000',
welcomeMessage: 'Hello',
},
}
mockLimit.mockResolvedValueOnce([]) // Subdomain is available
mockLimit.mockResolvedValueOnce([{ isDeployed: true }]) // Workflow exists and is deployed
mockReturning.mockResolvedValue([{ id: 'test-uuid' }])
const req = new NextRequest('http://localhost:3000/api/chat', {
method: 'POST',
body: JSON.stringify(validData),
})
const { POST } = await import('./route')
const response = await POST(req)
expect(response.status).toBe(200)
expect(mockCreateSuccessResponse).toHaveBeenCalledWith({
id: 'test-uuid',
chatUrl: 'http://test-chat.localhost:3000',
message: 'Chat deployment created successfully'
})
})
})
})
+15 -10
View File
@@ -11,7 +11,6 @@ import { encryptSecret } from '@/lib/utils'
const logger = createLogger('ChatAPI')
// Define Zod schema for API request validation
const chatSchema = z.object({
workflowId: z.string().min(1, "Workflow ID is required"),
subdomain: z.string().min(1, "Subdomain is required")
@@ -25,8 +24,10 @@ const chatSchema = z.object({
authType: z.enum(["public", "password", "email"]).default("public"),
password: z.string().optional(),
allowedEmails: z.array(z.string()).optional().default([]),
outputBlockId: z.string().optional(),
outputPath: z.string().optional(),
outputConfigs: z.array(z.object({
blockId: z.string(),
path: z.string()
})).optional().default([]),
})
export async function GET(request: NextRequest) {
@@ -74,8 +75,7 @@ export async function POST(request: NextRequest) {
authType = 'public',
password,
allowedEmails = [],
outputBlockId,
outputPath
outputConfigs = []
} = validatedData
// Perform additional validation specific to auth types
@@ -132,10 +132,16 @@ export async function POST(request: NextRequest) {
authType,
hasPassword: !!encryptedPassword,
emailCount: allowedEmails?.length || 0,
outputBlockId,
outputPath
outputConfigsCount: outputConfigs.length
})
// Merge customizations with the additional fields
const mergedCustomizations = {
...(customizations || {}),
primaryColor: customizations?.primaryColor || '#802FFF',
welcomeMessage: customizations?.welcomeMessage || 'Hi there! How can I help you today?'
}
await db.insert(chat).values({
id,
workflowId,
@@ -143,13 +149,12 @@ export async function POST(request: NextRequest) {
subdomain,
title,
description: description || '',
customizations: customizations || {},
customizations: mergedCustomizations,
isActive: true,
authType,
password: encryptedPassword,
allowedEmails: authType === 'email' ? allowedEmails : [],
outputBlockId: outputBlockId || null,
outputPath: outputPath || null,
outputConfigs,
createdAt: new Date(),
updatedAt: new Date(),
})
@@ -0,0 +1,206 @@
/**
* Tests for subdomain validation API route
*
* @vitest-environment node
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { NextRequest } from 'next/server'
describe('Subdomain Validation API Route', () => {
// Mock database responses
const mockSelect = vi.fn()
const mockFrom = vi.fn()
const mockWhere = vi.fn()
const mockLimit = vi.fn()
// Mock success and error responses
const mockCreateSuccessResponse = vi.fn()
const mockCreateErrorResponse = vi.fn()
const mockNextResponseJson = vi.fn()
beforeEach(() => {
vi.resetModules()
// Set up database query chain
mockSelect.mockReturnValue({ from: mockFrom })
mockFrom.mockReturnValue({ where: mockWhere })
mockWhere.mockReturnValue({ limit: mockLimit })
// Mock the database
vi.doMock('@/db', () => ({
db: {
select: mockSelect,
},
}))
// Mock the schema
vi.doMock('@/db/schema', () => ({
chat: {
subdomain: 'subdomain',
},
}))
// Mock the logger
vi.doMock('@/lib/logs/console-logger', () => ({
createLogger: vi.fn().mockReturnValue({
info: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
debug: vi.fn(),
}),
}))
// Mock the response utilities
vi.doMock('@/app/api/workflows/utils', () => ({
createSuccessResponse: mockCreateSuccessResponse.mockImplementation((data) => {
return new Response(JSON.stringify(data), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
}),
createErrorResponse: mockCreateErrorResponse.mockImplementation((message, status = 500) => {
return new Response(JSON.stringify({ error: message }), {
status,
headers: { 'Content-Type': 'application/json' },
})
}),
}))
// Mock the NextResponse json method
mockNextResponseJson.mockImplementation((data, options) => {
return new Response(JSON.stringify(data), {
status: options?.status || 200,
headers: { 'Content-Type': 'application/json' },
})
})
vi.doMock('next/server', () => ({
NextRequest: vi.fn(),
NextResponse: {
json: mockNextResponseJson,
},
}))
})
afterEach(() => {
vi.clearAllMocks()
})
it('should return 401 when user is not authenticated', async () => {
vi.doMock('@/lib/auth', () => ({
getSession: vi.fn().mockResolvedValue(null),
}))
const req = new NextRequest('http://localhost:3000/api/chat/subdomains/validate?subdomain=test')
const { GET } = await import('./route')
const response = await GET(req)
expect(response.status).toBe(401)
expect(mockCreateErrorResponse).toHaveBeenCalledWith('Unauthorized', 401)
})
it('should return 400 when subdomain parameter is missing', async () => {
vi.doMock('@/lib/auth', () => ({
getSession: vi.fn().mockResolvedValue({
user: { id: 'user-id' },
}),
}))
const req = new NextRequest('http://localhost:3000/api/chat/subdomains/validate')
const { GET } = await import('./route')
const response = await GET(req)
expect(response.status).toBe(400)
expect(mockCreateErrorResponse).toHaveBeenCalledWith('Missing subdomain parameter', 400)
})
it('should return 400 when subdomain format is invalid', async () => {
vi.doMock('@/lib/auth', () => ({
getSession: vi.fn().mockResolvedValue({
user: { id: 'user-id' },
}),
}))
const req = new NextRequest('http://localhost:3000/api/chat/subdomains/validate?subdomain=Invalid_Subdomain!')
const { GET } = await import('./route')
const response = await GET(req)
const data = await response.json()
expect(response.status).toBe(400)
expect(data).toHaveProperty('available', false)
expect(data).toHaveProperty('error', 'Invalid subdomain format')
expect(mockNextResponseJson).toHaveBeenCalledWith(
{ available: false, error: 'Invalid subdomain format' },
{ status: 400 }
)
})
it('should return available=true when subdomain is valid and not in use', async () => {
vi.doMock('@/lib/auth', () => ({
getSession: vi.fn().mockResolvedValue({
user: { id: 'user-id' },
}),
}))
mockLimit.mockResolvedValue([])
const req = new NextRequest('http://localhost:3000/api/chat/subdomains/validate?subdomain=available-subdomain')
const { GET } = await import('./route')
const response = await GET(req)
expect(response.status).toBe(200)
expect(mockCreateSuccessResponse).toHaveBeenCalledWith({
available: true,
subdomain: 'available-subdomain',
})
})
it('should return available=false when subdomain is already in use', async () => {
vi.doMock('@/lib/auth', () => ({
getSession: vi.fn().mockResolvedValue({
user: { id: 'user-id' },
}),
}))
mockLimit.mockResolvedValue([{ id: 'existing-chat-id' }])
const req = new NextRequest('http://localhost:3000/api/chat/subdomains/validate?subdomain=used-subdomain')
const { GET } = await import('./route')
const response = await GET(req)
expect(response.status).toBe(200)
expect(mockCreateSuccessResponse).toHaveBeenCalledWith({
available: false,
subdomain: 'used-subdomain',
})
})
it('should return 500 when database query fails', async () => {
vi.doMock('@/lib/auth', () => ({
getSession: vi.fn().mockResolvedValue({
user: { id: 'user-id' },
}),
}))
mockLimit.mockRejectedValue(new Error('Database error'))
const req = new NextRequest('http://localhost:3000/api/chat/subdomains/validate?subdomain=error-subdomain')
const { GET } = await import('./route')
const response = await GET(req)
expect(response.status).toBe(500)
expect(mockCreateErrorResponse).toHaveBeenCalledWith('Failed to check subdomain availability', 500)
})
})
@@ -6,7 +6,7 @@ import { eq } from 'drizzle-orm'
import { createLogger } from '@/lib/logs/console-logger'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
const logger = createLogger('SubdomainCheck')
const logger = createLogger('SubdomainValidate')
export async function GET(request: Request) {
// Check if the user is authenticated
+301
View File
@@ -0,0 +1,301 @@
/**
* Tests for chat API utils
*
* @vitest-environment node
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { NextResponse } from 'next/server'
describe('Chat API Utils', () => {
beforeEach(() => {
vi.resetModules()
vi.doMock('@/lib/logs/console-logger', () => ({
createLogger: vi.fn().mockReturnValue({
info: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
debug: vi.fn(),
}),
}))
vi.stubGlobal('process', {
...process,
env: {
...process.env,
NODE_ENV: 'development'
}
})
})
afterEach(() => {
vi.clearAllMocks()
})
describe('Auth token utils', () => {
it('should encrypt and validate auth tokens', async () => {
const { encryptAuthToken, validateAuthToken } = await import('./utils')
const subdomainId = 'test-subdomain-id'
const type = 'password'
const token = encryptAuthToken(subdomainId, type)
expect(typeof token).toBe('string')
expect(token.length).toBeGreaterThan(0)
const isValid = validateAuthToken(token, subdomainId)
expect(isValid).toBe(true)
const isInvalidSubdomain = validateAuthToken(token, 'wrong-subdomain-id')
expect(isInvalidSubdomain).toBe(false)
})
it('should reject expired tokens', async () => {
const { validateAuthToken } = await import('./utils')
const subdomainId = 'test-subdomain-id'
// Create an expired token by directly constructing it with an old timestamp
const expiredToken = Buffer.from(`${subdomainId}:password:${Date.now() - (25 * 60 * 60 * 1000)}`).toString('base64')
const isValid = validateAuthToken(expiredToken, subdomainId)
expect(isValid).toBe(false)
})
})
describe('Cookie handling', () => {
it('should set auth cookie correctly', async () => {
const { setChatAuthCookie } = await import('./utils')
const mockSet = vi.fn()
const mockResponse = {
cookies: {
set: mockSet
}
} as unknown as NextResponse
const subdomainId = 'test-subdomain-id'
const type = 'password'
setChatAuthCookie(mockResponse, subdomainId, type)
expect(mockSet).toHaveBeenCalledWith({
name: `chat_auth_${subdomainId}`,
value: expect.any(String),
httpOnly: true,
secure: false, // Development mode
sameSite: 'lax',
path: '/',
domain: undefined, // Development mode
maxAge: 60 * 60 * 24,
})
})
})
describe('CORS handling', () => {
it('should add CORS headers for localhost in development', async () => {
const { addCorsHeaders } = await import('./utils')
const mockRequest = {
headers: {
get: vi.fn().mockReturnValue('http://test.localhost:3000')
}
} as any
const mockResponse = {
headers: {
set: vi.fn()
}
} as unknown as NextResponse
addCorsHeaders(mockResponse, mockRequest)
expect(mockResponse.headers.set).toHaveBeenCalledWith('Access-Control-Allow-Origin', 'http://test.localhost:3000')
expect(mockResponse.headers.set).toHaveBeenCalledWith('Access-Control-Allow-Credentials', 'true')
expect(mockResponse.headers.set).toHaveBeenCalledWith('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
expect(mockResponse.headers.set).toHaveBeenCalledWith('Access-Control-Allow-Headers', 'Content-Type, X-Requested-With')
})
it('should handle OPTIONS request', async () => {
const { OPTIONS } = await import('./utils')
const mockRequest = {
headers: {
get: vi.fn().mockReturnValue('http://test.localhost:3000')
}
} as any
const response = await OPTIONS(mockRequest)
expect(response.status).toBe(204)
})
})
describe('Chat auth validation', () => {
beforeEach(() => {
vi.doMock('./utils', async (importOriginal) => {
const original = await importOriginal() as any
return {
...original,
validateAuthToken: vi.fn((token, id) => {
if (token === 'valid-token' && id === 'chat-id') {
return true
}
return false
}),
}
})
// Mock decryptSecret globally for all auth tests
vi.doMock('@/lib/utils', () => ({
decryptSecret: vi.fn((encryptedValue) => {
return Promise.resolve({ decrypted: 'correct-password' })
}),
}))
})
it('should allow access to public chats', async () => {
const utils = await import('./utils')
const { validateChatAuth } = utils
const deployment = {
id: 'chat-id',
authType: 'public'
}
const mockRequest = {
cookies: {
get: vi.fn().mockReturnValue(null)
}
} as any
const result = await validateChatAuth('request-id', deployment, mockRequest)
expect(result.authorized).toBe(true)
})
it('should request password auth for GET requests', async () => {
const { validateChatAuth } = await import('./utils')
const deployment = {
id: 'chat-id',
authType: 'password'
}
const mockRequest = {
method: 'GET',
cookies: {
get: vi.fn().mockReturnValue(null)
}
} as any
const result = await validateChatAuth('request-id', deployment, mockRequest)
expect(result.authorized).toBe(false)
expect(result.error).toBe('auth_required_password')
})
it('should validate password for POST requests', async () => {
const { validateChatAuth } = await import('./utils')
const { decryptSecret } = await import('@/lib/utils')
const deployment = {
id: 'chat-id',
authType: 'password',
password: 'encrypted-password'
}
const mockRequest = {
method: 'POST',
cookies: {
get: vi.fn().mockReturnValue(null)
}
} as any
const parsedBody = {
password: 'correct-password'
}
const result = await validateChatAuth('request-id', deployment, mockRequest, parsedBody)
expect(decryptSecret).toHaveBeenCalledWith('encrypted-password')
expect(result.authorized).toBe(true)
})
it('should reject incorrect password', async () => {
const { validateChatAuth } = await import('./utils')
const deployment = {
id: 'chat-id',
authType: 'password',
password: 'encrypted-password'
}
const mockRequest = {
method: 'POST',
cookies: {
get: vi.fn().mockReturnValue(null)
}
} as any
const parsedBody = {
password: 'wrong-password'
}
const result = await validateChatAuth('request-id', deployment, mockRequest, parsedBody)
expect(result.authorized).toBe(false)
expect(result.error).toBe('Invalid password')
})
it('should request email auth for email-protected chats', async () => {
const { validateChatAuth } = await import('./utils')
const deployment = {
id: 'chat-id',
authType: 'email',
allowedEmails: ['user@example.com', '@company.com']
}
const mockRequest = {
method: 'GET',
cookies: {
get: vi.fn().mockReturnValue(null)
}
} as any
const result = await validateChatAuth('request-id', deployment, mockRequest)
expect(result.authorized).toBe(false)
expect(result.error).toBe('auth_required_email')
})
it('should check allowed emails for email auth', async () => {
const { validateChatAuth } = await import('./utils')
const deployment = {
id: 'chat-id',
authType: 'email',
allowedEmails: ['user@example.com', '@company.com']
}
const mockRequest = {
method: 'POST',
cookies: {
get: vi.fn().mockReturnValue(null)
}
} as any
const result1 = await validateChatAuth('request-id', deployment, mockRequest, { email: 'user@example.com' })
expect(result1.authorized).toBe(false)
expect(result1.error).toBe('otp_required')
const result2 = await validateChatAuth('request-id', deployment, mockRequest, { email: 'other@company.com' })
expect(result2.authorized).toBe(false)
expect(result2.error).toBe('otp_required')
const result3 = await validateChatAuth('request-id', deployment, mockRequest, { email: 'user@unknown.com' })
expect(result3.authorized).toBe(false)
expect(result3.error).toBe('Email not authorized')
})
})
})
+124 -47
View File
@@ -241,8 +241,8 @@ export async function executeWorkflowForChat(chatId: string, message: string) {
id: chat.id,
workflowId: chat.workflowId,
userId: chat.userId,
outputBlockId: chat.outputBlockId,
outputPath: chat.outputPath,
outputConfigs: chat.outputConfigs,
customizations: chat.customizations,
})
.from(chat)
.where(eq(chat.id, chatId))
@@ -256,6 +256,35 @@ export async function executeWorkflowForChat(chatId: string, message: string) {
const deployment = deploymentResult[0]
const workflowId = deployment.workflowId
// Check for multi-output configuration in customizations
const customizations = (deployment.customizations || {}) as Record<string, any>
let outputBlockIds: string[] = []
let outputPaths: string[] = []
// Extract output configs from the new schema format
if (deployment.outputConfigs && Array.isArray(deployment.outputConfigs)) {
// Extract block IDs and paths from the new outputConfigs array format
logger.debug(`[${requestId}] Found ${deployment.outputConfigs.length} output configs in deployment`)
deployment.outputConfigs.forEach(config => {
logger.debug(`[${requestId}] Processing output config: blockId=${config.blockId}, path=${config.path || 'none'}`)
})
outputBlockIds = deployment.outputConfigs.map(config => config.blockId)
outputPaths = deployment.outputConfigs.map(config => config.path || '')
} else {
// Use customizations as fallback
outputBlockIds = Array.isArray(customizations.outputBlockIds) ? customizations.outputBlockIds : []
outputPaths = Array.isArray(customizations.outputPaths) ? customizations.outputPaths : []
}
// Fall back to customizations if we still have no outputs
if (outputBlockIds.length === 0 && customizations.outputBlockIds && customizations.outputBlockIds.length > 0) {
outputBlockIds = customizations.outputBlockIds
outputPaths = customizations.outputPaths || new Array(outputBlockIds.length).fill('')
}
logger.debug(`[${requestId}] Using ${outputBlockIds.length} output blocks for extraction`)
// Find the workflow
const workflowResult = await db
.select({
@@ -414,56 +443,104 @@ export async function executeWorkflowForChat(chatId: string, message: string) {
logger.debug(`[${requestId}] Workflow executed successfully, blocks executed: ${result.logs?.length || 0}`)
// Get the output based on the selected block
let output
// Get the outputs from all selected blocks
let outputs: {content: any}[] = []
let hasFoundOutputs = false
if (deployment.outputBlockId) {
// Determine appropriate output
const blockId = deployment.outputBlockId
const path = deployment.outputPath
if (outputBlockIds.length > 0 && result.logs) {
logger.debug(`[${requestId}] Looking for outputs from ${outputBlockIds.length} configured blocks`)
// This is identical to what the chat panel does to extract outputs
logger.debug(`[${requestId}] Looking for output from block ${blockId} with path ${path || 'none'}`)
// Extract the specific block output
if (result.logs) {
output = extractBlockOutput(result.logs, blockId, path || undefined)
// Extract outputs from each selected block
for (let i = 0; i < outputBlockIds.length; i++) {
const blockId = outputBlockIds[i]
const path = outputPaths[i] || undefined
if (output !== null && output !== undefined) {
logger.debug(`[${requestId}] Found specific block output`)
} else {
logger.warn(`[${requestId}] Could not find specific block output, falling back to final output`)
output = result.output?.response || result.output
logger.debug(`[${requestId}] Looking for output from block ${blockId} with path ${path || 'none'}`)
// Find the block log entry
const blockLog = result.logs.find(log => log.blockId === blockId)
if (!blockLog || !blockLog.output) {
logger.debug(`[${requestId}] No output found for block ${blockId}`)
continue
}
} else {
logger.warn(`[${requestId}] No logs found in execution result, using final output`)
output = result.output?.response || result.output
// Extract the specific path if provided
let specificOutput = blockLog.output
if (path) {
logger.debug(`[${requestId}] Extracting path ${path} from output`)
const pathParts = path.split('.')
for (const part of pathParts) {
if (specificOutput === null || specificOutput === undefined || typeof specificOutput !== 'object') {
logger.debug(`[${requestId}] Cannot extract path ${part}, output is not an object`)
specificOutput = null
break
}
specificOutput = specificOutput[part]
}
}
if (specificOutput !== null && specificOutput !== undefined) {
logger.debug(`[${requestId}] Found output for block ${blockId}`)
outputs.push({
content: specificOutput
})
hasFoundOutputs = true
}
}
}
// If no specific outputs were found, use the final result
if (!hasFoundOutputs) {
logger.debug(`[${requestId}] No specific outputs found, using final output`)
if (result.output) {
if (result.output.response) {
outputs.push({
content: result.output.response
})
} else {
outputs.push({
content: result.output
})
}
}
}
// Simplify the response format to match what the chat panel expects
if (outputs.length === 1) {
const content = outputs[0].content
// Don't wrap strings in an object
if (typeof content === 'string') {
return {
id: uuidv4(),
content: content,
timestamp: new Date().toISOString(),
type: 'workflow'
}
}
// Return the content directly - avoid extra nesting
return {
id: uuidv4(),
content: content,
timestamp: new Date().toISOString(),
type: 'workflow'
}
} else if (outputs.length > 1) {
// For multiple outputs, create a structured object that can be handled better by the client
// This approach allows the client to decide how to render multiple outputs
return {
id: uuidv4(),
multipleOutputs: true,
contents: outputs.map(o => o.content),
timestamp: new Date().toISOString(),
type: 'workflow'
}
} else {
// No specific block selected, use final output
logger.debug(`[${requestId}] No output block specified, using final output`)
output = result.output?.response || result.output
}
// Format the output the same way ChatMessage does
let formattedOutput
if (typeof output === 'object' && output !== null) {
// For objects, use the entire object (ChatMessage component handles display)
formattedOutput = output
} else {
// For strings or primitives, format as text
formattedOutput = { text: String(output) }
}
// Add a timestamp like the chat panel adds to messages
const timestamp = new Date().toISOString()
// Create a response that mimics the structure in the chat panel
return {
id: uuidv4(),
content: formattedOutput,
timestamp: timestamp,
type: 'workflow'
// Fallback for no outputs - should rarely happen
return {
id: uuidv4(),
content: "No output returned from workflow",
timestamp: new Date().toISOString(),
type: 'workflow'
}
}
}
@@ -412,38 +412,67 @@ export default function ChatClient({ subdomain }: { subdomain: string }) {
const responseData = await response.json()
console.log('Message response:', responseData)
// Extract content from the response - could be in content or output
let messageContent = responseData.output
// Handle different response formats from API
if (!messageContent && responseData.content) {
// Content could be an object or a string
if (typeof responseData.content === 'object') {
// If it's an object with a text property, use that
if (responseData.content.text) {
messageContent = responseData.content.text
} else {
// Try to convert to string for display
if (responseData.multipleOutputs && responseData.contents && Array.isArray(responseData.contents)) {
// For multiple outputs, create separate assistant messages for each
const assistantMessages = responseData.contents.map((content: any) => {
// Format the content appropriately
let formattedContent = content
// Convert objects to strings for display
if (typeof formattedContent === 'object' && formattedContent !== null) {
try {
messageContent = JSON.stringify(responseData.content)
formattedContent = JSON.stringify(formattedContent)
} catch (e) {
messageContent = 'Received structured data response'
formattedContent = 'Received structured data response'
}
}
} else {
// Direct string content
messageContent = responseData.content
return {
id: crypto.randomUUID(),
content: formattedContent || "No content found",
type: 'assistant' as const,
timestamp: new Date(),
}
})
// Add all messages at once
setMessages((prev) => [...prev, ...assistantMessages])
} else {
// Handle single output as before
// Extract content from the response - could be in content or output
let messageContent = responseData.output
// Handle different response formats from API
if (!messageContent && responseData.content) {
// Content could be an object or a string
if (typeof responseData.content === 'object') {
// If it's an object with a text property, use that
if (responseData.content.text) {
messageContent = responseData.content.text
} else {
// Try to convert to string for display
try {
messageContent = JSON.stringify(responseData.content)
} catch (e) {
messageContent = 'Received structured data response'
}
}
} else {
// Direct string content
messageContent = responseData.content
}
}
}
const assistantMessage: ChatMessage = {
id: crypto.randomUUID(),
content: messageContent || "Sorry, I couldn't process your request.",
type: 'assistant',
timestamp: new Date(),
}
const assistantMessage: ChatMessage = {
id: crypto.randomUUID(),
content: messageContent || "Sorry, I couldn't process your request.",
type: 'assistant',
timestamp: new Date(),
}
setMessages((prev) => [...prev, assistantMessage])
setMessages((prev) => [...prev, assistantMessage])
}
} catch (error) {
console.error('Error sending message:', error)
@@ -640,14 +669,14 @@ export default function ChatClient({ subdomain }: { subdomain: string }) {
@keyframes growShrink {
0%,
100% {
transform: scale(0.9);
transform: scale(0.9)
}
50% {
transform: scale(1.1);
transform: scale(1.1)
}
}
.loading-dot {
animation: growShrink 1.5s infinite ease-in-out;
animation: growShrink 1.5s infinite ease-in-out
}
`}</style>
@@ -4,7 +4,6 @@ import { FormEvent, useEffect, useRef, useState } from 'react'
import {
AlertTriangle,
Check,
Circle,
Copy,
Eye,
EyeOff,
@@ -27,7 +26,6 @@ import {
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { CopyButton } from '@/components/ui/copy-button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Skeleton } from '@/components/ui/skeleton'
@@ -36,6 +34,7 @@ import { createLogger } from '@/lib/logs/console-logger'
import { cn } from '@/lib/utils'
import { useNotificationStore } from '@/stores/notifications/store'
import { OutputSelect } from '@/app/w/[id]/components/panel/components/chat/components/output-select/output-select'
import { OutputConfig } from '@/stores/panel/chat/types'
const logger = createLogger('ChatDeploy')
@@ -71,8 +70,8 @@ const chatSchema = z.object({
authType: z.enum(['public', 'password', 'email']),
password: z.string().optional(),
allowedEmails: z.array(z.string()).optional(),
outputBlockId: z.string().nullish(),
outputPath: z.string().nullish(),
outputBlockIds: z.array(z.string()).optional(),
outputPaths: z.array(z.string()).optional(),
})
export function ChatDeploy({
@@ -121,7 +120,7 @@ export function ChatDeploy({
description: string
authType: AuthType
emails: string[]
outputBlockId: string | null
selectedOutputIds: string[]
} | null>(null)
// State to track if any changes have been made
@@ -132,7 +131,7 @@ export function ChatDeploy({
const [internalShowDeleteConfirmation, setInternalShowDeleteConfirmation] = useState(false)
// Output block selection
const [selectedOutputBlock, setSelectedOutputBlock] = useState<string | null>(null)
const [selectedOutputBlocks, setSelectedOutputBlocks] = useState<string[]>([])
// Track manual submission state
const [chatSubmitting, setChatSubmitting] = useState(false)
@@ -191,7 +190,7 @@ export function ChatDeploy({
const subdomainChanged = subdomain !== originalValues.subdomain
const titleChanged = title !== originalValues.title
const descriptionChanged = description !== originalValues.description
const outputBlockChanged = selectedOutputBlock !== originalValues.outputBlockId
const outputBlockChanged = selectedOutputBlocks.some((blockId) => !originalValues.selectedOutputIds.includes(blockId))
const welcomeMessageChanged =
welcomeMessage !==
(existingChat.customizations?.welcomeMessage || 'Hi there! How can I help you today?')
@@ -224,7 +223,7 @@ export function ChatDeploy({
authType,
emails,
password,
selectedOutputBlock,
selectedOutputBlocks,
welcomeMessage,
originalValues,
])
@@ -263,7 +262,9 @@ export function ChatDeploy({
description: chatDetail.description || '',
authType: chatDetail.authType || 'public',
emails: Array.isArray(chatDetail.allowedEmails) ? [...chatDetail.allowedEmails] : [],
outputBlockId: chatDetail.outputBlockId || null,
selectedOutputIds: Array.isArray(chatDetail.outputConfigs)
? chatDetail.outputConfigs.map((config: OutputConfig) => `${config.blockId}_${config.path}`)
: [],
})
// Set emails if using email auth
@@ -273,10 +274,13 @@ export function ChatDeploy({
// For security, we don't populate password - user will need to enter a new one if changing it
// Inside the fetchExistingChat function - after loading other form values
if (chatDetail.outputBlockId && chatDetail.outputPath) {
const combinedOutputId = `${chatDetail.outputBlockId}_${chatDetail.outputPath}`
setSelectedOutputBlock(combinedOutputId)
// Inside the fetchExistingChat function - update how we load output configs
if (chatDetail.outputConfigs) {
const configs = Array.isArray(chatDetail.outputConfigs) ? chatDetail.outputConfigs as OutputConfig[] : []
const combinedOutputIds = configs.map(config =>
`${config.blockId}_${config.path}`
)
setSelectedOutputBlocks(combinedOutputIds)
}
// Set welcome message if it exists
@@ -346,7 +350,7 @@ export function ChatDeploy({
try {
const response = await fetch(
`/api/chat/subdomain-check?subdomain=${encodeURIComponent(domain)}`
`/api/chat/subdomains/validate?subdomain=${encodeURIComponent(domain)}`
)
const data = await response.json()
@@ -465,7 +469,7 @@ export function ChatDeploy({
subdomain,
title,
authType,
hasOutputBlockSelection: !!selectedOutputBlock,
hasOutputBlockSelection: !!selectedOutputBlocks.length,
})
// Basic validation
@@ -484,7 +488,7 @@ export function ChatDeploy({
setIsCheckingSubdomain(true)
try {
const response = await fetch(
`/api/chat/subdomain-check?subdomain=${encodeURIComponent(subdomain)}`
`/api/chat/subdomains/validate?subdomain=${encodeURIComponent(subdomain)}`
)
const data = await response.json()
@@ -505,14 +509,11 @@ export function ChatDeploy({
}
// Verify output selection if it's set
if (selectedOutputBlock) {
const firstUnderscoreIndex = selectedOutputBlock.indexOf('_')
if (firstUnderscoreIndex === -1) {
logger.error('Invalid output block format', { selectedOutputBlock })
setErrorMessage('Invalid output block format. Please select a valid output.')
setChatSubmitting(false)
return
}
if (selectedOutputBlocks.length === 0) {
logger.error('No output blocks selected')
setErrorMessage('Please select at least one output block')
setChatSubmitting(false)
return
}
if (subdomainError) {
@@ -596,24 +597,42 @@ export function ChatDeploy({
}
// Add output block configuration if selected
if (selectedOutputBlock) {
const firstUnderscoreIndex = selectedOutputBlock.indexOf('_')
if (firstUnderscoreIndex !== -1) {
const blockId = selectedOutputBlock.substring(0, firstUnderscoreIndex)
const path = selectedOutputBlock.substring(firstUnderscoreIndex + 1)
payload.outputBlockId = blockId
payload.outputPath = path
logger.info('Added output configuration to payload:', {
outputBlockId: blockId,
outputPath: path,
if (selectedOutputBlocks && selectedOutputBlocks.length > 0) {
const outputConfigs = selectedOutputBlocks
.map(outputId => {
const firstUnderscoreIndex = outputId.indexOf('_')
// Only process IDs that have the correct blockId_path format
if (firstUnderscoreIndex !== -1) {
const blockId = outputId.substring(0, firstUnderscoreIndex)
const path = outputId.substring(firstUnderscoreIndex + 1)
// Additional validation to ensure both parts are non-empty
if (blockId && path) {
return { blockId, path } as OutputConfig
}
logger.warn(`Invalid output format: ${outputId}, missing blockId or path`)
return null
}
logger.warn(`Invalid output ID format: ${outputId}, missing required format blockId_path`)
return null
})
.filter(Boolean) as OutputConfig[] // Remove any null values
// Only include output configurations if we have valid ones
if (outputConfigs.length > 0) {
payload.outputConfigs = outputConfigs
logger.info('Added output configuration to payload:', {
outputConfigsCount: outputConfigs.length,
outputConfigs: outputConfigs
})
} else {
logger.warn('No valid output configurations found in selection')
payload.outputConfigs = []
}
} else {
// No output block selected - explicitly set to null
payload.outputBlockId = null
payload.outputPath = null
// No output blocks selected - explicitly set to empty array
payload.outputConfigs = []
}
// Pass the API key from workflow deployment
@@ -658,7 +677,7 @@ export function ChatDeploy({
authType: payload.authType,
hasPassword: !!payload.password,
emailCount: payload.allowedEmails?.length || 0,
hasOutputConfig: !!payload.outputBlockId,
hasOutputConfig: !!payload.outputConfigs.length,
deployApiEnabled: payload.deployApiEnabled,
})
@@ -948,17 +967,17 @@ export function ChatDeploy({
<CardContent className="p-1">
<OutputSelect
workflowId={workflowId}
selectedOutput={selectedOutputBlock}
onOutputSelect={(value) => {
logger.info(`Output block selection changed to: ${value}`)
setSelectedOutputBlock(value)
selectedOutputs={selectedOutputBlocks}
onOutputSelect={(values) => {
logger.info(`Output block selection changed to: ${values}`)
setSelectedOutputBlocks(values)
// Mark as changed to enable update button
if (existingChat) {
setHasChanges(true)
}
}}
placeholder="Select which block output to use"
placeholder="Select which block outputs to use"
disabled={isDeploying}
/>
</CardContent>
@@ -9,7 +9,6 @@ import { useExecutionStore } from '@/stores/execution/store'
import { useChatStore } from '@/stores/panel/chat/store'
import { useConsoleStore } from '@/stores/panel/console/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import { useWorkflowExecution } from '../../../../hooks/use-workflow-execution'
import { ChatMessage } from './components/chat-message/chat-message'
import { OutputSelect } from './components/output-select/output-select'
@@ -25,7 +24,6 @@ export function Chat({ panelWidth, chatMessage, setChatMessage }: ChatProps) {
const { messages, addMessage, selectedWorkflowOutputs, setSelectedWorkflowOutput } =
useChatStore()
const { entries } = useConsoleStore()
const blocks = useWorkflowStore((state) => state.blocks)
const messagesEndRef = useRef<HTMLDivElement>(null)
// Use the execution store state to track if a workflow is executing
@@ -48,13 +46,27 @@ export function Chat({ panelWidth, chatMessage, setChatMessage }: ChatProps) {
.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())
}, [messages, activeWorkflowId])
// Get selected workflow output
const selectedOutput = useMemo(() => {
if (!activeWorkflowId) return null
const selectedId = selectedWorkflowOutputs[activeWorkflowId]
if (!selectedId) return outputEntries[0]?.id || null
return selectedId
}, [selectedWorkflowOutputs, activeWorkflowId, outputEntries])
// Get selected workflow outputs
const selectedOutputs = useMemo(() => {
if (!activeWorkflowId) return []
const selected = selectedWorkflowOutputs[activeWorkflowId]
if (!selected || selected.length === 0) {
const defaultSelection = outputEntries.length > 0 ? [outputEntries[0].id] : []
return defaultSelection
}
// Ensure we have no duplicates in the selection
const dedupedSelection = [...new Set(selected)]
// If deduplication removed items, update the store
if (dedupedSelection.length !== selected.length) {
setSelectedWorkflowOutput(activeWorkflowId, dedupedSelection)
return dedupedSelection
}
return selected
}, [selectedWorkflowOutputs, activeWorkflowId, outputEntries, setSelectedWorkflowOutput])
// Auto-scroll to bottom when new messages are added
useEffect(() => {
@@ -94,9 +106,17 @@ export function Chat({ panelWidth, chatMessage, setChatMessage }: ChatProps) {
}
// Handle output selection
const handleOutputSelection = (value: string) => {
const handleOutputSelection = (values: string[]) => {
// Ensure no duplicates in selection
const dedupedValues = [...new Set(values)]
if (activeWorkflowId) {
setSelectedWorkflowOutput(activeWorkflowId, value)
// If array is empty, explicitly set to empty array to ensure complete reset
if (dedupedValues.length === 0) {
setSelectedWorkflowOutput(activeWorkflowId, [])
} else {
setSelectedWorkflowOutput(activeWorkflowId, dedupedValues)
}
}
}
@@ -106,10 +126,10 @@ export function Chat({ panelWidth, chatMessage, setChatMessage }: ChatProps) {
<div className="flex-none border-b px-4 py-2">
<OutputSelect
workflowId={activeWorkflowId}
selectedOutput={selectedOutput}
selectedOutputs={selectedOutputs}
onOutputSelect={handleOutputSelection}
disabled={!activeWorkflowId}
placeholder="Select output source"
placeholder="Select output sources"
/>
</div>
@@ -1,23 +1,24 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { ChevronDown } from 'lucide-react'
import { ChevronDown, Check } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import { getBlock } from '@/blocks'
import { Button } from '@/components/ui/button'
interface OutputSelectProps {
workflowId: string | null
selectedOutput: string | null
onOutputSelect: (outputId: string) => void
selectedOutputs: string[]
onOutputSelect: (outputIds: string[]) => void
disabled?: boolean
placeholder?: string
}
export function OutputSelect({
workflowId,
selectedOutput,
selectedOutputs = [],
onOutputSelect,
disabled = false,
placeholder = 'Select output source',
placeholder = 'Select output sources',
}: OutputSelectProps) {
const [isOutputDropdownOpen, setIsOutputDropdownOpen] = useState(false)
const dropdownRef = useRef<HTMLDivElement>(null)
@@ -76,19 +77,38 @@ export function OutputSelect({
return outputs
}, [blocks, workflowId])
// Get selected output display name
const selectedOutputDisplayName = useMemo(() => {
if (!selectedOutput) return placeholder
const output = workflowOutputs.find((o) => o.id === selectedOutput)
return output
? `${output.blockName.replace(/\s+/g, '').toLowerCase()}.${output.path}`
: placeholder
}, [selectedOutput, workflowOutputs, placeholder])
// Get selected outputs display text
const selectedOutputsDisplayText = useMemo(() => {
if (!selectedOutputs || selectedOutputs.length === 0) {
return placeholder
}
// Ensure all selected outputs exist in the workflowOutputs array
const validOutputs = selectedOutputs.filter(id => workflowOutputs.some(o => o.id === id))
if (validOutputs.length === 0) {
return placeholder
}
if (validOutputs.length === 1) {
const output = workflowOutputs.find((o) => o.id === validOutputs[0])
if (output) {
return `${output.blockName.replace(/\s+/g, '').toLowerCase()}.${output.path}`
}
return placeholder
}
return `${validOutputs.length} outputs selected`
}, [selectedOutputs, workflowOutputs, placeholder])
// Get selected output block info
// Get first selected output info for display icon
const selectedOutputInfo = useMemo(() => {
if (!selectedOutput) return null
const output = workflowOutputs.find((o) => o.id === selectedOutput)
if (!selectedOutputs || selectedOutputs.length === 0) return null
const validOutputs = selectedOutputs.filter(id => workflowOutputs.some(o => o.id === id))
if (validOutputs.length === 0) return null
const output = workflowOutputs.find((o) => o.id === validOutputs[0])
if (!output) return null
return {
@@ -97,7 +117,7 @@ export function OutputSelect({
blockType: output.blockType,
path: output.path,
}
}, [selectedOutput, workflowOutputs])
}, [selectedOutputs, workflowOutputs])
// Group output options by block
const groupedOutputs = useMemo(() => {
@@ -192,10 +212,18 @@ export function OutputSelect({
}
}, [])
// Handle output selection
// Handle output selection - toggle selection
const handleOutputSelection = (value: string) => {
onOutputSelect(value)
setIsOutputDropdownOpen(false)
let newSelectedOutputs: string[]
const index = selectedOutputs.indexOf(value)
if (index === -1) {
newSelectedOutputs = [...new Set([...selectedOutputs, value])]
} else {
newSelectedOutputs = selectedOutputs.filter((id) => id !== value)
}
onOutputSelect(newSelectedOutputs)
}
return (
@@ -225,10 +253,10 @@ export function OutputSelect({
{selectedOutputInfo.blockName.charAt(0).toUpperCase()}
</span>
</div>
<span className="truncate">{selectedOutputDisplayName}</span>
<span className="truncate">{selectedOutputsDisplayText}</span>
</div>
) : (
<span className="truncate w-[calc(100%-24px)]">{selectedOutputDisplayName}</span>
<span className="truncate w-[calc(100%-24px)]">{selectedOutputsDisplayText}</span>
)}
<ChevronDown
className={`h-4 w-4 transition-transform ml-1 flex-shrink-0 ${
@@ -254,10 +282,18 @@ export function OutputSelect({
className={cn(
'flex items-center gap-2 text-sm text-left w-full px-3 py-1.5',
'hover:bg-accent hover:text-accent-foreground',
'focus:bg-accent focus:text-accent-foreground focus:outline-none',
selectedOutput === output.id && 'bg-accent text-accent-foreground'
'focus:bg-accent focus:text-accent-foreground focus:outline-none'
)}
>
<div className="flex-shrink-0 w-5 h-5 flex items-center justify-center">
{selectedOutputs.includes(output.id) ? (
<div className="w-4 h-4 rounded bg-primary flex items-center justify-center">
<Check className="h-3 w-3 text-white" />
</div>
) : (
<div className="w-4 h-4 rounded border border-input" />
)}
</div>
<div
className="flex items-center justify-center w-5 h-5 rounded flex-shrink-0"
style={{
@@ -268,13 +304,25 @@ export function OutputSelect({
{blockName.charAt(0).toUpperCase()}
</span>
</div>
<span className="truncate max-w-[calc(100%-28px)]">{output.path}</span>
<span className="truncate max-w-[calc(100%-48px)]">{output.path}</span>
</button>
))}
</div>
</div>
))}
</div>
{/* Done button to close dropdown */}
<div className="border-t p-2">
<Button
variant="secondary"
size="sm"
onClick={() => setIsOutputDropdownOpen(false)}
className="w-full bg-secondary/80 text-secondary-foreground hover:bg-secondary/90"
>
Done
</Button>
</div>
</div>
)}
</div>
@@ -0,0 +1,3 @@
ALTER TABLE "chat" ADD COLUMN "output_configs" json DEFAULT '[]';--> statement-breakpoint
ALTER TABLE "chat" DROP COLUMN "output_block_id";--> statement-breakpoint
ALTER TABLE "chat" DROP COLUMN "output_path";
File diff suppressed because it is too large Load Diff
+7
View File
@@ -225,6 +225,13 @@
"when": 1745638714702,
"tag": "0031_lively_nico_minoru",
"breakpoints": true
},
{
"idx": 32,
"version": "7",
"when": 1745973220228,
"tag": "0032_rare_nico_minoru",
"breakpoints": true
}
]
}
+1 -2
View File
@@ -260,8 +260,7 @@ export const chat = pgTable('chat', {
allowedEmails: json('allowed_emails').default('[]'), // Array of allowed emails or domains when authType is 'email'
// Output configuration
outputBlockId: text('output_block_id'), // Stores the selected output block ID
outputPath: text('output_path'), // Stores the output path within the block
outputConfigs: json('output_configs').default('[]'), // Array of {blockId, path} objects
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
+17 -8
View File
@@ -39,17 +39,26 @@ export const useChatStore = create<ChatStore>()(
return get().messages.filter((message) => message.workflowId === workflowId)
},
setSelectedWorkflowOutput: (workflowId, outputId) => {
set((state) => ({
selectedWorkflowOutputs: {
...state.selectedWorkflowOutputs,
[workflowId]: outputId,
},
}))
setSelectedWorkflowOutput: (workflowId, outputIds) => {
set((state) => {
// Create a new copy of the selections state
const newSelections = { ...state.selectedWorkflowOutputs }
// If empty array, explicitly remove the key to prevent empty arrays from persisting
if (outputIds.length === 0) {
// Delete the key entirely instead of setting to empty array
delete newSelections[workflowId]
} else {
// Ensure no duplicates in the selection by using Set
newSelections[workflowId] = [...new Set(outputIds)]
}
return { selectedWorkflowOutputs: newSelections }
})
},
getSelectedWorkflowOutput: (workflowId) => {
return get().selectedWorkflowOutputs[workflowId] || null
return get().selectedWorkflowOutputs[workflowId] || []
},
}),
{
+8 -3
View File
@@ -7,12 +7,17 @@ export interface ChatMessage {
blockId?: string
}
export interface OutputConfig {
blockId: string
path: string
}
export interface ChatStore {
messages: ChatMessage[]
selectedWorkflowOutputs: Record<string, string>
selectedWorkflowOutputs: Record<string, string[]>
addMessage: (message: Omit<ChatMessage, 'id' | 'timestamp'>) => void
clearChat: (workflowId: string | null) => void
getWorkflowMessages: (workflowId: string) => ChatMessage[]
setSelectedWorkflowOutput: (workflowId: string, outputId: string) => void
getSelectedWorkflowOutput: (workflowId: string) => string | null
setSelectedWorkflowOutput: (workflowId: string, outputIds: string[]) => void
getSelectedWorkflowOutput: (workflowId: string) => string[]
}
+44 -47
View File
@@ -47,19 +47,19 @@ const redactApiKeys = (obj: any): any => {
* @returns The value at the path, or undefined if not found
*/
const getValueByPath = (obj: any, path: string): any => {
if (!obj || !path) return undefined;
if (!obj || !path) return undefined
const pathParts = path.split('.');
let current = obj;
const pathParts = path.split('.')
let current = obj
for (const part of pathParts) {
if (current === null || current === undefined || typeof current !== 'object') {
return undefined;
return undefined
}
current = current[part];
current = current[part]
}
return current;
return current
}
export const useConsoleStore = create<ConsoleStore>()(
@@ -91,50 +91,47 @@ export const useConsoleStore = create<ConsoleStore>()(
// Check if this block matches a selected workflow output
if (entry.workflowId && entry.blockName) {
const chatStore = useChatStore.getState()
const selectedOutputId = chatStore.getSelectedWorkflowOutput(entry.workflowId)
const selectedOutputIds = chatStore.getSelectedWorkflowOutput(entry.workflowId)
if (selectedOutputId) {
// The selectedOutputId format is "{blockId}_{path}"
// We need to extract both components
const idParts = selectedOutputId.split('_');
const selectedBlockId = idParts[0];
// Reconstruct the path by removing the blockId part
const selectedPath = idParts.slice(1).join('.');
console.log(`[Chat Output] Selected Output ID: ${selectedOutputId}`);
console.log(`[Chat Output] Block ID: ${selectedBlockId}, Path: ${selectedPath}`);
console.log(`[Chat Output] Current Block ID: ${entry.blockId}`);
// If this block matches the selected output for this workflow
if (selectedBlockId && entry.blockId === selectedBlockId) {
// Extract the specific value from the output using the path
let specificValue: any = undefined;
if (selectedOutputIds && selectedOutputIds.length > 0) {
// Process each selected output that matches this block
for (const selectedOutputId of selectedOutputIds) {
// The selectedOutputId format is "{blockId}_{path}"
// We need to extract both components
const idParts = selectedOutputId.split('_')
const selectedBlockId = idParts[0]
// Reconstruct the path by removing the blockId part
const selectedPath = idParts.slice(1).join('.')
if (selectedPath) {
specificValue = getValueByPath(entry.output, selectedPath);
console.log(`[Chat Output] Found value:`, specificValue);
} else {
console.log(`[Chat Output] No path specified, using entire output`);
specificValue = entry.output;
// If this block matches the selected output for this workflow
if (selectedBlockId && entry.blockId === selectedBlockId) {
// Extract the specific value from the output using the path
let specificValue: any = undefined
if (selectedPath) {
specificValue = getValueByPath(entry.output, selectedPath)
} else {
specificValue = entry.output
}
// Format the value appropriately for display
let formattedValue: string
if (specificValue === undefined) {
formattedValue = "Output value not found"
} else if (typeof specificValue === 'object') {
formattedValue = JSON.stringify(specificValue, null, 2)
} else {
formattedValue = String(specificValue)
}
// Add the specific value to chat, not the whole output
chatStore.addMessage({
content: formattedValue,
workflowId: entry.workflowId,
type: 'workflow',
blockId: entry.blockId,
})
}
// Format the value appropriately for display
let formattedValue: string;
if (specificValue === undefined) {
formattedValue = "Output value not found";
} else if (typeof specificValue === 'object') {
formattedValue = JSON.stringify(specificValue, null, 2);
} else {
formattedValue = String(specificValue);
}
// Add the specific value to chat, not the whole output
chatStore.addMessage({
content: formattedValue,
workflowId: entry.workflowId,
type: 'workflow',
blockId: entry.blockId,
})
}
}
}