mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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:
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
})
|
||||
})
|
||||
+1
-1
@@ -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
|
||||
@@ -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
@@ -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>
|
||||
|
||||
|
||||
+64
-45
@@ -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>
|
||||
|
||||
|
||||
+73
-25
@@ -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
@@ -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
@@ -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(),
|
||||
|
||||
@@ -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] || []
|
||||
},
|
||||
}),
|
||||
{
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user