ai queue repr
This commit is contained in:
parent
d2fdeccce3
commit
69fc97f7a0
@ -81,7 +81,8 @@ const domainAgnosticSoftware = data['domain-agnostic-software'] || [];
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- This should be your loading section in AIQueryInterface.astro -->
|
||||||
<!-- Loading State -->
|
<!-- Loading State -->
|
||||||
<div id="ai-loading" class="ai-loading" style="display: none; text-align: center; padding: 2rem;">
|
<div id="ai-loading" class="ai-loading" style="display: none; text-align: center; padding: 2rem;">
|
||||||
<div style="display: inline-block; margin-bottom: 1rem;">
|
<div style="display: inline-block; margin-bottom: 1rem;">
|
||||||
@ -92,6 +93,32 @@ const domainAgnosticSoftware = data['domain-agnostic-software'] || [];
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<p id="loading-text" style="color: var(--color-text-secondary);">Analysiere Szenario und generiere Empfehlungen...</p>
|
<p id="loading-text" style="color: var(--color-text-secondary);">Analysiere Szenario und generiere Empfehlungen...</p>
|
||||||
|
|
||||||
|
<!-- Queue Status Display - THIS SECTION SHOULD BE PRESENT -->
|
||||||
|
<div id="queue-status" style="margin-top: 1rem; padding: 1rem; background-color: var(--color-bg-secondary); border-radius: 0.5rem; border: 1px solid var(--color-border); display: none;">
|
||||||
|
<div style="display: flex; align-items: center; justify-content: center; gap: 1rem; margin-bottom: 0.75rem;">
|
||||||
|
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
||||||
|
<div id="queue-position-badge" style="width: 24px; height: 24px; background-color: var(--color-primary); color: white; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: bold; font-size: 0.875rem;">1</div>
|
||||||
|
<span style="font-weight: 500;">Position in Warteschlange</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="font-size: 0.875rem; color: var(--color-text-secondary); text-align: center;">
|
||||||
|
<div id="queue-length-info" style="margin-bottom: 0.5rem;">
|
||||||
|
<span id="queue-length">0</span> Anfrage(n) in der Warteschlange
|
||||||
|
</div>
|
||||||
|
<div id="estimated-time-info">
|
||||||
|
Geschätzte Wartezeit: <span id="estimated-time">--</span>
|
||||||
|
</div>
|
||||||
|
<div id="task-id-info" style="margin-top: 0.5rem; font-family: monospace; font-size: 0.75rem; opacity: 0.7;">
|
||||||
|
Task-ID: <span id="current-task-id">--</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Progress bar -->
|
||||||
|
<div style="margin-top: 1rem; background-color: var(--color-bg-tertiary); border-radius: 0.25rem; height: 4px; overflow: hidden;">
|
||||||
|
<div id="queue-progress" style="height: 100%; background-color: var(--color-primary); width: 0%; transition: width 0.3s ease;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Error State -->
|
<!-- Error State -->
|
||||||
@ -240,86 +267,161 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
aiInput.addEventListener('input', updateCharacterCount);
|
aiInput.addEventListener('input', updateCharacterCount);
|
||||||
updateCharacterCount();
|
updateCharacterCount();
|
||||||
|
|
||||||
// Submit handler
|
// Submit handler with enhanced queue feedback
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
const query = aiInput.value.trim();
|
const query = aiInput.value.trim();
|
||||||
|
|
||||||
if (!query) {
|
|
||||||
alert('Bitte geben Sie eine Beschreibung ein.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (query.length < 10) {
|
|
||||||
alert('Bitte geben Sie eine detailliertere Beschreibung ein (mindestens 10 Zeichen).');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hide previous results and errors
|
|
||||||
aiResults.style.display = 'none';
|
|
||||||
aiError.style.display = 'none';
|
|
||||||
aiLoading.style.display = 'block';
|
|
||||||
|
|
||||||
// Disable submit button
|
|
||||||
aiSubmitBtn.disabled = true;
|
|
||||||
submitBtnText.textContent = currentMode === 'workflow' ? 'Generiere Empfehlungen...' : 'Suche passende Methode...';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/ai/query', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
query,
|
|
||||||
mode: currentMode
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(data.error || `HTTP ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!data.success) {
|
|
||||||
throw new Error(data.error || 'Unknown error');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store recommendation for restoration
|
|
||||||
currentRecommendation = data.recommendation;
|
|
||||||
|
|
||||||
// Display results based on mode
|
if (!query) {
|
||||||
if (currentMode === 'workflow') {
|
alert('Bitte geben Sie eine Beschreibung ein.');
|
||||||
displayWorkflowResults(data.recommendation, query);
|
return;
|
||||||
} else {
|
}
|
||||||
displayToolResults(data.recommendation, query);
|
|
||||||
|
if (query.length < 10) {
|
||||||
|
alert('Bitte geben Sie eine detailliertere Beschreibung ein (mindestens 10 Zeichen).');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate task ID for tracking
|
||||||
|
const taskId = `ai_${Date.now()}_${Math.random().toString(36).substr(2, 6)}`;
|
||||||
|
|
||||||
|
// Hide previous results and errors
|
||||||
|
aiResults.style.display = 'none';
|
||||||
|
aiError.style.display = 'none';
|
||||||
|
aiLoading.style.display = 'block';
|
||||||
|
|
||||||
|
// Show queue status section
|
||||||
|
const queueStatus = document.getElementById('queue-status');
|
||||||
|
const taskIdDisplay = document.getElementById('current-task-id');
|
||||||
|
if (queueStatus && taskIdDisplay) {
|
||||||
|
queueStatus.style.display = 'block';
|
||||||
|
taskIdDisplay.textContent = taskId;
|
||||||
}
|
}
|
||||||
|
|
||||||
aiLoading.style.display = 'none';
|
// Disable submit button
|
||||||
aiResults.style.display = 'block';
|
aiSubmitBtn.disabled = true;
|
||||||
|
submitBtnText.textContent = currentMode === 'workflow' ? 'Generiere Empfehlungen...' : 'Suche passende Methode...';
|
||||||
|
|
||||||
} catch (error) {
|
// Start queue status polling
|
||||||
console.error('AI query failed:', error);
|
let statusInterval;
|
||||||
aiLoading.style.display = 'none';
|
let startTime = Date.now();
|
||||||
aiError.style.display = 'block';
|
|
||||||
|
|
||||||
// Show user-friendly error messages
|
const updateQueueStatus = async () => {
|
||||||
if (error.message.includes('429')) {
|
try {
|
||||||
aiErrorMessage.textContent = 'Zu viele Anfragen. Bitte warten Sie einen Moment und versuchen Sie es erneut.';
|
const response = await fetch(`/api/ai/queue-status?taskId=${taskId}`);
|
||||||
} else if (error.message.includes('401')) {
|
const data = await response.json();
|
||||||
aiErrorMessage.textContent = 'Authentifizierung erforderlich. Bitte melden Sie sich an.';
|
|
||||||
} else if (error.message.includes('503')) {
|
if (data.success) {
|
||||||
aiErrorMessage.textContent = 'KI-Service vorübergehend nicht verfügbar. Bitte versuchen Sie es später erneut.';
|
const queueLength = document.getElementById('queue-length');
|
||||||
} else {
|
const estimatedTime = document.getElementById('estimated-time');
|
||||||
aiErrorMessage.textContent = `Fehler: ${error.message}`;
|
const positionBadge = document.getElementById('queue-position-badge');
|
||||||
|
const progressBar = document.getElementById('queue-progress');
|
||||||
|
|
||||||
|
if (queueLength) queueLength.textContent = data.queueLength;
|
||||||
|
|
||||||
|
if (estimatedTime) {
|
||||||
|
if (data.estimatedWaitTime > 0) {
|
||||||
|
estimatedTime.textContent = formatDuration(data.estimatedWaitTime);
|
||||||
|
} else {
|
||||||
|
estimatedTime.textContent = 'Verarbeitung läuft...';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (positionBadge && data.currentPosition) {
|
||||||
|
positionBadge.textContent = data.currentPosition;
|
||||||
|
|
||||||
|
// Update progress bar (inverse of position)
|
||||||
|
if (progressBar && data.queueLength > 0) {
|
||||||
|
const progress = Math.max(0, ((data.queueLength - data.currentPosition + 1) / data.queueLength) * 100);
|
||||||
|
progressBar.style.width = `${progress}%`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If processing and no position (request is being handled)
|
||||||
|
if (data.isProcessing && !data.currentPosition) {
|
||||||
|
if (positionBadge) positionBadge.textContent = '⚡';
|
||||||
|
if (progressBar) progressBar.style.width = '100%';
|
||||||
|
if (estimatedTime) estimatedTime.textContent = 'Verarbeitung läuft...';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Queue status update failed:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initial status update
|
||||||
|
updateQueueStatus();
|
||||||
|
|
||||||
|
// Poll every 500ms for status updates
|
||||||
|
statusInterval = setInterval(updateQueueStatus, 500);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/ai/query', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
query,
|
||||||
|
mode: currentMode,
|
||||||
|
taskId // Include task ID for backend tracking
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// Clear status polling
|
||||||
|
if (statusInterval) clearInterval(statusInterval);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || `HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error(data.error || 'Unknown error');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store recommendation for restoration
|
||||||
|
currentRecommendation = data.recommendation;
|
||||||
|
|
||||||
|
// Display results based on mode
|
||||||
|
if (currentMode === 'workflow') {
|
||||||
|
displayWorkflowResults(data.recommendation, query);
|
||||||
|
} else {
|
||||||
|
displayToolResults(data.recommendation, query);
|
||||||
|
}
|
||||||
|
|
||||||
|
aiLoading.style.display = 'none';
|
||||||
|
aiResults.style.display = 'block';
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('AI query failed:', error);
|
||||||
|
|
||||||
|
// Clear status polling
|
||||||
|
if (statusInterval) clearInterval(statusInterval);
|
||||||
|
|
||||||
|
aiLoading.style.display = 'none';
|
||||||
|
aiError.style.display = 'block';
|
||||||
|
|
||||||
|
// Show user-friendly error messages
|
||||||
|
if (error.message.includes('429')) {
|
||||||
|
aiErrorMessage.textContent = 'Zu viele Anfragen. Bitte warten Sie einen Moment und versuchen Sie es erneut.';
|
||||||
|
} else if (error.message.includes('401')) {
|
||||||
|
aiErrorMessage.textContent = 'Authentifizierung erforderlich. Bitte melden Sie sich an.';
|
||||||
|
} else if (error.message.includes('503')) {
|
||||||
|
aiErrorMessage.textContent = 'KI-Service vorübergehend nicht verfügbar. Bitte versuchen Sie es später erneut.';
|
||||||
|
} else {
|
||||||
|
aiErrorMessage.textContent = `Fehler: ${error.message}`;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
// Re-enable submit button and hide queue status
|
||||||
|
aiSubmitBtn.disabled = false;
|
||||||
|
const config = modeConfig[currentMode];
|
||||||
|
submitBtnText.textContent = config.submitText;
|
||||||
|
|
||||||
|
if (queueStatus) queueStatus.style.display = 'none';
|
||||||
|
if (statusInterval) clearInterval(statusInterval);
|
||||||
}
|
}
|
||||||
} finally {
|
};
|
||||||
// Re-enable submit button
|
|
||||||
aiSubmitBtn.disabled = false;
|
|
||||||
const config = modeConfig[currentMode];
|
|
||||||
submitBtnText.textContent = config.submitText;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Event listeners
|
// Event listeners
|
||||||
aiSubmitBtn.addEventListener('click', handleSubmit);
|
aiSubmitBtn.addEventListener('click', handleSubmit);
|
||||||
|
@ -20,7 +20,7 @@ const rateLimitStore = new Map<string, { count: number; resetTime: number }>();
|
|||||||
const RATE_LIMIT_WINDOW = 60 * 1000; // 1 minute
|
const RATE_LIMIT_WINDOW = 60 * 1000; // 1 minute
|
||||||
const RATE_LIMIT_MAX = 10; // 10 requests per minute per user
|
const RATE_LIMIT_MAX = 10; // 10 requests per minute per user
|
||||||
|
|
||||||
// Input validation and sanitization (UNCHANGED)
|
// Input validation and sanitization
|
||||||
function sanitizeInput(input: string): string {
|
function sanitizeInput(input: string): string {
|
||||||
// Remove any content that looks like system instructions
|
// Remove any content that looks like system instructions
|
||||||
let sanitized = input
|
let sanitized = input
|
||||||
@ -36,7 +36,7 @@ function sanitizeInput(input: string): string {
|
|||||||
return sanitized;
|
return sanitized;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strip markdown code blocks from AI response (UNCHANGED)
|
// Strip markdown code blocks from AI response
|
||||||
function stripMarkdownJson(content: string): string {
|
function stripMarkdownJson(content: string): string {
|
||||||
// Remove ```json and ``` wrappers
|
// Remove ```json and ``` wrappers
|
||||||
return content
|
return content
|
||||||
@ -45,7 +45,7 @@ function stripMarkdownJson(content: string): string {
|
|||||||
.trim();
|
.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rate limiting check (UNCHANGED)
|
// Rate limiting check
|
||||||
function checkRateLimit(userId: string): boolean {
|
function checkRateLimit(userId: string): boolean {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const userLimit = rateLimitStore.get(userId);
|
const userLimit = rateLimitStore.get(userId);
|
||||||
@ -74,7 +74,7 @@ function cleanupExpiredRateLimits() {
|
|||||||
|
|
||||||
setInterval(cleanupExpiredRateLimits, 5 * 60 * 1000);
|
setInterval(cleanupExpiredRateLimits, 5 * 60 * 1000);
|
||||||
|
|
||||||
// Load tools database (UNCHANGED)
|
// Load tools database
|
||||||
async function loadToolsDatabase() {
|
async function loadToolsDatabase() {
|
||||||
try {
|
try {
|
||||||
return await getCompressedToolsDataForAI();
|
return await getCompressedToolsDataForAI();
|
||||||
@ -84,7 +84,7 @@ async function loadToolsDatabase() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create system prompt for workflow mode (EXACTLY AS ORIGINAL)
|
// Create system prompt for workflow mode
|
||||||
function createWorkflowSystemPrompt(toolsData: any): string {
|
function createWorkflowSystemPrompt(toolsData: any): string {
|
||||||
const toolsList = toolsData.tools.map((tool: any) => ({
|
const toolsList = toolsData.tools.map((tool: any) => ({
|
||||||
name: tool.name,
|
name: tool.name,
|
||||||
@ -99,7 +99,7 @@ function createWorkflowSystemPrompt(toolsData: any): string {
|
|||||||
related_concepts: tool.related_concepts || []
|
related_concepts: tool.related_concepts || []
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// NEW: Include concepts for background knowledge
|
// Include concepts for background knowledge
|
||||||
const conceptsList = toolsData.concepts.map((concept: any) => ({
|
const conceptsList = toolsData.concepts.map((concept: any) => ({
|
||||||
name: concept.name,
|
name: concept.name,
|
||||||
description: concept.description,
|
description: concept.description,
|
||||||
@ -109,7 +109,7 @@ function createWorkflowSystemPrompt(toolsData: any): string {
|
|||||||
tags: concept.tags
|
tags: concept.tags
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Get regular phases (no more filtering needed)
|
// Get regular phases
|
||||||
const regularPhases = toolsData.phases || [];
|
const regularPhases = toolsData.phases || [];
|
||||||
|
|
||||||
// Get domain-agnostic software phases
|
// Get domain-agnostic software phases
|
||||||
@ -201,7 +201,7 @@ ANTWORT-FORMAT (strict JSON):
|
|||||||
Antworte NUR mit validen JSON. Keine zusätzlichen Erklärungen außerhalb des JSON.`;
|
Antworte NUR mit validen JSON. Keine zusätzlichen Erklärungen außerhalb des JSON.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create system prompt for tool-specific mode (EXACTLY AS ORIGINAL)
|
// Create system prompt for tool-specific mode
|
||||||
function createToolSystemPrompt(toolsData: any): string {
|
function createToolSystemPrompt(toolsData: any): string {
|
||||||
const toolsList = toolsData.tools.map((tool: any) => ({
|
const toolsList = toolsData.tools.map((tool: any) => ({
|
||||||
name: tool.name,
|
name: tool.name,
|
||||||
@ -217,7 +217,7 @@ function createToolSystemPrompt(toolsData: any): string {
|
|||||||
related_concepts: tool.related_concepts || []
|
related_concepts: tool.related_concepts || []
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// NEW: Include concepts for background knowledge
|
// Include concepts for background knowledge
|
||||||
const conceptsList = toolsData.concepts.map((concept: any) => ({
|
const conceptsList = toolsData.concepts.map((concept: any) => ({
|
||||||
name: concept.name,
|
name: concept.name,
|
||||||
description: concept.description,
|
description: concept.description,
|
||||||
@ -277,7 +277,7 @@ Antworte NUR mit validen JSON. Keine zusätzlichen Erklärungen außerhalb des J
|
|||||||
|
|
||||||
export const POST: APIRoute = async ({ request }) => {
|
export const POST: APIRoute = async ({ request }) => {
|
||||||
try {
|
try {
|
||||||
// CONSOLIDATED: Replace 20+ lines with single function call (UNCHANGED)
|
// Authentication check
|
||||||
const authResult = await withAPIAuth(request, 'ai');
|
const authResult = await withAPIAuth(request, 'ai');
|
||||||
if (!authResult.authenticated) {
|
if (!authResult.authenticated) {
|
||||||
return createAuthErrorResponse();
|
return createAuthErrorResponse();
|
||||||
@ -285,16 +285,16 @@ export const POST: APIRoute = async ({ request }) => {
|
|||||||
|
|
||||||
const userId = authResult.userId;
|
const userId = authResult.userId;
|
||||||
|
|
||||||
// Rate limiting (ONLY CHANGE: Use helper for this one response)
|
// Rate limiting
|
||||||
if (!checkRateLimit(userId)) {
|
if (!checkRateLimit(userId)) {
|
||||||
return apiError.rateLimit('Rate limit exceeded');
|
return apiError.rateLimit('Rate limit exceeded');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse request body (UNCHANGED)
|
// Parse request body
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { query, mode = 'workflow' } = body;
|
const { query, mode = 'workflow', taskId: clientTaskId } = body;
|
||||||
|
|
||||||
// Validation (ONLY CHANGE: Use helpers for error responses)
|
// Validation
|
||||||
if (!query || typeof query !== 'string') {
|
if (!query || typeof query !== 'string') {
|
||||||
return apiError.badRequest('Query required');
|
return apiError.badRequest('Query required');
|
||||||
}
|
}
|
||||||
@ -303,20 +303,24 @@ export const POST: APIRoute = async ({ request }) => {
|
|||||||
return apiError.badRequest('Invalid mode. Must be "workflow" or "tool"');
|
return apiError.badRequest('Invalid mode. Must be "workflow" or "tool"');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sanitize input (UNCHANGED)
|
// Sanitize input
|
||||||
const sanitizedQuery = sanitizeInput(query);
|
const sanitizedQuery = sanitizeInput(query);
|
||||||
if (sanitizedQuery.includes('[FILTERED]')) {
|
if (sanitizedQuery.includes('[FILTERED]')) {
|
||||||
return apiError.badRequest('Invalid input detected');
|
return apiError.badRequest('Invalid input detected');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load tools database (UNCHANGED)
|
// Load tools database
|
||||||
const toolsData = await loadToolsDatabase();
|
const toolsData = await loadToolsDatabase();
|
||||||
|
|
||||||
// Create appropriate system prompt based on mode (UNCHANGED)
|
// Create appropriate system prompt based on mode
|
||||||
const systemPrompt = mode === 'workflow'
|
const systemPrompt = mode === 'workflow'
|
||||||
? createWorkflowSystemPrompt(toolsData)
|
? createWorkflowSystemPrompt(toolsData)
|
||||||
: createToolSystemPrompt(toolsData);
|
: createToolSystemPrompt(toolsData);
|
||||||
|
|
||||||
|
// Generate task ID for queue tracking (use client-provided ID if available)
|
||||||
|
const taskId = clientTaskId || `ai_${userId}_${Date.now()}_${Math.random().toString(36).substr(2, 6)}`;
|
||||||
|
|
||||||
|
// Make AI API call through rate-limited queue
|
||||||
const aiResponse = await enqueueApiCall(() =>
|
const aiResponse = await enqueueApiCall(() =>
|
||||||
fetch(process.env.AI_API_ENDPOINT + '/v1/chat/completions', {
|
fetch(process.env.AI_API_ENDPOINT + '/v1/chat/completions', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@ -340,9 +344,9 @@ export const POST: APIRoute = async ({ request }) => {
|
|||||||
temperature: 0.3
|
temperature: 0.3
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
);
|
, taskId);
|
||||||
|
|
||||||
// AI response handling (ONLY CHANGE: Use helpers for error responses)
|
// AI response handling
|
||||||
if (!aiResponse.ok) {
|
if (!aiResponse.ok) {
|
||||||
console.error('AI API error:', await aiResponse.text());
|
console.error('AI API error:', await aiResponse.text());
|
||||||
return apiServerError.unavailable('AI service unavailable');
|
return apiServerError.unavailable('AI service unavailable');
|
||||||
@ -355,7 +359,7 @@ export const POST: APIRoute = async ({ request }) => {
|
|||||||
return apiServerError.unavailable('No response from AI');
|
return apiServerError.unavailable('No response from AI');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse AI JSON response (UNCHANGED)
|
// Parse AI JSON response
|
||||||
let recommendation;
|
let recommendation;
|
||||||
try {
|
try {
|
||||||
const cleanedContent = stripMarkdownJson(aiContent);
|
const cleanedContent = stripMarkdownJson(aiContent);
|
||||||
@ -365,7 +369,7 @@ export const POST: APIRoute = async ({ request }) => {
|
|||||||
return apiServerError.unavailable('Invalid AI response format');
|
return apiServerError.unavailable('Invalid AI response format');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate tool names and concept names against database (EXACTLY AS ORIGINAL)
|
// Validate tool names and concept names against database
|
||||||
const validToolNames = new Set(toolsData.tools.map((t: any) => t.name));
|
const validToolNames = new Set(toolsData.tools.map((t: any) => t.name));
|
||||||
const validConceptNames = new Set(toolsData.concepts.map((c: any) => c.name));
|
const validConceptNames = new Set(toolsData.concepts.map((c: any) => c.name));
|
||||||
|
|
||||||
@ -415,13 +419,14 @@ export const POST: APIRoute = async ({ request }) => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log successful query (UNCHANGED)
|
// Log successful query
|
||||||
console.log(`[AI Query] Mode: ${mode}, User: ${userId}, Query length: ${sanitizedQuery.length}, Tools: ${validatedRecommendation.recommended_tools.length}, Concepts: ${validatedRecommendation.background_knowledge?.length || 0}`);
|
console.log(`[AI Query] Mode: ${mode}, User: ${userId}, Query length: ${sanitizedQuery.length}, Tools: ${validatedRecommendation.recommended_tools.length}, Concepts: ${validatedRecommendation.background_knowledge?.length || 0}`);
|
||||||
|
|
||||||
// SUCCESS RESPONSE (UNCHANGED - Preserves exact original format)
|
// Success response with task ID
|
||||||
return new Response(JSON.stringify({
|
return new Response(JSON.stringify({
|
||||||
success: true,
|
success: true,
|
||||||
mode,
|
mode,
|
||||||
|
taskId,
|
||||||
recommendation: validatedRecommendation,
|
recommendation: validatedRecommendation,
|
||||||
query: sanitizedQuery
|
query: sanitizedQuery
|
||||||
}), {
|
}), {
|
||||||
@ -431,7 +436,6 @@ export const POST: APIRoute = async ({ request }) => {
|
|||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('AI query error:', error);
|
console.error('AI query error:', error);
|
||||||
// ONLY CHANGE: Use helper for error response
|
|
||||||
return apiServerError.internal('Internal server error');
|
return apiServerError.internal('Internal server error');
|
||||||
}
|
}
|
||||||
};
|
};
|
23
src/pages/api/ai/queue-status.ts
Normal file
23
src/pages/api/ai/queue-status.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
// src/pages/api/ai/queue-status.ts
|
||||||
|
import type { APIRoute } from 'astro';
|
||||||
|
import { getQueueStatus } from '../../../utils/rateLimitedQueue.js';
|
||||||
|
import { apiResponse, apiServerError } from '../../../utils/api.js';
|
||||||
|
|
||||||
|
export const prerender = false;
|
||||||
|
|
||||||
|
export const GET: APIRoute = async ({ request }) => {
|
||||||
|
try {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const taskId = url.searchParams.get('taskId');
|
||||||
|
|
||||||
|
const status = getQueueStatus(taskId || undefined);
|
||||||
|
|
||||||
|
return apiResponse.success({
|
||||||
|
...status,
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Queue status error:', error);
|
||||||
|
return apiServerError.internal('Failed to get queue status');
|
||||||
|
}
|
||||||
|
};
|
@ -1031,7 +1031,7 @@ Collaboration Section Collapse */
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ai-loading, .ai-error, .ai-results {
|
.ai-loading, .ai-error, .ai-results {
|
||||||
animation: fadeIn 0.3s ease-in;
|
animation: fadeIn 0.3s ease-in-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-mode-toggle {
|
.ai-mode-toggle {
|
||||||
@ -1428,12 +1428,23 @@ footer {
|
|||||||
max-height: 0;
|
max-height: 0;
|
||||||
padding-top: 0;
|
padding-top: 0;
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
|
transform: translateY(-10px);
|
||||||
}
|
}
|
||||||
to {
|
to {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
max-height: 1000px;
|
max-height: 1000px;
|
||||||
padding-top: 1rem;
|
padding-top: 1rem;
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1507,9 +1518,16 @@ Strobing borders: Bright colored borders that change with each keyframe
|
|||||||
Higher opacity: More saturated colors (up to 100% on yellow)
|
Higher opacity: More saturated colors (up to 100% on yellow)
|
||||||
|
|
||||||
This will literally assault the user's retinas. They'll need sunglasses to look at their shared tools! 🌈💥👁️🗨️*/
|
This will literally assault the user's retinas. They'll need sunglasses to look at their shared tools! 🌈💥👁️🗨️*/
|
||||||
|
|
||||||
@keyframes pulse {
|
@keyframes pulse {
|
||||||
0%, 100% { opacity: 1; }
|
0%, 100% {
|
||||||
50% { opacity: 0.5; }
|
transform: scale(1);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scale(1.05);
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes fadeInUp {
|
@keyframes fadeInUp {
|
||||||
@ -2039,4 +2057,19 @@ This will literally assault the user's retinas. They'll need sunglasses to look
|
|||||||
.form-label.required::after {
|
.form-label.required::after {
|
||||||
content: " *";
|
content: " *";
|
||||||
color: var(--color-error);
|
color: var(--color-error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#queue-status {
|
||||||
|
animation: slideDown 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
#queue-position-badge {
|
||||||
|
animation: pulse 2s infinite;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
#queue-progress {
|
||||||
|
background: linear-gradient(90deg, var(--color-primary), var(--color-accent));
|
||||||
|
animation: shimmer 2s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ -1,9 +1,6 @@
|
|||||||
// src/utils/rateLimitedQueue.ts
|
// src/utils/rateLimitedQueue.ts
|
||||||
// ------------------------------------------------------------
|
// ------------------------------------------------------------
|
||||||
// A tiny FIFO, single‑instance queue that spaces API requests by
|
// Enhanced FIFO queue with status tracking for visual feedback
|
||||||
// a configurable delay. Import `enqueueApiCall()` wherever you
|
|
||||||
// call the AI API and the queue will make sure calls are sent
|
|
||||||
// one after another with the defined pause in‑between.
|
|
||||||
// ------------------------------------------------------------
|
// ------------------------------------------------------------
|
||||||
|
|
||||||
import dotenv from "dotenv";
|
import dotenv from "dotenv";
|
||||||
@ -12,53 +9,113 @@ dotenv.config();
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Delay (in **milliseconds**) between two consecutive API calls.
|
* Delay (in **milliseconds**) between two consecutive API calls.
|
||||||
*
|
* Defaults to **2000 ms** (2 seconds) when not set or invalid.
|
||||||
* Configure it in your `.env` file, e.g.
|
|
||||||
* AI_RATE_LIMIT_DELAY_MS=2000
|
|
||||||
* Defaults to **1000 ms** (≈ 1 request / second) when not set or invalid.
|
|
||||||
*/
|
*/
|
||||||
const RATE_LIMIT_DELAY_MS = Number.parseInt(process.env.AI_RATE_LIMIT_DELAY_MS ?? "1000", 10) || 1000;
|
const RATE_LIMIT_DELAY_MS = Number.parseInt(process.env.AI_RATE_LIMIT_DELAY_MS ?? "2000", 10) || 2000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Internal task type. Every task returns a Promise so callers get the
|
* Internal task type with ID tracking for status updates
|
||||||
* real API response transparently.
|
|
||||||
*/
|
*/
|
||||||
export type Task<T = unknown> = () => Promise<T>;
|
export type Task<T = unknown> = () => Promise<T>;
|
||||||
|
|
||||||
|
interface QueuedTask {
|
||||||
|
id: string;
|
||||||
|
task: Task;
|
||||||
|
addedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueueStatus {
|
||||||
|
queueLength: number;
|
||||||
|
isProcessing: boolean;
|
||||||
|
estimatedWaitTime: number; // in milliseconds
|
||||||
|
currentPosition?: number; // position of specific request
|
||||||
|
}
|
||||||
|
|
||||||
class RateLimitedQueue {
|
class RateLimitedQueue {
|
||||||
private queue: Task[] = [];
|
private queue: QueuedTask[] = [];
|
||||||
private processing = false;
|
private processing = false;
|
||||||
private delayMs = RATE_LIMIT_DELAY_MS;
|
private delayMs = RATE_LIMIT_DELAY_MS;
|
||||||
|
private lastProcessedAt = 0;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Schedule a task. Returns a Promise that resolves/rejects with the
|
* Schedule a task with ID tracking. Returns a Promise that resolves/rejects
|
||||||
* task result once the queue reaches it.
|
* with the task result once the queue reaches it.
|
||||||
*/
|
*/
|
||||||
add<T>(task: Task<T>): Promise<T> {
|
add<T>(task: Task<T>, taskId?: string): Promise<T> {
|
||||||
|
const id = taskId || this.generateTaskId();
|
||||||
|
|
||||||
return new Promise<T>((resolve, reject) => {
|
return new Promise<T>((resolve, reject) => {
|
||||||
this.queue.push(async () => {
|
this.queue.push({
|
||||||
try {
|
id,
|
||||||
const result = await task();
|
task: async () => {
|
||||||
resolve(result);
|
try {
|
||||||
} catch (err) {
|
const result = await task();
|
||||||
reject(err);
|
resolve(result);
|
||||||
}
|
} catch (err) {
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
addedAt: Date.now()
|
||||||
});
|
});
|
||||||
this.process();
|
this.process();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Change the delay at runtime – e.g. if you reload env vars without
|
* Get current queue status for visual feedback
|
||||||
* restarting the server.
|
*/
|
||||||
|
getStatus(taskId?: string): QueueStatus {
|
||||||
|
const queueLength = this.queue.length;
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// Calculate estimated wait time
|
||||||
|
let estimatedWaitTime = 0;
|
||||||
|
if (queueLength > 0) {
|
||||||
|
if (this.processing) {
|
||||||
|
// Time since last request + remaining delay + queue length * delay
|
||||||
|
const timeSinceLastRequest = now - this.lastProcessedAt;
|
||||||
|
const remainingDelay = Math.max(0, this.delayMs - timeSinceLastRequest);
|
||||||
|
estimatedWaitTime = remainingDelay + (queueLength - 1) * this.delayMs;
|
||||||
|
} else {
|
||||||
|
// Queue will start immediately, so just queue length * delay
|
||||||
|
estimatedWaitTime = queueLength * this.delayMs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const status: QueueStatus = {
|
||||||
|
queueLength,
|
||||||
|
isProcessing: this.processing,
|
||||||
|
estimatedWaitTime
|
||||||
|
};
|
||||||
|
|
||||||
|
// Find position of specific task if ID provided
|
||||||
|
if (taskId) {
|
||||||
|
const position = this.queue.findIndex(item => item.id === taskId);
|
||||||
|
if (position >= 0) {
|
||||||
|
status.currentPosition = position + 1; // 1-based indexing for user display
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change the delay at runtime
|
||||||
*/
|
*/
|
||||||
setDelay(ms: number): void {
|
setDelay(ms: number): void {
|
||||||
if (!Number.isFinite(ms) || ms < 0) return;
|
if (!Number.isFinite(ms) || ms < 0) return;
|
||||||
this.delayMs = ms;
|
this.delayMs = ms;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current delay setting
|
||||||
|
*/
|
||||||
|
getDelay(): number {
|
||||||
|
return this.delayMs;
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------
|
// ---------------------------------------
|
||||||
// ️🌐 Internal helpers
|
// Internal helpers
|
||||||
// ---------------------------------------
|
// ---------------------------------------
|
||||||
private async process(): Promise<void> {
|
private async process(): Promise<void> {
|
||||||
if (this.processing) return;
|
if (this.processing) return;
|
||||||
@ -67,26 +124,41 @@ class RateLimitedQueue {
|
|||||||
while (this.queue.length > 0) {
|
while (this.queue.length > 0) {
|
||||||
const next = this.queue.shift();
|
const next = this.queue.shift();
|
||||||
if (!next) continue;
|
if (!next) continue;
|
||||||
await next();
|
|
||||||
// Wait before the next one
|
this.lastProcessedAt = Date.now();
|
||||||
await new Promise((r) => setTimeout(r, this.delayMs));
|
await next.task();
|
||||||
|
|
||||||
|
// Wait before the next one (only if there are more tasks)
|
||||||
|
if (this.queue.length > 0) {
|
||||||
|
await new Promise((r) => setTimeout(r, this.delayMs));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.processing = false;
|
this.processing = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private generateTaskId(): string {
|
||||||
|
return `task_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------
|
// ------------------------------------------------------------
|
||||||
// Export a **singleton** instance so every import shares the
|
// Export singleton instance and convenience functions
|
||||||
// same queue. That way the rate‑limit is enforced globally.
|
|
||||||
// ------------------------------------------------------------
|
// ------------------------------------------------------------
|
||||||
const queue = new RateLimitedQueue();
|
const queue = new RateLimitedQueue();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper for convenience: `enqueueApiCall(() => fetch(...))`.
|
* Helper for convenience: `enqueueApiCall(() => fetch(...), 'optional-id')`.
|
||||||
*/
|
*/
|
||||||
export function enqueueApiCall<T>(task: Task<T>): Promise<T> {
|
export function enqueueApiCall<T>(task: Task<T>, taskId?: string): Promise<T> {
|
||||||
return queue.add(task);
|
return queue.add(task, taskId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default queue;
|
/**
|
||||||
|
* Get current queue status for visual feedback
|
||||||
|
*/
|
||||||
|
export function getQueueStatus(taskId?: string): QueueStatus {
|
||||||
|
return queue.getStatus(taskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default queue;
|
Loading…
x
Reference in New Issue
Block a user