From 69fc97f7a0b65f06bfb4f3dd979aa02880879d44 Mon Sep 17 00:00:00 2001 From: overcuriousity Date: Sat, 26 Jul 2025 14:33:51 +0200 Subject: [PATCH] ai queue repr --- src/components/AIQueryInterface.astro | 252 ++++++++++++++++++-------- src/pages/api/ai/query.ts | 52 +++--- src/pages/api/ai/queue-status.ts | 23 +++ src/styles/global.css | 41 ++++- src/utils/rateLimitedQueue.ts | 140 ++++++++++---- 5 files changed, 371 insertions(+), 137 deletions(-) create mode 100644 src/pages/api/ai/queue-status.ts diff --git a/src/components/AIQueryInterface.astro b/src/components/AIQueryInterface.astro index 1b58445..6e760e7 100644 --- a/src/components/AIQueryInterface.astro +++ b/src/components/AIQueryInterface.astro @@ -81,7 +81,8 @@ const domainAgnosticSoftware = data['domain-agnostic-software'] || []; - + + @@ -240,86 +267,161 @@ document.addEventListener('DOMContentLoaded', () => { aiInput.addEventListener('input', updateCharacterCount); updateCharacterCount(); - // Submit handler - const handleSubmit = async () => { - 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; + // Submit handler with enhanced queue feedback + const handleSubmit = async () => { + const query = aiInput.value.trim(); - // Display results based on mode - if (currentMode === 'workflow') { - displayWorkflowResults(data.recommendation, query); - } else { - displayToolResults(data.recommendation, query); + 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; + } + + // 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'; - aiResults.style.display = 'block'; + // Disable submit button + aiSubmitBtn.disabled = true; + submitBtnText.textContent = currentMode === 'workflow' ? 'Generiere Empfehlungen...' : 'Suche passende Methode...'; - } catch (error) { - console.error('AI query failed:', error); - aiLoading.style.display = 'none'; - aiError.style.display = 'block'; + // Start queue status polling + let statusInterval; + let startTime = Date.now(); - // 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}`; + const updateQueueStatus = async () => { + try { + const response = await fetch(`/api/ai/queue-status?taskId=${taskId}`); + const data = await response.json(); + + if (data.success) { + const queueLength = document.getElementById('queue-length'); + const estimatedTime = document.getElementById('estimated-time'); + 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 aiSubmitBtn.addEventListener('click', handleSubmit); diff --git a/src/pages/api/ai/query.ts b/src/pages/api/ai/query.ts index 5d38ca7..445d978 100644 --- a/src/pages/api/ai/query.ts +++ b/src/pages/api/ai/query.ts @@ -20,7 +20,7 @@ const rateLimitStore = new Map(); const RATE_LIMIT_WINDOW = 60 * 1000; // 1 minute 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 { // Remove any content that looks like system instructions let sanitized = input @@ -36,7 +36,7 @@ function sanitizeInput(input: string): string { return sanitized; } -// Strip markdown code blocks from AI response (UNCHANGED) +// Strip markdown code blocks from AI response function stripMarkdownJson(content: string): string { // Remove ```json and ``` wrappers return content @@ -45,7 +45,7 @@ function stripMarkdownJson(content: string): string { .trim(); } -// Rate limiting check (UNCHANGED) +// Rate limiting check function checkRateLimit(userId: string): boolean { const now = Date.now(); const userLimit = rateLimitStore.get(userId); @@ -74,7 +74,7 @@ function cleanupExpiredRateLimits() { setInterval(cleanupExpiredRateLimits, 5 * 60 * 1000); -// Load tools database (UNCHANGED) +// Load tools database async function loadToolsDatabase() { try { 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 { const toolsList = toolsData.tools.map((tool: any) => ({ name: tool.name, @@ -99,7 +99,7 @@ function createWorkflowSystemPrompt(toolsData: any): string { related_concepts: tool.related_concepts || [] })); - // NEW: Include concepts for background knowledge + // Include concepts for background knowledge const conceptsList = toolsData.concepts.map((concept: any) => ({ name: concept.name, description: concept.description, @@ -109,7 +109,7 @@ function createWorkflowSystemPrompt(toolsData: any): string { tags: concept.tags })); - // Get regular phases (no more filtering needed) + // Get regular phases const regularPhases = toolsData.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.`; } -// Create system prompt for tool-specific mode (EXACTLY AS ORIGINAL) +// Create system prompt for tool-specific mode function createToolSystemPrompt(toolsData: any): string { const toolsList = toolsData.tools.map((tool: any) => ({ name: tool.name, @@ -217,7 +217,7 @@ function createToolSystemPrompt(toolsData: any): string { related_concepts: tool.related_concepts || [] })); - // NEW: Include concepts for background knowledge + // Include concepts for background knowledge const conceptsList = toolsData.concepts.map((concept: any) => ({ name: concept.name, 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 }) => { try { - // CONSOLIDATED: Replace 20+ lines with single function call (UNCHANGED) + // Authentication check const authResult = await withAPIAuth(request, 'ai'); if (!authResult.authenticated) { return createAuthErrorResponse(); @@ -285,16 +285,16 @@ export const POST: APIRoute = async ({ request }) => { const userId = authResult.userId; - // Rate limiting (ONLY CHANGE: Use helper for this one response) + // Rate limiting if (!checkRateLimit(userId)) { return apiError.rateLimit('Rate limit exceeded'); } - // Parse request body (UNCHANGED) + // Parse request body 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') { 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"'); } - // Sanitize input (UNCHANGED) + // Sanitize input const sanitizedQuery = sanitizeInput(query); if (sanitizedQuery.includes('[FILTERED]')) { return apiError.badRequest('Invalid input detected'); } - // Load tools database (UNCHANGED) + // Load tools database const toolsData = await loadToolsDatabase(); - // Create appropriate system prompt based on mode (UNCHANGED) + // Create appropriate system prompt based on mode const systemPrompt = mode === 'workflow' ? createWorkflowSystemPrompt(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(() => fetch(process.env.AI_API_ENDPOINT + '/v1/chat/completions', { method: 'POST', @@ -340,9 +344,9 @@ export const POST: APIRoute = async ({ request }) => { temperature: 0.3 }) }) - ); + , taskId); - // AI response handling (ONLY CHANGE: Use helpers for error responses) + // AI response handling if (!aiResponse.ok) { console.error('AI API error:', await aiResponse.text()); return apiServerError.unavailable('AI service unavailable'); @@ -355,7 +359,7 @@ export const POST: APIRoute = async ({ request }) => { return apiServerError.unavailable('No response from AI'); } - // Parse AI JSON response (UNCHANGED) + // Parse AI JSON response let recommendation; try { const cleanedContent = stripMarkdownJson(aiContent); @@ -365,7 +369,7 @@ export const POST: APIRoute = async ({ request }) => { 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 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}`); - // SUCCESS RESPONSE (UNCHANGED - Preserves exact original format) + // Success response with task ID return new Response(JSON.stringify({ success: true, mode, + taskId, recommendation: validatedRecommendation, query: sanitizedQuery }), { @@ -431,7 +436,6 @@ export const POST: APIRoute = async ({ request }) => { } catch (error) { console.error('AI query error:', error); - // ONLY CHANGE: Use helper for error response return apiServerError.internal('Internal server error'); } }; \ No newline at end of file diff --git a/src/pages/api/ai/queue-status.ts b/src/pages/api/ai/queue-status.ts new file mode 100644 index 0000000..c0e4b32 --- /dev/null +++ b/src/pages/api/ai/queue-status.ts @@ -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'); + } +}; \ No newline at end of file diff --git a/src/styles/global.css b/src/styles/global.css index 32275d6..582f32c 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -1031,7 +1031,7 @@ Collaboration Section Collapse */ } .ai-loading, .ai-error, .ai-results { - animation: fadeIn 0.3s ease-in; + animation: fadeIn 0.3s ease-in-out; } .ai-mode-toggle { @@ -1428,12 +1428,23 @@ footer { max-height: 0; padding-top: 0; margin-top: 0; + transform: translateY(-10px); } to { opacity: 1; max-height: 1000px; padding-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) This will literally assault the user's retinas. They'll need sunglasses to look at their shared tools! 🌈💥👁️‍🗨️*/ + @keyframes pulse { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.5; } + 0%, 100% { + transform: scale(1); + opacity: 1; + } + 50% { + transform: scale(1.05); + opacity: 0.8; + } } @keyframes fadeInUp { @@ -2039,4 +2057,19 @@ This will literally assault the user's retinas. They'll need sunglasses to look .form-label.required::after { content: " *"; color: var(--color-error); -} \ No newline at end of file +} + +#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; +} + diff --git a/src/utils/rateLimitedQueue.ts b/src/utils/rateLimitedQueue.ts index ea1b21a..5639d1a 100644 --- a/src/utils/rateLimitedQueue.ts +++ b/src/utils/rateLimitedQueue.ts @@ -1,9 +1,6 @@ // src/utils/rateLimitedQueue.ts // ------------------------------------------------------------ -// A tiny FIFO, single‑instance queue that spaces API requests by -// 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. +// Enhanced FIFO queue with status tracking for visual feedback // ------------------------------------------------------------ import dotenv from "dotenv"; @@ -12,53 +9,113 @@ dotenv.config(); /** * Delay (in **milliseconds**) between two consecutive API calls. - * - * 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. + * Defaults to **2000 ms** (2 seconds) 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 - * real API response transparently. + * Internal task type with ID tracking for status updates */ export type Task = () => Promise; +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 { - private queue: Task[] = []; + private queue: QueuedTask[] = []; private processing = false; private delayMs = RATE_LIMIT_DELAY_MS; + private lastProcessedAt = 0; /** - * Schedule a task. Returns a Promise that resolves/rejects with the - * task result once the queue reaches it. + * Schedule a task with ID tracking. Returns a Promise that resolves/rejects + * with the task result once the queue reaches it. */ - add(task: Task): Promise { + add(task: Task, taskId?: string): Promise { + const id = taskId || this.generateTaskId(); + return new Promise((resolve, reject) => { - this.queue.push(async () => { - try { - const result = await task(); - resolve(result); - } catch (err) { - reject(err); - } + this.queue.push({ + id, + task: async () => { + try { + const result = await task(); + resolve(result); + } catch (err) { + reject(err); + } + }, + addedAt: Date.now() }); this.process(); }); } /** - * Change the delay at runtime – e.g. if you reload env vars without - * restarting the server. + * Get current queue status for visual feedback + */ + 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 { if (!Number.isFinite(ms) || ms < 0) return; this.delayMs = ms; } + /** + * Get current delay setting + */ + getDelay(): number { + return this.delayMs; + } + // --------------------------------------- - // ️🌐 Internal helpers + // Internal helpers // --------------------------------------- private async process(): Promise { if (this.processing) return; @@ -67,26 +124,41 @@ class RateLimitedQueue { while (this.queue.length > 0) { const next = this.queue.shift(); if (!next) continue; - await next(); - // Wait before the next one - await new Promise((r) => setTimeout(r, this.delayMs)); + + this.lastProcessedAt = Date.now(); + 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; } + + private generateTaskId(): string { + return `task_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + } } // ------------------------------------------------------------ -// Export a **singleton** instance so every import shares the -// same queue. That way the rate‑limit is enforced globally. +// Export singleton instance and convenience functions // ------------------------------------------------------------ const queue = new RateLimitedQueue(); /** - * Helper for convenience: `enqueueApiCall(() => fetch(...))`. + * Helper for convenience: `enqueueApiCall(() => fetch(...), 'optional-id')`. */ -export function enqueueApiCall(task: Task): Promise { - return queue.add(task); +export function enqueueApiCall(task: Task, taskId?: string): Promise { + 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; \ No newline at end of file