ai queue repr

This commit is contained in:
overcuriousity 2025-07-26 14:33:51 +02:00
parent d2fdeccce3
commit 69fc97f7a0
5 changed files with 371 additions and 137 deletions

View File

@ -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);

View File

@ -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');
} }
}; };

View 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');
}
};

View File

@ -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;
}

View File

@ -1,9 +1,6 @@
// src/utils/rateLimitedQueue.ts // src/utils/rateLimitedQueue.ts
// ------------------------------------------------------------ // ------------------------------------------------------------
// A tiny FIFO, singleinstance 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 inbetween.
// ------------------------------------------------------------ // ------------------------------------------------------------
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 **1000ms** (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 ratelimit 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;