mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
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:
@@ -45,6 +45,7 @@ export async function GET(request: Request) {
|
||||
'support',
|
||||
'admin',
|
||||
'qa',
|
||||
'agent',
|
||||
]
|
||||
if (reservedSubdomains.includes(subdomain)) {
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -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 }),
|
||||
})
|
||||
|
||||
|
||||
@@ -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',
|
||||
}),
|
||||
})
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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'
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './file-display'
|
||||
export * from './markdown-renderer'
|
||||
export * from './smooth-streaming'
|
||||
export * from './thinking-block'
|
||||
@@ -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>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ export interface CopilotChat {
|
||||
*/
|
||||
export interface MessageFileAttachment {
|
||||
id: string
|
||||
s3_key: string
|
||||
key: string
|
||||
filename: string
|
||||
media_type: string
|
||||
size: number
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -12,7 +12,7 @@ export interface CopilotToolCall {
|
||||
|
||||
export interface MessageFileAttachment {
|
||||
id: string
|
||||
s3_key: string
|
||||
key: string
|
||||
filename: string
|
||||
media_type: string
|
||||
size: number
|
||||
|
||||
Reference in New Issue
Block a user