- Implement dual-agent architecture supporting both Chat Completions and Responses API - Add comprehensive file upload functionality for responses agents with image and PDF support - Integrate OpenAI Conversations API for persistent file context across messages - Add compact UI design with reduced font sizes and tighter spacing for better chat focus - Include vector store management, document upload system, and admin dashboard enhancements - Fix conversation persistence ensuring uploaded files remain accessible in follow-up questions 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
530 lines
No EOL
17 KiB
JavaScript
530 lines
No EOL
17 KiB
JavaScript
const OpenAI = require('openai');
|
|
const { VectorStore, Document, ResponseSession, Conversation } = require('../models');
|
|
|
|
const openai = new OpenAI({
|
|
apiKey: process.env.OPENAI_API_KEY,
|
|
organization: process.env.OPENAI_ORG_ID,
|
|
});
|
|
|
|
// Direct HTTP client for Responses API until it's officially supported in the Node.js SDK
|
|
const fetch = (...args) => import('node-fetch').then(({default: fetch}) => fetch(...args));
|
|
|
|
class ResponsesAPIService {
|
|
constructor() {
|
|
if (!process.env.OPENAI_API_KEY || process.env.OPENAI_API_KEY.includes('your-actual')) {
|
|
console.warn('⚠️ OpenAI API key not configured properly for Responses API');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Make direct HTTP request to Responses API
|
|
*/
|
|
async makeResponsesAPIRequest(endpoint, method = 'GET', body = null) {
|
|
const url = `https://api.openai.com${endpoint}`;
|
|
|
|
const headers = {
|
|
'Authorization': `Bearer ${process.env.OPENAI_API_KEY}`,
|
|
'Content-Type': 'application/json',
|
|
};
|
|
|
|
if (process.env.OPENAI_ORG_ID) {
|
|
headers['OpenAI-Organization'] = process.env.OPENAI_ORG_ID;
|
|
}
|
|
|
|
const config = {
|
|
method,
|
|
headers,
|
|
};
|
|
|
|
if (body && (method === 'POST' || method === 'PUT' || method === 'PATCH')) {
|
|
config.body = JSON.stringify(body);
|
|
}
|
|
|
|
console.log(`Making ${method} request to ${url}`);
|
|
console.log(`Request body:`, body ? JSON.stringify(body, null, 2) : 'none');
|
|
|
|
const response = await fetch(url, config);
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
console.error(`Responses API Error (${response.status}):`, errorText);
|
|
throw new Error(`Responses API request failed: ${response.status} ${errorText}`);
|
|
}
|
|
|
|
return await response.json();
|
|
}
|
|
|
|
/**
|
|
* Build tools configuration based on agent settings
|
|
*/
|
|
buildToolsConfig(agent) {
|
|
const tools = [];
|
|
|
|
// Web Search Tool
|
|
if (agent.webSearchEnabled) {
|
|
tools.push({
|
|
type: 'web_search'
|
|
});
|
|
}
|
|
|
|
// File Search Tool
|
|
if (agent.fileSearchEnabled && agent.vectorStoreIds?.length > 0) {
|
|
tools.push({
|
|
type: 'file_search',
|
|
vector_store_ids: agent.vectorStoreIds,
|
|
max_num_results: agent.maxNumResults || 20
|
|
});
|
|
}
|
|
|
|
// Code Interpreter Tool
|
|
if (agent.codeInterpreterEnabled) {
|
|
tools.push({
|
|
type: 'code_interpreter'
|
|
});
|
|
}
|
|
|
|
return tools;
|
|
}
|
|
|
|
/**
|
|
* Create or get an OpenAI conversation for maintaining context
|
|
*/
|
|
async createOrGetConversation(conversationId) {
|
|
try {
|
|
if (conversationId) {
|
|
// Try to retrieve existing conversation from our metadata
|
|
const conversation = await Conversation.findByPk(conversationId);
|
|
if (conversation && conversation.metadata?.openaiConversationId) {
|
|
console.log(`Using existing OpenAI conversation: ${conversation.metadata.openaiConversationId}`);
|
|
return conversation.metadata.openaiConversationId;
|
|
}
|
|
}
|
|
|
|
// Create new OpenAI conversation
|
|
const openaiConversation = await this.makeResponsesAPIRequest('/v1/conversations', 'POST', {});
|
|
console.log(`Created new OpenAI conversation: ${openaiConversation.id}`);
|
|
|
|
// Store OpenAI conversation ID in our local conversation metadata
|
|
if (conversationId) {
|
|
const conversation = await Conversation.findByPk(conversationId);
|
|
if (conversation) {
|
|
await conversation.update({
|
|
metadata: {
|
|
...conversation.metadata,
|
|
openaiConversationId: openaiConversation.id
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
return openaiConversation.id;
|
|
} catch (error) {
|
|
console.error('Error managing OpenAI conversation:', error);
|
|
// If conversation creation fails, continue without it (fallback)
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Process files for Responses API (upload PDFs, store images as buffers)
|
|
*/
|
|
async processFiles(files) {
|
|
const processedFiles = [];
|
|
|
|
for (const file of files) {
|
|
try {
|
|
console.log(`Processing file: ${file.originalname} (${file.mimetype})`);
|
|
|
|
if (file.mimetype.startsWith('image/')) {
|
|
// Images: store buffer for base64 encoding (no upload needed)
|
|
processedFiles.push({
|
|
buffer: file.buffer,
|
|
filename: file.originalname,
|
|
mimetype: file.mimetype,
|
|
size: file.size,
|
|
type: 'image'
|
|
});
|
|
console.log(`✅ Image processed: ${file.originalname}`);
|
|
} else {
|
|
// Documents (PDF, text): upload to OpenAI
|
|
const { Readable } = require('stream');
|
|
const fileStream = Readable.from(file.buffer);
|
|
fileStream.path = file.originalname;
|
|
|
|
const uploadedFile = await openai.files.create({
|
|
file: fileStream,
|
|
purpose: 'assistants'
|
|
});
|
|
|
|
processedFiles.push({
|
|
id: uploadedFile.id,
|
|
filename: file.originalname,
|
|
mimetype: file.mimetype,
|
|
size: file.size,
|
|
type: 'document'
|
|
});
|
|
|
|
console.log(`✅ Document uploaded successfully: ${uploadedFile.id}`);
|
|
}
|
|
} catch (error) {
|
|
console.error(`❌ Error processing file ${file.originalname}:`, error);
|
|
throw new Error(`Failed to process file ${file.originalname}: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
return processedFiles;
|
|
}
|
|
|
|
/**
|
|
* Create a response using the OpenAI Responses API
|
|
*/
|
|
async createResponse(input, agent, conversationId, userId, options = {}) {
|
|
try {
|
|
const {
|
|
background = agent.backgroundProcessing || false,
|
|
store = true, // Always store for conversation management
|
|
attachments = [],
|
|
openaiConversationId: providedConversationId
|
|
} = options;
|
|
|
|
console.log(`Creating Responses API request for agent: ${agent.name} (${agent.key})`);
|
|
|
|
// Create or get OpenAI conversation for context persistence
|
|
const openaiConversationId = providedConversationId || await this.createOrGetConversation(conversationId);
|
|
|
|
const tools = this.buildToolsConfig(agent);
|
|
|
|
// Handle input format based on whether files are present
|
|
let formattedInput;
|
|
if (attachments && attachments.length > 0) {
|
|
// Format input as array with file attachments
|
|
const inputContent = [];
|
|
|
|
// Add file attachments first
|
|
attachments.forEach(file => {
|
|
if (file.type === 'image') {
|
|
// For images, use base64 encoding
|
|
const base64 = file.buffer.toString('base64');
|
|
inputContent.push({
|
|
type: 'input_image',
|
|
image_url: `data:${file.mimetype};base64,${base64}`
|
|
});
|
|
} else if (file.type === 'document') {
|
|
// For documents (PDF, text), use input_file with file_id
|
|
inputContent.push({
|
|
type: 'input_file',
|
|
file_id: file.id
|
|
});
|
|
}
|
|
});
|
|
|
|
// Add text input
|
|
if (input && input.trim()) {
|
|
inputContent.push({
|
|
type: 'input_text',
|
|
text: input
|
|
});
|
|
}
|
|
|
|
formattedInput = [{
|
|
role: 'user',
|
|
content: inputContent
|
|
}];
|
|
} else {
|
|
// Text-only input (original format)
|
|
formattedInput = input;
|
|
}
|
|
|
|
const requestParams = {
|
|
model: agent.model,
|
|
input: formattedInput,
|
|
instructions: agent.systemPrompt,
|
|
store,
|
|
};
|
|
|
|
// Add conversation ID for context persistence
|
|
if (openaiConversationId) {
|
|
requestParams.conversation = openaiConversationId;
|
|
}
|
|
|
|
// Add tools if any are enabled
|
|
if (tools.length > 0) {
|
|
requestParams.tools = tools;
|
|
}
|
|
|
|
// Add background processing if enabled
|
|
if (background) {
|
|
requestParams.background = true;
|
|
}
|
|
|
|
// Handle model-specific parameters for reasoning models
|
|
if (agent.model === 'gpt-5' || agent.model.startsWith('o1') || agent.model.startsWith('o3')) {
|
|
console.log(`Reasoning model detected: ${agent.model}, using reasoning.effort = ${agent.reasoningEffort}`);
|
|
requestParams.reasoning = {
|
|
effort: agent.reasoningEffort || 'medium'
|
|
};
|
|
}
|
|
|
|
// Note: Responses API handles temperature and token limits automatically
|
|
|
|
console.log(`Responses API request params:`, {
|
|
model: requestParams.model,
|
|
tools: tools.length,
|
|
background,
|
|
store,
|
|
reasoning_effort: requestParams.reasoning?.effort
|
|
});
|
|
|
|
// Use direct HTTP request for Responses API
|
|
const response = await this.makeResponsesAPIRequest('/v1/responses', 'POST', requestParams);
|
|
|
|
// Store response session for tracking
|
|
await ResponseSession.create({
|
|
openaiResponseId: response.id,
|
|
conversationId,
|
|
assistantId: agent.id,
|
|
userId,
|
|
status: response.status || 'completed',
|
|
backgroundProcessing: background,
|
|
toolsUsed: tools,
|
|
metadata: {
|
|
model: response.model || agent.model,
|
|
inputTokens: response.usage?.input_tokens,
|
|
outputTokens: response.usage?.output_tokens,
|
|
totalTokens: response.usage?.total_tokens,
|
|
toolCalls: response.output?.filter(item => item.type !== 'message').length || 0
|
|
}
|
|
});
|
|
|
|
return response;
|
|
} catch (error) {
|
|
console.error('Responses API Error:', {
|
|
model: agent.model,
|
|
error: error.message,
|
|
status: error.status,
|
|
code: error.code,
|
|
type: error.type
|
|
});
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Retrieve a response by ID
|
|
*/
|
|
async retrieveResponse(responseId) {
|
|
try {
|
|
const response = await this.makeResponsesAPIRequest(`/v1/responses/${responseId}`);
|
|
|
|
// Update our local record
|
|
const responseSession = await ResponseSession.findOne({
|
|
where: { openaiResponseId: responseId }
|
|
});
|
|
|
|
if (responseSession) {
|
|
await responseSession.update({
|
|
status: response.status,
|
|
metadata: {
|
|
...responseSession.metadata,
|
|
lastChecked: new Date(),
|
|
finalUsage: response.usage
|
|
}
|
|
});
|
|
}
|
|
|
|
return response;
|
|
} catch (error) {
|
|
console.error('Error retrieving response:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Cancel a background response
|
|
*/
|
|
async cancelResponse(responseId) {
|
|
try {
|
|
const response = await this.makeResponsesAPIRequest(`/v1/responses/${responseId}/cancel`, 'POST');
|
|
|
|
// Update our local record
|
|
const responseSession = await ResponseSession.findOne({
|
|
where: { openaiResponseId: responseId }
|
|
});
|
|
|
|
if (responseSession) {
|
|
await responseSession.update({
|
|
status: 'cancelled',
|
|
metadata: {
|
|
...responseSession.metadata,
|
|
cancelledAt: new Date()
|
|
}
|
|
});
|
|
}
|
|
|
|
return response;
|
|
} catch (error) {
|
|
console.error('Error cancelling response:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* List responses with optional filtering
|
|
*/
|
|
async listResponses(options = {}) {
|
|
try {
|
|
const { limit = 20, after } = options;
|
|
|
|
const listParams = {
|
|
limit,
|
|
};
|
|
|
|
if (after) {
|
|
listParams.after = after;
|
|
}
|
|
|
|
const queryString = new URLSearchParams(listParams).toString();
|
|
return await this.makeResponsesAPIRequest(`/v1/responses?${queryString}`);
|
|
} catch (error) {
|
|
console.error('Error listing responses:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create streaming response (if supported)
|
|
*/
|
|
async createStreamingResponse(input, agent, conversationId, userId, onChunk, options = {}) {
|
|
try {
|
|
console.log(`Creating streaming Responses API request for agent: ${agent.name}`);
|
|
|
|
// Create or get OpenAI conversation for context persistence
|
|
const openaiConversationId = await this.createOrGetConversation(conversationId);
|
|
|
|
const tools = this.buildToolsConfig(agent);
|
|
|
|
const requestParams = {
|
|
model: agent.model,
|
|
input: input,
|
|
instructions: agent.systemPrompt,
|
|
store: true
|
|
};
|
|
|
|
// Add conversation ID for context persistence
|
|
if (openaiConversationId) {
|
|
requestParams.conversation = openaiConversationId;
|
|
}
|
|
|
|
// Add tools if any are enabled
|
|
if (tools.length > 0) {
|
|
requestParams.tools = tools;
|
|
}
|
|
|
|
// Handle model-specific parameters for reasoning models
|
|
if (agent.model === 'gpt-5' || agent.model.startsWith('o1') || agent.model.startsWith('o3')) {
|
|
requestParams.reasoning = {
|
|
effort: agent.reasoningEffort || 'medium'
|
|
};
|
|
}
|
|
|
|
// For now, fallback to non-streaming since streaming with direct HTTP is complex
|
|
console.log('Streaming not yet implemented with direct HTTP, falling back to regular response');
|
|
const response = await this.createResponse(input, agent, conversationId, userId, { ...options, openaiConversationId });
|
|
|
|
// Extract text from Responses API format and simulate streaming
|
|
const messageOutput = response.output?.find(item => item.type === 'message');
|
|
const textContent = messageOutput?.content?.find(content => content.type === 'output_text');
|
|
const content = textContent?.text || response.output_text || '';
|
|
|
|
onChunk({ content, done: true, responseId: response.id, fullResponse: content });
|
|
|
|
return { responseId: response.id, fullResponse: content };
|
|
} catch (error) {
|
|
console.error('Streaming Responses API Error:', error);
|
|
|
|
// Fall back to non-streaming if streaming not supported
|
|
if (error.message?.includes('streaming') || error.message?.includes('stream')) {
|
|
console.log('Streaming not supported, falling back to regular response');
|
|
const response = await this.createResponse(input, agent, conversationId, userId, options);
|
|
|
|
// Simulate streaming for consistent interface
|
|
const content = response.choices?.[0]?.message?.content || '';
|
|
onChunk({ content, done: true, responseId: response.id, fullResponse: content });
|
|
|
|
return { responseId: response.id, fullResponse: content };
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* List OpenAI vector stores
|
|
*/
|
|
async listVectorStores(options = {}) {
|
|
try {
|
|
const { limit = 20, order = 'desc', after, before } = options;
|
|
|
|
const queryParams = new URLSearchParams({ limit, order });
|
|
if (after) queryParams.append('after', after);
|
|
if (before) queryParams.append('before', before);
|
|
|
|
const vectorStores = await this.makeResponsesAPIRequest(`/v1/vector_stores?${queryParams.toString()}`);
|
|
console.log(`Retrieved ${vectorStores.data?.length || 0} vector stores from OpenAI`);
|
|
|
|
return vectorStores;
|
|
} catch (error) {
|
|
console.error('Error listing vector stores:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get a specific vector store by ID
|
|
*/
|
|
async getVectorStore(vectorStoreId) {
|
|
try {
|
|
const vectorStore = await this.makeResponsesAPIRequest(`/v1/vector_stores/${vectorStoreId}`);
|
|
return vectorStore;
|
|
} catch (error) {
|
|
console.error(`Error getting vector store ${vectorStoreId}:`, error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Test connection to Responses API
|
|
*/
|
|
async testConnection() {
|
|
try {
|
|
const response = await this.createResponse(
|
|
'Hello, this is a test message.',
|
|
{
|
|
id: 'test',
|
|
name: 'Test Agent',
|
|
key: 'test',
|
|
model: 'gpt-4o',
|
|
systemPrompt: 'You are a helpful test assistant.',
|
|
temperature: 0.7,
|
|
maxTokens: 50,
|
|
webSearchEnabled: false,
|
|
fileSearchEnabled: false,
|
|
codeInterpreterEnabled: false,
|
|
vectorStoreIds: [],
|
|
backgroundProcessing: false
|
|
},
|
|
'test-conversation',
|
|
'test-user',
|
|
{ store: false }
|
|
);
|
|
|
|
console.log('✅ Responses API connection test successful');
|
|
return { success: true, response };
|
|
} catch (error) {
|
|
console.error('❌ Responses API connection test failed:', error.message);
|
|
return { success: false, error: error.message };
|
|
}
|
|
}
|
|
}
|
|
|
|
module.exports = new ResponsesAPIService(); |