fix(copilot-cleanup): support azure blob upload in copilot, remove dead code & consolidate other copilot files (#1147)

* cleanup

* support azure blob image upload

* imports cleanup

* PR comments

* ack PR comments

* fix key validation
This commit is contained in:
Waleed
2025-08-26 20:06:43 -07:00
committed by GitHub
parent b177b291cf
commit 8226e7b40a
25 changed files with 495 additions and 677 deletions

View File

@@ -45,6 +45,7 @@ export async function GET(request: Request) {
'support',
'admin',
'qa',
'agent',
]
if (reservedSubdomains.includes(subdomain)) {
return NextResponse.json(

View File

@@ -19,7 +19,10 @@ export async function POST(req: NextRequest) {
const res = await fetch(`${SIM_AGENT_API_URL}/api/validate-key/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: {
'Content-Type': 'application/json',
...(env.COPILOT_API_KEY ? { 'x-api-key': env.COPILOT_API_KEY } : {}),
},
body: JSON.stringify({ userId }),
})

View File

@@ -225,7 +225,6 @@ describe('Copilot Chat API Route', () => {
streamToolCalls: true,
mode: 'agent',
depth: 0,
origin: 'http://localhost:3000',
}),
})
)
@@ -288,7 +287,6 @@ describe('Copilot Chat API Route', () => {
streamToolCalls: true,
mode: 'agent',
depth: 0,
origin: 'http://localhost:3000',
}),
})
)
@@ -298,7 +296,6 @@ describe('Copilot Chat API Route', () => {
const authMocks = mockAuth()
authMocks.setAuthenticated()
// Mock new chat creation
const newChat = {
id: 'chat-123',
userId: 'user-123',
@@ -307,8 +304,6 @@ describe('Copilot Chat API Route', () => {
}
mockReturning.mockResolvedValue([newChat])
// Mock sim agent response
;(global.fetch as any).mockResolvedValue({
ok: true,
body: new ReadableStream({
@@ -343,7 +338,6 @@ describe('Copilot Chat API Route', () => {
streamToolCalls: true,
mode: 'agent',
depth: 0,
origin: 'http://localhost:3000',
}),
})
)
@@ -353,11 +347,8 @@ describe('Copilot Chat API Route', () => {
const authMocks = mockAuth()
authMocks.setAuthenticated()
// Mock new chat creation
mockReturning.mockResolvedValue([{ id: 'chat-123', messages: [] }])
// Mock sim agent error
;(global.fetch as any).mockResolvedValue({
ok: false,
status: 500,
@@ -403,11 +394,8 @@ describe('Copilot Chat API Route', () => {
const authMocks = mockAuth()
authMocks.setAuthenticated()
// Mock new chat creation
mockReturning.mockResolvedValue([{ id: 'chat-123', messages: [] }])
// Mock sim agent response
;(global.fetch as any).mockResolvedValue({
ok: true,
body: new ReadableStream({
@@ -438,7 +426,6 @@ describe('Copilot Chat API Route', () => {
streamToolCalls: true,
mode: 'ask',
depth: 0,
origin: 'http://localhost:3000',
}),
})
)

View File

@@ -1,4 +1,3 @@
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'crypto'
import { and, desc, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
@@ -11,71 +10,29 @@ import {
createUnauthorizedResponse,
} from '@/lib/copilot/auth'
import { getCopilotModel } from '@/lib/copilot/config'
import { TITLE_GENERATION_SYSTEM_PROMPT, TITLE_GENERATION_USER_PROMPT } from '@/lib/copilot/prompts'
import type { CopilotProviderConfig } from '@/lib/copilot/types'
import { env } from '@/lib/env'
import { generateChatTitle } from '@/lib/generate-chat-title'
import { createLogger } from '@/lib/logs/console/logger'
import { SIM_AGENT_API_URL_DEFAULT } from '@/lib/sim-agent'
import { downloadFile } from '@/lib/uploads'
import { downloadFromS3WithConfig } from '@/lib/uploads/s3/s3-client'
import { S3_COPILOT_CONFIG, USE_S3_STORAGE } from '@/lib/uploads/setup'
import { createFileContent, isSupportedFileType } from '@/lib/uploads/file-utils'
import { S3_COPILOT_CONFIG } from '@/lib/uploads/setup'
import { downloadFile, getStorageProvider } from '@/lib/uploads/storage-client'
import { db } from '@/db'
import { copilotChats } from '@/db/schema'
import { executeProviderRequest } from '@/providers'
import { createAnthropicFileContent, isSupportedFileType } from './file-utils'
const logger = createLogger('CopilotChatAPI')
// Sim Agent API configuration
const SIM_AGENT_API_URL = env.SIM_AGENT_API_URL || SIM_AGENT_API_URL_DEFAULT
function getRequestOrigin(_req: NextRequest): string {
try {
// Strictly use configured Better Auth URL
return env.BETTER_AUTH_URL || ''
} catch (_) {
return ''
}
}
function deriveKey(keyString: string): Buffer {
return createHash('sha256').update(keyString, 'utf8').digest()
}
function decryptWithKey(encryptedValue: string, keyString: string): string {
const [ivHex, encryptedHex, authTagHex] = encryptedValue.split(':')
if (!ivHex || !encryptedHex || !authTagHex) {
throw new Error('Invalid encrypted format')
}
const key = deriveKey(keyString)
const iv = Buffer.from(ivHex, 'hex')
const decipher = createDecipheriv('aes-256-gcm', key, iv)
decipher.setAuthTag(Buffer.from(authTagHex, 'hex'))
let decrypted = decipher.update(encryptedHex, 'hex', 'utf8')
decrypted += decipher.final('utf8')
return decrypted
}
function encryptWithKey(plaintext: string, keyString: string): string {
const key = deriveKey(keyString)
const iv = randomBytes(16)
const cipher = createCipheriv('aes-256-gcm', key, iv)
let encrypted = cipher.update(plaintext, 'utf8', 'hex')
encrypted += cipher.final('hex')
const authTag = cipher.getAuthTag().toString('hex')
return `${iv.toString('hex')}:${encrypted}:${authTag}`
}
// Schema for file attachments
const FileAttachmentSchema = z.object({
id: z.string(),
s3_key: z.string(),
key: z.string(),
filename: z.string(),
media_type: z.string(),
size: z.number(),
})
// Schema for chat messages
const ChatMessageSchema = z.object({
message: z.string().min(1, 'Message is required'),
userMessageId: z.string().optional(), // ID from frontend for the user message
@@ -92,89 +49,6 @@ const ChatMessageSchema = z.object({
conversationId: z.string().optional(),
})
/**
* Generate a chat title using LLM
*/
async function generateChatTitle(userMessage: string): Promise<string> {
try {
const { provider, model } = getCopilotModel('title')
// Get the appropriate API key for the provider
let apiKey: string | undefined
if (provider === 'anthropic') {
// Use rotating API key for Anthropic
const { getRotatingApiKey } = require('@/lib/utils')
try {
apiKey = getRotatingApiKey('anthropic')
logger.debug(`Using rotating API key for Anthropic title generation`)
} catch (e) {
// If rotation fails, let the provider handle it
logger.warn(`Failed to get rotating API key for Anthropic:`, e)
}
}
const response = await executeProviderRequest(provider, {
model,
systemPrompt: TITLE_GENERATION_SYSTEM_PROMPT,
context: TITLE_GENERATION_USER_PROMPT(userMessage),
temperature: 0.3,
maxTokens: 50,
apiKey: apiKey || '',
stream: false,
})
if (typeof response === 'object' && 'content' in response) {
return response.content?.trim() || 'New Chat'
}
return 'New Chat'
} catch (error) {
logger.error('Failed to generate chat title:', error)
return 'New Chat'
}
}
/**
* Generate chat title asynchronously and update the database
*/
async function generateChatTitleAsync(
chatId: string,
userMessage: string,
requestId: string,
streamController?: ReadableStreamDefaultController<Uint8Array>
): Promise<void> {
try {
// logger.info(`[${requestId}] Starting async title generation for chat ${chatId}`)
const title = await generateChatTitle(userMessage)
// Update the chat with the generated title
await db
.update(copilotChats)
.set({
title,
updatedAt: new Date(),
})
.where(eq(copilotChats.id, chatId))
// Send title_updated event to client if streaming
if (streamController) {
const encoder = new TextEncoder()
const titleEvent = `data: ${JSON.stringify({
type: 'title_updated',
title: title,
})}\n\n`
streamController.enqueue(encoder.encode(titleEvent))
logger.debug(`[${requestId}] Sent title_updated event to client: "${title}"`)
}
// logger.info(`[${requestId}] Generated title for chat ${chatId}: "${title}"`)
} catch (error) {
logger.error(`[${requestId}] Failed to generate title for chat ${chatId}:`, error)
// Don't throw - this is a background operation
}
}
/**
* POST /api/copilot/chat
* Send messages to sim agent and handle chat persistence
@@ -209,14 +83,6 @@ export async function POST(req: NextRequest) {
conversationId,
} = ChatMessageSchema.parse(body)
// Derive request origin for downstream service
const requestOrigin = getRequestOrigin(req)
if (!requestOrigin) {
logger.error(`[${tracker.requestId}] Missing required configuration: BETTER_AUTH_URL`)
return createInternalServerErrorResponse('Missing required configuration: BETTER_AUTH_URL')
}
// Consolidation mapping: map negative depths to base depth with prefetch=true
let effectiveDepth: number | undefined = typeof depth === 'number' ? depth : undefined
let effectivePrefetch: boolean | undefined = prefetch
@@ -230,22 +96,6 @@ export async function POST(req: NextRequest) {
}
}
// logger.info(`[${tracker.requestId}] Processing copilot chat request`, {
// userId: authenticatedUserId,
// workflowId,
// chatId,
// mode,
// stream,
// createNewChat,
// messageLength: message.length,
// hasImplicitFeedback: !!implicitFeedback,
// provider: provider || 'openai',
// hasConversationId: !!conversationId,
// depth,
// prefetch,
// origin: requestOrigin,
// })
// Handle chat context
let currentChat: any = null
let conversationHistory: any[] = []
@@ -286,8 +136,6 @@ export async function POST(req: NextRequest) {
// Process file attachments if present
const processedFileContents: any[] = []
if (fileAttachments && fileAttachments.length > 0) {
// logger.info(`[${tracker.requestId}] Processing ${fileAttachments.length} file attachments`)
for (const attachment of fileAttachments) {
try {
// Check if file type is supported
@@ -296,23 +144,30 @@ export async function POST(req: NextRequest) {
continue
}
// Download file from S3
// logger.info(`[${tracker.requestId}] Downloading file: ${attachment.s3_key}`)
const storageProvider = getStorageProvider()
let fileBuffer: Buffer
if (USE_S3_STORAGE) {
fileBuffer = await downloadFromS3WithConfig(attachment.s3_key, S3_COPILOT_CONFIG)
if (storageProvider === 's3') {
fileBuffer = await downloadFile(attachment.key, {
bucket: S3_COPILOT_CONFIG.bucket,
region: S3_COPILOT_CONFIG.region,
})
} else if (storageProvider === 'blob') {
const { BLOB_COPILOT_CONFIG } = await import('@/lib/uploads/setup')
fileBuffer = await downloadFile(attachment.key, {
containerName: BLOB_COPILOT_CONFIG.containerName,
accountName: BLOB_COPILOT_CONFIG.accountName,
accountKey: BLOB_COPILOT_CONFIG.accountKey,
connectionString: BLOB_COPILOT_CONFIG.connectionString,
})
} else {
// Fallback to generic downloadFile for other storage providers
fileBuffer = await downloadFile(attachment.s3_key)
fileBuffer = await downloadFile(attachment.key)
}
// Convert to Anthropic format
const fileContent = createAnthropicFileContent(fileBuffer, attachment.media_type)
// Convert to format
const fileContent = createFileContent(fileBuffer, attachment.media_type)
if (fileContent) {
processedFileContents.push(fileContent)
// logger.info(
// `[${tracker.requestId}] Processed file: ${attachment.filename} (${attachment.media_type})`
// )
}
} catch (error) {
logger.error(
@@ -337,14 +192,26 @@ export async function POST(req: NextRequest) {
for (const attachment of msg.fileAttachments) {
try {
if (isSupportedFileType(attachment.media_type)) {
const storageProvider = getStorageProvider()
let fileBuffer: Buffer
if (USE_S3_STORAGE) {
fileBuffer = await downloadFromS3WithConfig(attachment.s3_key, S3_COPILOT_CONFIG)
if (storageProvider === 's3') {
fileBuffer = await downloadFile(attachment.key, {
bucket: S3_COPILOT_CONFIG.bucket,
region: S3_COPILOT_CONFIG.region,
})
} else if (storageProvider === 'blob') {
const { BLOB_COPILOT_CONFIG } = await import('@/lib/uploads/setup')
fileBuffer = await downloadFile(attachment.key, {
containerName: BLOB_COPILOT_CONFIG.containerName,
accountName: BLOB_COPILOT_CONFIG.accountName,
accountKey: BLOB_COPILOT_CONFIG.accountKey,
connectionString: BLOB_COPILOT_CONFIG.connectionString,
})
} else {
// Fallback to generic downloadFile for other storage providers
fileBuffer = await downloadFile(attachment.s3_key)
fileBuffer = await downloadFile(attachment.key)
}
const fileContent = createAnthropicFileContent(fileBuffer, attachment.media_type)
const fileContent = createFileContent(fileBuffer, attachment.media_type)
if (fileContent) {
content.push(fileContent)
}
@@ -412,7 +279,7 @@ export async function POST(req: NextRequest) {
provider: 'azure-openai',
model: modelToUse,
apiKey: env.AZURE_OPENAI_API_KEY,
apiVersion: env.AZURE_OPENAI_API_VERSION,
apiVersion: 'preview',
endpoint: env.AZURE_OPENAI_ENDPOINT,
}
} else {
@@ -445,11 +312,8 @@ export async function POST(req: NextRequest) {
...(typeof effectiveDepth === 'number' ? { depth: effectiveDepth } : {}),
...(typeof effectivePrefetch === 'boolean' ? { prefetch: effectivePrefetch } : {}),
...(session?.user?.name && { userName: session.user.name }),
...(requestOrigin ? { origin: requestOrigin } : {}),
}
// Log the payload being sent to the streaming endpoint (logs currently disabled)
const simAgentResponse = await fetch(`${SIM_AGENT_API_URL}/api/chat-completion-streaming`, {
method: 'POST',
headers: {
@@ -479,8 +343,6 @@ export async function POST(req: NextRequest) {
// If streaming is requested, forward the stream and update chat later
if (stream && simAgentResponse.body) {
// logger.info(`[${tracker.requestId}] Streaming response from sim agent`)
// Create user message to save
const userMessage = {
id: userMessageId || crypto.randomUUID(), // Use frontend ID if provided
@@ -519,30 +381,30 @@ export async function POST(req: NextRequest) {
// Start title generation in parallel if needed
if (actualChatId && !currentChat?.title && conversationHistory.length === 0) {
// logger.info(`[${tracker.requestId}] Starting title generation with stream updates`, {
// chatId: actualChatId,
// hasTitle: !!currentChat?.title,
// conversationLength: conversationHistory.length,
// message: message.substring(0, 100) + (message.length > 100 ? '...' : ''),
// })
generateChatTitleAsync(actualChatId, message, tracker.requestId, controller).catch(
(error) => {
generateChatTitle(message)
.then(async (title) => {
if (title) {
await db
.update(copilotChats)
.set({
title,
updatedAt: new Date(),
})
.where(eq(copilotChats.id, actualChatId!))
const titleEvent = `data: ${JSON.stringify({
type: 'title_updated',
title: title,
})}\n\n`
controller.enqueue(encoder.encode(titleEvent))
logger.info(`[${tracker.requestId}] Generated and saved title: ${title}`)
}
})
.catch((error) => {
logger.error(`[${tracker.requestId}] Title generation failed:`, error)
}
)
})
} else {
// logger.debug(`[${tracker.requestId}] Skipping title generation`, {
// chatId: actualChatId,
// hasTitle: !!currentChat?.title,
// conversationLength: conversationHistory.length,
// reason: !actualChatId
// ? 'no chatId'
// : currentChat?.title
// ? 'already has title'
// : conversationHistory.length > 0
// ? 'not first message'
// : 'unknown',
// })
logger.debug(`[${tracker.requestId}] Skipping title generation`)
}
// Forward the sim agent stream and capture assistant response
@@ -553,7 +415,6 @@ export async function POST(req: NextRequest) {
while (true) {
const { done, value } = await reader.read()
if (done) {
// logger.info(`[${tracker.requestId}] Stream reading completed`)
break
}
@@ -563,13 +424,9 @@ export async function POST(req: NextRequest) {
controller.enqueue(value)
} catch (error) {
// Client disconnected - stop reading from sim agent
// logger.info(
// `[${tracker.requestId}] Client disconnected, stopping stream processing`
// )
reader.cancel() // Stop reading from sim agent
break
}
const chunkSize = value.byteLength
// Decode and parse SSE events for logging and capturing content
const decodedChunk = decoder.decode(value, { stream: true })
@@ -605,22 +462,12 @@ export async function POST(req: NextRequest) {
break
case 'reasoning':
// Treat like thinking: do not add to assistantContent to avoid leaking
logger.debug(
`[${tracker.requestId}] Reasoning chunk received (${(event.data || event.content || '').length} chars)`
)
break
case 'tool_call':
// logger.info(
// `[${tracker.requestId}] Tool call ${event.data?.partial ? '(partial)' : '(complete)'}:`,
// {
// id: event.data?.id,
// name: event.data?.name,
// arguments: event.data?.arguments,
// blockIndex: event.data?._blockIndex,
// }
// )
if (!event.data?.partial) {
toolCalls.push(event.data)
if (event.data?.id) {
@@ -630,23 +477,12 @@ export async function POST(req: NextRequest) {
break
case 'tool_generating':
// logger.info(`[${tracker.requestId}] Tool generating:`, {
// toolCallId: event.toolCallId,
// toolName: event.toolName,
// })
if (event.toolCallId) {
startedToolExecutionIds.add(event.toolCallId)
}
break
case 'tool_result':
// logger.info(`[${tracker.requestId}] Tool result received:`, {
// toolCallId: event.toolCallId,
// toolName: event.toolName,
// success: event.success,
// result: `${JSON.stringify(event.result).substring(0, 200)}...`,
// resultSize: JSON.stringify(event.result).length,
// })
if (event.toolCallId) {
completedToolExecutionIds.add(event.toolCallId)
}
@@ -861,9 +697,22 @@ export async function POST(req: NextRequest) {
// Start title generation in parallel if this is first message (non-streaming)
if (actualChatId && !currentChat.title && conversationHistory.length === 0) {
logger.info(`[${tracker.requestId}] Starting title generation for non-streaming response`)
generateChatTitleAsync(actualChatId, message, tracker.requestId).catch((error) => {
logger.error(`[${tracker.requestId}] Title generation failed:`, error)
})
generateChatTitle(message)
.then(async (title) => {
if (title) {
await db
.update(copilotChats)
.set({
title,
updatedAt: new Date(),
})
.where(eq(copilotChats.id, actualChatId!))
logger.info(`[${tracker.requestId}] Generated and saved title: ${title}`)
}
})
.catch((error) => {
logger.error(`[${tracker.requestId}] Title generation failed:`, error)
})
}
// Update chat in database immediately (without blocking for title)

View File

@@ -229,7 +229,6 @@ describe('Copilot Chat Update Messages API Route', () => {
const authMocks = mockAuth()
authMocks.setAuthenticated()
// Mock chat exists - override the default empty array
const existingChat = {
id: 'chat-123',
userId: 'user-123',
@@ -267,7 +266,6 @@ describe('Copilot Chat Update Messages API Route', () => {
messageCount: 2,
})
// Verify database operations
expect(mockSelect).toHaveBeenCalled()
expect(mockUpdate).toHaveBeenCalled()
expect(mockSet).toHaveBeenCalledWith({
@@ -280,7 +278,6 @@ describe('Copilot Chat Update Messages API Route', () => {
const authMocks = mockAuth()
authMocks.setAuthenticated()
// Mock chat exists
const existingChat = {
id: 'chat-456',
userId: 'user-123',
@@ -341,7 +338,6 @@ describe('Copilot Chat Update Messages API Route', () => {
const authMocks = mockAuth()
authMocks.setAuthenticated()
// Mock chat exists
const existingChat = {
id: 'chat-789',
userId: 'user-123',
@@ -374,7 +370,6 @@ describe('Copilot Chat Update Messages API Route', () => {
const authMocks = mockAuth()
authMocks.setAuthenticated()
// Mock database error during chat lookup
mockLimit.mockRejectedValueOnce(new Error('Database connection failed'))
const req = createMockRequest('POST', {
@@ -401,7 +396,6 @@ describe('Copilot Chat Update Messages API Route', () => {
const authMocks = mockAuth()
authMocks.setAuthenticated()
// Mock chat exists
const existingChat = {
id: 'chat-123',
userId: 'user-123',
@@ -409,7 +403,6 @@ describe('Copilot Chat Update Messages API Route', () => {
}
mockLimit.mockResolvedValueOnce([existingChat])
// Mock database error during update
mockSet.mockReturnValueOnce({
where: vi.fn().mockRejectedValue(new Error('Update operation failed')),
})
@@ -438,7 +431,6 @@ describe('Copilot Chat Update Messages API Route', () => {
const authMocks = mockAuth()
authMocks.setAuthenticated()
// Create a request with invalid JSON
const req = new NextRequest('http://localhost:3000/api/copilot/chat/update-messages', {
method: 'POST',
body: '{invalid-json',
@@ -459,7 +451,6 @@ describe('Copilot Chat Update Messages API Route', () => {
const authMocks = mockAuth()
authMocks.setAuthenticated()
// Mock chat exists
const existingChat = {
id: 'chat-large',
userId: 'user-123',
@@ -467,7 +458,6 @@ describe('Copilot Chat Update Messages API Route', () => {
}
mockLimit.mockResolvedValueOnce([existingChat])
// Create a large array of messages
const messages = Array.from({ length: 100 }, (_, i) => ({
id: `msg-${i + 1}`,
role: i % 2 === 0 ? 'user' : 'assistant',
@@ -500,7 +490,6 @@ describe('Copilot Chat Update Messages API Route', () => {
const authMocks = mockAuth()
authMocks.setAuthenticated()
// Mock chat exists
const existingChat = {
id: 'chat-mixed',
userId: 'user-123',

View File

@@ -28,7 +28,7 @@ const UpdateMessagesSchema = z.object({
.array(
z.object({
id: z.string(),
s3_key: z.string(),
key: z.string(),
filename: z.string(),
media_type: z.string(),
size: z.number(),

View File

@@ -2,7 +2,7 @@ import { readFile } from 'fs/promises'
import type { NextRequest, NextResponse } from 'next/server'
import { createLogger } from '@/lib/logs/console/logger'
import { downloadFile, getStorageProvider, isUsingCloudStorage } from '@/lib/uploads'
import { BLOB_KB_CONFIG, S3_KB_CONFIG } from '@/lib/uploads/setup'
import { S3_KB_CONFIG } from '@/lib/uploads/setup'
import '@/lib/uploads/setup.server'
import {
@@ -15,19 +15,6 @@ import {
const logger = createLogger('FilesServeAPI')
async function streamToBuffer(readableStream: NodeJS.ReadableStream): Promise<Buffer> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = []
readableStream.on('data', (data) => {
chunks.push(data instanceof Buffer ? data : Buffer.from(data))
})
readableStream.on('end', () => {
resolve(Buffer.concat(chunks))
})
readableStream.on('error', reject)
})
}
/**
* Main API route handler for serving files
*/
@@ -102,49 +89,23 @@ async function handleLocalFile(filename: string): Promise<NextResponse> {
}
async function downloadKBFile(cloudKey: string): Promise<Buffer> {
logger.info(`Downloading KB file: ${cloudKey}`)
const storageProvider = getStorageProvider()
if (storageProvider === 'blob') {
logger.info(`Downloading KB file from Azure Blob Storage: ${cloudKey}`)
// Use KB-specific blob configuration
const { getBlobServiceClient } = await import('@/lib/uploads/blob/blob-client')
const blobServiceClient = getBlobServiceClient()
const containerClient = blobServiceClient.getContainerClient(BLOB_KB_CONFIG.containerName)
const blockBlobClient = containerClient.getBlockBlobClient(cloudKey)
const downloadBlockBlobResponse = await blockBlobClient.download()
if (!downloadBlockBlobResponse.readableStreamBody) {
throw new Error('Failed to get readable stream from blob download')
}
// Convert stream to buffer
return await streamToBuffer(downloadBlockBlobResponse.readableStreamBody)
const { BLOB_KB_CONFIG } = await import('@/lib/uploads/setup')
return downloadFile(cloudKey, {
containerName: BLOB_KB_CONFIG.containerName,
accountName: BLOB_KB_CONFIG.accountName,
accountKey: BLOB_KB_CONFIG.accountKey,
connectionString: BLOB_KB_CONFIG.connectionString,
})
}
if (storageProvider === 's3') {
logger.info(`Downloading KB file from S3: ${cloudKey}`)
// Use KB-specific S3 configuration
const { getS3Client } = await import('@/lib/uploads/s3/s3-client')
const { GetObjectCommand } = await import('@aws-sdk/client-s3')
const s3Client = getS3Client()
const command = new GetObjectCommand({
Bucket: S3_KB_CONFIG.bucket,
Key: cloudKey,
})
const response = await s3Client.send(command)
if (!response.Body) {
throw new Error('No body in S3 response')
}
// Convert stream to buffer using the same method as the regular S3 client
const stream = response.Body as any
return new Promise<Buffer>((resolve, reject) => {
const chunks: Buffer[] = []
stream.on('data', (chunk: Buffer) => chunks.push(chunk))
stream.on('end', () => resolve(Buffer.concat(chunks)))
stream.on('error', reject)
return downloadFile(cloudKey, {
bucket: S3_KB_CONFIG.bucket,
region: S3_KB_CONFIG.region,
})
}
@@ -167,17 +128,22 @@ async function handleCloudProxy(
if (isKBFile) {
fileBuffer = await downloadKBFile(cloudKey)
} else if (bucketType === 'copilot') {
// Download from copilot-specific bucket
const storageProvider = getStorageProvider()
if (storageProvider === 's3') {
const { downloadFromS3WithConfig } = await import('@/lib/uploads/s3/s3-client')
const { S3_COPILOT_CONFIG } = await import('@/lib/uploads/setup')
fileBuffer = await downloadFromS3WithConfig(cloudKey, S3_COPILOT_CONFIG)
fileBuffer = await downloadFile(cloudKey, {
bucket: S3_COPILOT_CONFIG.bucket,
region: S3_COPILOT_CONFIG.region,
})
} else if (storageProvider === 'blob') {
// For Azure Blob, use the default downloadFile for now
// TODO: Add downloadFromBlobWithConfig when needed
fileBuffer = await downloadFile(cloudKey)
const { BLOB_COPILOT_CONFIG } = await import('@/lib/uploads/setup')
fileBuffer = await downloadFile(cloudKey, {
containerName: BLOB_COPILOT_CONFIG.containerName,
accountName: BLOB_COPILOT_CONFIG.accountName,
accountKey: BLOB_COPILOT_CONFIG.accountKey,
connectionString: BLOB_COPILOT_CONFIG.connectionString,
})
} else {
fileBuffer = await downloadFile(cloudKey)
}

View File

@@ -3,9 +3,7 @@
import { useEffect } from 'react'
import { formatDistanceToNow } from 'date-fns'
import { AlertCircle, History, RotateCcw } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Separator } from '@/components/ui/separator'
import { Button, ScrollArea, Separator } from '@/components/ui'
import { useCopilotStore } from '@/stores/copilot/store'
export function CheckpointPanel() {

View File

@@ -0,0 +1,98 @@
import { memo, useState } from 'react'
import { FileText, Image } from 'lucide-react'
import type { MessageFileAttachment } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/user-input'
interface FileAttachmentDisplayProps {
fileAttachments: MessageFileAttachment[]
}
export const FileAttachmentDisplay = memo(({ fileAttachments }: FileAttachmentDisplayProps) => {
const [fileUrls, setFileUrls] = useState<Record<string, string>>({})
const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${Math.round((bytes / k ** i) * 10) / 10} ${sizes[i]}`
}
const getFileIcon = (mediaType: string) => {
if (mediaType.startsWith('image/')) {
return <Image className='h-5 w-5 text-muted-foreground' />
}
if (mediaType.includes('pdf')) {
return <FileText className='h-5 w-5 text-red-500' />
}
if (mediaType.includes('text') || mediaType.includes('json') || mediaType.includes('xml')) {
return <FileText className='h-5 w-5 text-blue-500' />
}
return <FileText className='h-5 w-5 text-muted-foreground' />
}
const getFileUrl = (file: MessageFileAttachment) => {
const cacheKey = file.key
if (fileUrls[cacheKey]) {
return fileUrls[cacheKey]
}
const url = `/api/files/serve/${encodeURIComponent(file.key)}?bucket=copilot`
setFileUrls((prev) => ({ ...prev, [cacheKey]: url }))
return url
}
const handleFileClick = (file: MessageFileAttachment) => {
const serveUrl = getFileUrl(file)
window.open(serveUrl, '_blank')
}
const isImageFile = (mediaType: string) => {
return mediaType.startsWith('image/')
}
return (
<>
{fileAttachments.map((file) => (
<div
key={file.id}
className='group relative h-16 w-16 cursor-pointer overflow-hidden rounded-md border border-border/50 bg-muted/20 transition-all hover:bg-muted/40'
onClick={() => handleFileClick(file)}
title={`${file.filename} (${formatFileSize(file.size)})`}
>
{isImageFile(file.media_type) ? (
// For images, show actual thumbnail
<img
src={getFileUrl(file)}
alt={file.filename}
className='h-full w-full object-cover'
onError={(e) => {
// If image fails to load, replace with icon
const target = e.target as HTMLImageElement
target.style.display = 'none'
const parent = target.parentElement
if (parent) {
const iconContainer = document.createElement('div')
iconContainer.className =
'flex items-center justify-center w-full h-full bg-background/50'
iconContainer.innerHTML =
'<svg class="h-5 w-5 text-muted-foreground" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/></svg>'
parent.appendChild(iconContainer)
}
}}
/>
) : (
// For other files, show icon centered
<div className='flex h-full w-full items-center justify-center bg-background/50'>
{getFileIcon(file.media_type)}
</div>
)}
{/* Hover overlay effect */}
<div className='pointer-events-none absolute inset-0 bg-black/10 opacity-0 transition-opacity group-hover:opacity-100' />
</div>
))}
</>
)
})
FileAttachmentDisplay.displayName = 'FileAttachmentDisplay'

View File

@@ -0,0 +1,4 @@
export * from './file-display'
export * from './markdown-renderer'
export * from './smooth-streaming'
export * from './thinking-block'

View File

@@ -0,0 +1,159 @@
import { memo, useEffect, useRef, useState } from 'react'
import CopilotMarkdownRenderer from './markdown-renderer'
export const StreamingIndicator = memo(() => (
<div className='flex items-center py-1 text-muted-foreground transition-opacity duration-200 ease-in-out'>
<div className='flex space-x-0.5'>
<div
className='h-1 w-1 animate-bounce rounded-full bg-muted-foreground'
style={{ animationDelay: '0ms', animationDuration: '1.2s' }}
/>
<div
className='h-1 w-1 animate-bounce rounded-full bg-muted-foreground'
style={{ animationDelay: '0.15s', animationDuration: '1.2s' }}
/>
<div
className='h-1 w-1 animate-bounce rounded-full bg-muted-foreground'
style={{ animationDelay: '0.3s', animationDuration: '1.2s' }}
/>
</div>
</div>
))
StreamingIndicator.displayName = 'StreamingIndicator'
interface SmoothStreamingTextProps {
content: string
isStreaming: boolean
}
export const SmoothStreamingText = memo(
({ content, isStreaming }: SmoothStreamingTextProps) => {
const [displayedContent, setDisplayedContent] = useState('')
const contentRef = useRef(content)
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
const indexRef = useRef(0)
const streamingStartTimeRef = useRef<number | null>(null)
const isAnimatingRef = useRef(false)
useEffect(() => {
// Update content reference
contentRef.current = content
if (content.length === 0) {
setDisplayedContent('')
indexRef.current = 0
streamingStartTimeRef.current = null
return
}
if (isStreaming) {
// Start timing when streaming begins
if (streamingStartTimeRef.current === null) {
streamingStartTimeRef.current = Date.now()
}
// Continue animation if there's more content to show
if (indexRef.current < content.length) {
const animateText = () => {
const currentContent = contentRef.current
const currentIndex = indexRef.current
if (currentIndex < currentContent.length) {
// Add characters one by one for true character-by-character streaming
const chunkSize = 1
const newDisplayed = currentContent.slice(0, currentIndex + chunkSize)
setDisplayedContent(newDisplayed)
indexRef.current = currentIndex + chunkSize
// Consistent fast speed for all characters
const delay = 3 // Consistent fast delay in ms for all characters
timeoutRef.current = setTimeout(animateText, delay)
} else {
// Animation complete
isAnimatingRef.current = false
}
}
// Only start new animation if not already animating
if (!isAnimatingRef.current) {
// Clear any existing animation
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
isAnimatingRef.current = true
// Continue animation from current position
animateText()
}
}
} else {
// Not streaming, show all content immediately and reset timing
setDisplayedContent(content)
indexRef.current = content.length
isAnimatingRef.current = false
streamingStartTimeRef.current = null
}
// Cleanup on unmount
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
isAnimatingRef.current = false
}
}, [content, isStreaming])
return (
<div className='relative max-w-full overflow-hidden' style={{ minHeight: '1.25rem' }}>
<CopilotMarkdownRenderer content={displayedContent} />
</div>
)
},
(prevProps, nextProps) => {
// Prevent re-renders during streaming unless content actually changed
return (
prevProps.content === nextProps.content && prevProps.isStreaming === nextProps.isStreaming
// markdownComponents is now memoized so no need to compare
)
}
)
SmoothStreamingText.displayName = 'SmoothStreamingText'
// Maximum character length for a word before it's broken up
const MAX_WORD_LENGTH = 25
export const WordWrap = ({ text }: { text: string }) => {
if (!text) return null
// Split text into words, keeping spaces and punctuation
const parts = text.split(/(\s+)/g)
return (
<>
{parts.map((part, index) => {
// If the part is whitespace or shorter than the max length, render it as is
if (part.match(/\s+/) || part.length <= MAX_WORD_LENGTH) {
return <span key={index}>{part}</span>
}
// For long words, break them up into chunks
const chunks = []
for (let i = 0; i < part.length; i += MAX_WORD_LENGTH) {
chunks.push(part.substring(i, i + MAX_WORD_LENGTH))
}
return (
<span key={index} className='break-all'>
{chunks.map((chunk, chunkIndex) => (
<span key={chunkIndex}>{chunk}</span>
))}
</span>
)
})}
</>
)
}

View File

@@ -1,24 +1,20 @@
'use client'
import { type FC, memo, useEffect, useMemo, useRef, useState } from 'react'
import {
Check,
Clipboard,
FileText,
Image,
Loader2,
RotateCcw,
ThumbsDown,
ThumbsUp,
X,
} from 'lucide-react'
import { type FC, memo, useEffect, useMemo, useState } from 'react'
import { Check, Clipboard, Loader2, RotateCcw, ThumbsDown, ThumbsUp, X } from 'lucide-react'
import { InlineToolCall } from '@/lib/copilot/inline-tool-call'
import { createLogger } from '@/lib/logs/console/logger'
import {
FileAttachmentDisplay,
SmoothStreamingText,
StreamingIndicator,
ThinkingBlock,
WordWrap,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components'
import CopilotMarkdownRenderer from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/markdown-renderer'
import { usePreviewStore } from '@/stores/copilot/preview-store'
import { useCopilotStore } from '@/stores/copilot/store'
import type { CopilotMessage as CopilotMessageType } from '@/stores/copilot/types'
import CopilotMarkdownRenderer from './components/markdown-renderer'
import { ThinkingBlock } from './components/thinking-block'
const logger = createLogger('CopilotMessage')
@@ -27,266 +23,6 @@ interface CopilotMessageProps {
isStreaming?: boolean
}
// Memoized streaming indicator component for better performance
const StreamingIndicator = memo(() => (
<div className='flex items-center py-1 text-muted-foreground transition-opacity duration-200 ease-in-out'>
<div className='flex space-x-0.5'>
<div
className='h-1 w-1 animate-bounce rounded-full bg-muted-foreground'
style={{ animationDelay: '0ms', animationDuration: '1.2s' }}
/>
<div
className='h-1 w-1 animate-bounce rounded-full bg-muted-foreground'
style={{ animationDelay: '0.15s', animationDuration: '1.2s' }}
/>
<div
className='h-1 w-1 animate-bounce rounded-full bg-muted-foreground'
style={{ animationDelay: '0.3s', animationDuration: '1.2s' }}
/>
</div>
</div>
))
StreamingIndicator.displayName = 'StreamingIndicator'
// File attachment display component
interface FileAttachmentDisplayProps {
fileAttachments: any[]
}
const FileAttachmentDisplay = memo(({ fileAttachments }: FileAttachmentDisplayProps) => {
// Cache for file URLs to avoid re-fetching on every render
const [fileUrls, setFileUrls] = useState<Record<string, string>>({})
const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${Math.round((bytes / k ** i) * 10) / 10} ${sizes[i]}`
}
const getFileIcon = (mediaType: string) => {
if (mediaType.startsWith('image/')) {
return <Image className='h-5 w-5 text-muted-foreground' />
}
if (mediaType.includes('pdf')) {
return <FileText className='h-5 w-5 text-red-500' />
}
if (mediaType.includes('text') || mediaType.includes('json') || mediaType.includes('xml')) {
return <FileText className='h-5 w-5 text-blue-500' />
}
return <FileText className='h-5 w-5 text-muted-foreground' />
}
const getFileUrl = (file: any) => {
const cacheKey = file.s3_key
if (fileUrls[cacheKey]) {
return fileUrls[cacheKey]
}
// Generate URL only once and cache it
const url = `/api/files/serve/s3/${encodeURIComponent(file.s3_key)}?bucket=copilot`
setFileUrls((prev) => ({ ...prev, [cacheKey]: url }))
return url
}
const handleFileClick = (file: any) => {
// Use cached URL or generate it
const serveUrl = getFileUrl(file)
// Open the file in a new tab
window.open(serveUrl, '_blank')
}
const isImageFile = (mediaType: string) => {
return mediaType.startsWith('image/')
}
return (
<>
{fileAttachments.map((file) => (
<div
key={file.id}
className='group relative h-16 w-16 cursor-pointer overflow-hidden rounded-md border border-border/50 bg-muted/20 transition-all hover:bg-muted/40'
onClick={() => handleFileClick(file)}
title={`${file.filename} (${formatFileSize(file.size)})`}
>
{isImageFile(file.media_type) ? (
// For images, show actual thumbnail
<img
src={getFileUrl(file)}
alt={file.filename}
className='h-full w-full object-cover'
onError={(e) => {
// If image fails to load, replace with icon
const target = e.target as HTMLImageElement
target.style.display = 'none'
const parent = target.parentElement
if (parent) {
const iconContainer = document.createElement('div')
iconContainer.className =
'flex items-center justify-center w-full h-full bg-background/50'
iconContainer.innerHTML =
'<svg class="h-5 w-5 text-muted-foreground" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/></svg>'
parent.appendChild(iconContainer)
}
}}
/>
) : (
// For other files, show icon centered
<div className='flex h-full w-full items-center justify-center bg-background/50'>
{getFileIcon(file.media_type)}
</div>
)}
{/* Hover overlay effect */}
<div className='pointer-events-none absolute inset-0 bg-black/10 opacity-0 transition-opacity group-hover:opacity-100' />
</div>
))}
</>
)
})
FileAttachmentDisplay.displayName = 'FileAttachmentDisplay'
// Smooth streaming text component with typewriter effect
interface SmoothStreamingTextProps {
content: string
isStreaming: boolean
}
const SmoothStreamingText = memo(
({ content, isStreaming }: SmoothStreamingTextProps) => {
const [displayedContent, setDisplayedContent] = useState('')
const contentRef = useRef(content)
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
const indexRef = useRef(0)
const streamingStartTimeRef = useRef<number | null>(null)
const isAnimatingRef = useRef(false)
useEffect(() => {
// Update content reference
contentRef.current = content
if (content.length === 0) {
setDisplayedContent('')
indexRef.current = 0
streamingStartTimeRef.current = null
return
}
if (isStreaming) {
// Start timing when streaming begins
if (streamingStartTimeRef.current === null) {
streamingStartTimeRef.current = Date.now()
}
// Continue animation if there's more content to show
if (indexRef.current < content.length) {
const animateText = () => {
const currentContent = contentRef.current
const currentIndex = indexRef.current
if (currentIndex < currentContent.length) {
// Add characters one by one for true character-by-character streaming
const chunkSize = 1
const newDisplayed = currentContent.slice(0, currentIndex + chunkSize)
setDisplayedContent(newDisplayed)
indexRef.current = currentIndex + chunkSize
// Consistent fast speed for all characters
const delay = 3 // Consistent fast delay in ms for all characters
timeoutRef.current = setTimeout(animateText, delay)
} else {
// Animation complete
isAnimatingRef.current = false
}
}
// Only start new animation if not already animating
if (!isAnimatingRef.current) {
// Clear any existing animation
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
isAnimatingRef.current = true
// Continue animation from current position
animateText()
}
}
} else {
// Not streaming, show all content immediately and reset timing
setDisplayedContent(content)
indexRef.current = content.length
isAnimatingRef.current = false
streamingStartTimeRef.current = null
}
// Cleanup on unmount
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
isAnimatingRef.current = false
}
}, [content, isStreaming])
return (
<div className='relative max-w-full overflow-hidden' style={{ minHeight: '1.25rem' }}>
<CopilotMarkdownRenderer content={displayedContent} />
</div>
)
},
(prevProps, nextProps) => {
// Prevent re-renders during streaming unless content actually changed
return (
prevProps.content === nextProps.content && prevProps.isStreaming === nextProps.isStreaming
// markdownComponents is now memoized so no need to compare
)
}
)
SmoothStreamingText.displayName = 'SmoothStreamingText'
// Maximum character length for a word before it's broken up
const MAX_WORD_LENGTH = 25
const WordWrap = ({ text }: { text: string }) => {
if (!text) return null
// Split text into words, keeping spaces and punctuation
const parts = text.split(/(\s+)/g)
return (
<>
{parts.map((part, index) => {
// If the part is whitespace or shorter than the max length, render it as is
if (part.match(/\s+/) || part.length <= MAX_WORD_LENGTH) {
return <span key={index}>{part}</span>
}
// For long words, break them up into chunks
const chunks = []
for (let i = 0; i < part.length; i += MAX_WORD_LENGTH) {
chunks.push(part.substring(i, i + MAX_WORD_LENGTH))
}
return (
<span key={index} className='break-all'>
{chunks.map((chunk, chunkIndex) => (
<span key={chunkIndex}>{chunk}</span>
))}
</span>
)
})}
</>
)
}
const CopilotMessage: FC<CopilotMessageProps> = memo(
({ message, isStreaming }) => {
const isUser = message.role === 'user'

View File

@@ -24,24 +24,30 @@ import {
X,
Zap,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
Switch,
Textarea,
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui'
import { useSession } from '@/lib/auth-client'
import { createLogger } from '@/lib/logs/console/logger'
import { cn } from '@/lib/utils'
import { CopilotSlider } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/copilot-slider'
import { useCopilotStore } from '@/stores/copilot/store'
import { CopilotSlider as Slider } from './copilot-slider'
const logger = createLogger('CopilotUserInput')
export interface MessageFileAttachment {
id: string
s3_key: string
key: string
filename: string
media_type: string
size: number
@@ -53,7 +59,7 @@ interface AttachedFile {
size: number
type: string
path: string
key?: string // Add key field to store the actual S3 key
key?: string // Add key field to store the actual storage key
uploading: boolean
previewUrl?: string // For local preview of images before upload
}
@@ -191,7 +197,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
const userId = session?.user?.id
if (!userId) {
console.error('User ID not available for file upload')
logger.error('User ID not available for file upload')
return
}
@@ -237,21 +243,22 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
const presignedData = await presignedResponse.json()
// Upload file to S3
console.log('Uploading to S3:', presignedData.presignedUrl)
logger.info(`Uploading file: ${presignedData.presignedUrl}`)
const uploadHeaders = presignedData.uploadHeaders || {}
const uploadResponse = await fetch(presignedData.presignedUrl, {
method: 'PUT',
headers: {
'Content-Type': file.type,
...uploadHeaders,
},
body: file,
})
console.log('S3 Upload response status:', uploadResponse.status)
logger.info(`Upload response status: ${uploadResponse.status}`)
if (!uploadResponse.ok) {
const errorText = await uploadResponse.text()
console.error('S3 Upload failed:', errorText)
logger.error(`Upload failed: ${errorText}`)
throw new Error(`Failed to upload file: ${uploadResponse.status} ${errorText}`)
}
@@ -262,14 +269,14 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
? {
...f,
path: presignedData.fileInfo.path,
key: presignedData.fileInfo.key, // Store the actual S3 key
key: presignedData.fileInfo.key, // Store the actual storage key
uploading: false,
}
: f
)
)
} catch (error) {
console.error('File upload failed:', error)
logger.error(`File upload failed: ${error}`)
// Remove failed upload
setAttachedFiles((prev) => prev.filter((f) => f.id !== tempFile.id))
}
@@ -283,10 +290,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
// Check for failed uploads and show user feedback
const failedUploads = attachedFiles.filter((f) => !f.uploading && !f.key)
if (failedUploads.length > 0) {
console.error(
'Some files failed to upload:',
failedUploads.map((f) => f.name)
)
logger.error(`Some files failed to upload: ${failedUploads.map((f) => f.name).join(', ')}`)
}
// Convert attached files to the format expected by the API
@@ -294,7 +298,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
.filter((f) => !f.uploading && f.key) // Only include successfully uploaded files with keys
.map((f) => ({
id: f.id,
s3_key: f.key!, // Use the actual S3 key stored from the upload response
key: f.key!, // Use the actual storage key from the upload response
filename: f.name,
media_type: f.type,
size: f.size,
@@ -372,9 +376,9 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
}
const handleFileClick = (file: AttachedFile) => {
// If file has been uploaded and has an S3 key, open the S3 URL
// If file has been uploaded and has a storage key, open the file URL
if (file.key) {
const serveUrl = `/api/files/serve/s3/${encodeURIComponent(file.key)}?bucket=copilot`
const serveUrl = file.path
window.open(serveUrl, '_blank')
} else if (file.previewUrl) {
// If file hasn't been uploaded yet but has a preview URL, open that
@@ -512,9 +516,9 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
className='h-full w-full object-cover'
/>
) : isImageFile(file.type) && file.key ? (
// For uploaded images without preview URL, use S3 URL
// For uploaded images without preview URL, use storage URL
<img
src={`/api/files/serve/s3/${encodeURIComponent(file.key)}?bucket=copilot`}
src={file.previewUrl || file.path}
alt={file.name}
className='h-full w-full object-cover'
/>
@@ -719,7 +723,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
</div>
</div>
<div className='relative'>
<Slider
<CopilotSlider
min={0}
max={3}
step={1}

View File

@@ -16,16 +16,18 @@ import 'prismjs/components/prism-javascript'
import 'prismjs/themes/prism.css'
import Editor from 'react-simple-code-editor'
import { Button } from '@/components/ui/button'
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Input } from '@/components/ui/input'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
Input,
ScrollArea,
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui'
import { createLogger } from '@/lib/logs/console/logger'
import { validateName } from '@/lib/utils'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'

View File

@@ -759,14 +759,6 @@ export class AgentBlockHandler implements BlockHandler {
}
private processRegularResponse(result: any, responseFormat: any): BlockOutput {
logger.info('Provider response received', {
contentLength: result.content ? result.content.length : 0,
model: result.model,
hasTokens: !!result.tokens,
hasToolCalls: !!result.toolCalls,
toolCallsCount: result.toolCalls?.length || 0,
})
if (responseFormat) {
return this.processStructuredResponse(result, responseFormat)
}

View File

@@ -42,7 +42,7 @@ export interface CopilotChat {
*/
export interface MessageFileAttachment {
id: string
s3_key: string
key: string
filename: string
media_type: string
size: number

View File

@@ -217,14 +217,6 @@ export function getCopilotConfig(): CopilotConfig {
try {
applyEnvironmentOverrides(config)
logger.info('Copilot configuration loaded', {
chatProvider: config.chat.defaultProvider,
chatModel: config.chat.defaultModel,
ragProvider: config.rag.defaultProvider,
ragModel: config.rag.defaultModel,
streamingEnabled: config.general.streamingEnabled,
})
} catch (error) {
logger.warn('Error applying environment variable overrides, using defaults', { error })
}

View File

@@ -1,23 +1,39 @@
import OpenAI from 'openai'
import OpenAI, { AzureOpenAI } from 'openai'
import { env } from '@/lib/env'
const azureApiKey = env.AZURE_OPENAI_API_KEY
const azureEndpoint = env.AZURE_OPENAI_ENDPOINT
const azureApiVersion = env.AZURE_OPENAI_API_VERSION
const chatTitleModelName = env.WAND_OPENAI_MODEL_NAME || 'gpt-4o'
const openaiApiKey = env.OPENAI_API_KEY
const useChatTitleAzure = azureApiKey && azureEndpoint && azureApiVersion
const client = useChatTitleAzure
? new AzureOpenAI({
apiKey: azureApiKey,
apiVersion: azureApiVersion,
endpoint: azureEndpoint,
})
: openaiApiKey
? new OpenAI({
apiKey: openaiApiKey,
})
: null
/**
* Generates a short title for a chat based on the first message
* @param message First user message in the chat
* @returns A short title or null if API key is not available
*/
export async function generateChatTitle(message: string): Promise<string | null> {
const apiKey = env.OPENAI_API_KEY
if (!apiKey) {
if (!client) {
return null
}
try {
const openai = new OpenAI({ apiKey })
const response = await openai.chat.completions.create({
model: 'gpt-3.5-turbo',
const response = await client.chat.completions.create({
model: useChatTitleAzure ? chatTitleModelName : 'gpt-4o',
messages: [
{
role: 'system',
@@ -30,7 +46,7 @@ export async function generateChatTitle(message: string): Promise<string | null>
},
],
max_tokens: 20,
temperature: 0.7,
temperature: 0.2,
})
const title = response.choices[0]?.message?.content?.trim() || null

View File

@@ -1 +1 @@
export const SIM_AGENT_API_URL_DEFAULT = 'https://agent.sim.ai'
export const SIM_AGENT_API_URL_DEFAULT = 'https://staging.agent.sim.ai'

View File

@@ -1,12 +1,12 @@
export interface FileAttachment {
id: string
s3_key: string
key: string
filename: string
media_type: string
size: number
}
export interface AnthropicMessageContent {
export interface MessageContent {
type: 'text' | 'image' | 'document'
text?: string
source?: {
@@ -17,7 +17,7 @@ export interface AnthropicMessageContent {
}
/**
* Mapping of MIME types to Anthropic content types
* Mapping of MIME types to content types
*/
export const MIME_TYPE_MAPPING: Record<string, 'image' | 'document'> = {
// Images
@@ -47,14 +47,14 @@ export const MIME_TYPE_MAPPING: Record<string, 'image' | 'document'> = {
}
/**
* Get the Anthropic content type for a given MIME type
* Get the content type for a given MIME type
*/
export function getAnthropicContentType(mimeType: string): 'image' | 'document' | null {
export function getContentType(mimeType: string): 'image' | 'document' | null {
return MIME_TYPE_MAPPING[mimeType.toLowerCase()] || null
}
/**
* Check if a MIME type is supported by Anthropic
* Check if a MIME type is supported
*/
export function isSupportedFileType(mimeType: string): boolean {
return mimeType.toLowerCase() in MIME_TYPE_MAPPING
@@ -68,13 +68,10 @@ export function bufferToBase64(buffer: Buffer): string {
}
/**
* Create Anthropic message content from file data
* Create message content from file data
*/
export function createAnthropicFileContent(
fileBuffer: Buffer,
mimeType: string
): AnthropicMessageContent | null {
const contentType = getAnthropicContentType(mimeType)
export function createFileContent(fileBuffer: Buffer, mimeType: string): MessageContent | null {
const contentType = getContentType(mimeType)
if (!contentType) {
return null
}

View File

@@ -1,6 +1,18 @@
// BlobClient and S3Client are server-only - import from specific files when needed
// export * as BlobClient from '@/lib/uploads/blob/blob-client'
// export * as S3Client from '@/lib/uploads/s3/s3-client'
export {
bufferToBase64,
createFileContent as createAnthropicFileContent,
type FileAttachment,
getContentType as getAnthropicContentType,
getFileExtension,
getMimeTypeFromExtension,
isSupportedFileType,
type MessageContent as AnthropicMessageContent,
MIME_TYPE_MAPPING,
} from '@/lib/uploads/file-utils'
export {
BLOB_CHAT_CONFIG,
BLOB_CONFIG,

View File

@@ -102,16 +102,45 @@ export async function uploadFile(
* @param key File key/name
* @returns File buffer
*/
export async function downloadFile(key: string): Promise<Buffer> {
export async function downloadFile(key: string): Promise<Buffer>
/**
* Download a file from the configured storage provider with custom configuration
* @param key File key/name
* @param customConfig Custom storage configuration
* @returns File buffer
*/
export async function downloadFile(key: string, customConfig: CustomStorageConfig): Promise<Buffer>
export async function downloadFile(
key: string,
customConfig?: CustomStorageConfig
): Promise<Buffer> {
if (USE_BLOB_STORAGE) {
logger.info(`Downloading file from Azure Blob Storage: ${key}`)
const { downloadFromBlob } = await import('@/lib/uploads/blob/blob-client')
if (customConfig) {
const blobConfig: CustomBlobConfig = {
containerName: customConfig.containerName!,
accountName: customConfig.accountName!,
accountKey: customConfig.accountKey,
connectionString: customConfig.connectionString,
}
return downloadFromBlob(key, blobConfig)
}
return downloadFromBlob(key)
}
if (USE_S3_STORAGE) {
logger.info(`Downloading file from S3: ${key}`)
const { downloadFromS3 } = await import('@/lib/uploads/s3/s3-client')
if (customConfig) {
const s3Config: CustomS3Config = {
bucket: customConfig.bucket!,
region: customConfig.region!,
}
return downloadFromS3(key, s3Config)
}
return downloadFromS3(key)
}

View File

@@ -10,7 +10,6 @@ import {
const logger = createLogger('Providers')
// Sanitize the request by removing parameters that aren't supported by the model
function sanitizeRequest(request: ProviderRequest): ProviderRequest {
const sanitizedRequest = { ...request }
@@ -33,11 +32,6 @@ export async function executeProviderRequest(
providerId: string,
request: ProviderRequest
): Promise<ProviderResponse | ReadableStream | StreamingExecution> {
logger.info(`Executing request with provider: ${providerId}`, {
hasResponseFormat: !!request.responseFormat,
model: request.model,
})
const provider = getProvider(providerId)
if (!provider) {
throw new Error(`Provider not found: ${providerId}`)
@@ -87,16 +81,6 @@ export async function executeProviderRequest(
return response
}
// At this point, we know we have a ProviderResponse
logger.info('Provider response received', {
contentLength: response.content ? response.content.length : 0,
model: response.model,
hasTokens: !!response.tokens,
hasToolCalls: !!response.toolCalls,
toolCallsCount: response.toolCalls?.length || 0,
})
// Calculate cost based on token usage if tokens are available
if (response.tokens) {
const { prompt: promptTokens = 0, completion: completionTokens = 0 } = response.tokens
const useCachedInput = !!request.context && request.context.length > 0

View File

@@ -12,7 +12,7 @@ export interface CopilotToolCall {
export interface MessageFileAttachment {
id: string
s3_key: string
key: string
filename: string
media_type: string
size: number