Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 15 additions & 52 deletions archon-ui-main/src/components/settings/OllamaConfigurationPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { Badge } from '../ui/Badge';
import { useToast } from '../../features/shared/hooks/useToast';
import { cn } from '../../lib/utils';
import { credentialsService, OllamaInstance } from '../../services/credentialsService';
import { ollamaService } from '../../services/ollamaService';
import { OllamaModelDiscoveryModal } from './OllamaModelDiscoveryModal';
import type { OllamaInstance as OllamaInstanceType } from './types/OllamaTypes';

Expand Down Expand Up @@ -104,61 +105,23 @@ const OllamaConfigurationPanel: React.FC<OllamaConfigurationPanelProps> = ({
}
};

// Test connection to an Ollama instance with retry logic
// Test connection to an Ollama instance using ollamaService with smart retry logic
const testConnection = async (baseUrl: string, retryCount = 3): Promise<ConnectionTestResult> => {
const maxRetries = retryCount;
let lastError: Error | null = null;

for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const response = await fetch('/api/providers/validate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
provider: 'ollama',
base_url: baseUrl
})
});

if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}

const data = await response.json();

const result = {
isHealthy: data.health_status?.is_available || false,
responseTimeMs: data.health_status?.response_time_ms,
modelsAvailable: data.health_status?.models_available,
error: data.health_status?.error_message
};

// If successful, return immediately
if (result.isHealthy) {
return result;
}

// If not healthy but we got a valid response, still return (but might retry)
lastError = new Error(result.error || 'Instance not available');

} catch (error) {
lastError = error instanceof Error ? error : new Error('Unknown error');
}
try {
const result = await ollamaService.testConnection(baseUrl, retryCount);

// If this wasn't the last attempt, wait before retrying
if (attempt < maxRetries) {
const delayMs = Math.pow(2, attempt - 1) * 1000; // Exponential backoff: 1s, 2s, 4s
await new Promise(resolve => setTimeout(resolve, delayMs));
}
return {
isHealthy: result.isHealthy,
responseTimeMs: result.responseTime,
modelsAvailable: undefined, // Not available from the simple health check
error: result.error
};
} catch (error) {
return {
isHealthy: false,
error: error instanceof Error ? error.message : 'Connection test failed'
};
}

// All retries failed, return error result
return {
isHealthy: false,
error: lastError?.message || 'Connection failed after retries'
};
};

// Handle connection test for a specific instance
Expand Down
30 changes: 9 additions & 21 deletions archon-ui-main/src/features/knowledge/services/knowledgeService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,27 +117,15 @@ export const knowledgeService = {
formData.append("tags", JSON.stringify(metadata.tags));
}

// Use fetch directly for file upload (FormData doesn't work well with our ETag wrapper)
// In test environment, we need absolute URLs
let uploadUrl = "/api/documents/upload";
if (typeof process !== "undefined" && process.env?.NODE_ENV === "test") {
const testHost = process.env?.VITE_HOST || "localhost";
const testPort = process.env?.ARCHON_SERVER_PORT || "8181";
uploadUrl = `http://${testHost}:${testPort}${uploadUrl}`;
}

const response = await fetch(uploadUrl, {
method: "POST",
body: formData,
signal: AbortSignal.timeout(30000), // 30 second timeout for file uploads
});

if (!response.ok) {
const err = await response.json().catch(() => ({}));
throw new APIServiceError(err.error || `HTTP ${response.status}`, "HTTP_ERROR", response.status);
}

return response.json();
// Use API service with proper FormData handling and timeout
return callAPIWithETag<{ success: boolean; progressId: string; message: string; filename: string }>(
"/api/documents/upload",
{
method: "POST",
body: formData,
signal: AbortSignal.timeout(30000), // 30 second timeout for file uploads
},
);
},

/**
Expand Down
75 changes: 51 additions & 24 deletions archon-ui-main/src/features/shared/api/apiClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,14 @@ function buildFullUrl(cleanEndpoint: string): string {
}

/**
* Simple API call function for JSON APIs
* Simple API call function for JSON APIs and FormData uploads
* Browser automatically handles ETags/304s through its HTTP cache
*
* NOTE: This wrapper is designed for JSON-only API calls.
* For file uploads or FormData requests, use fetch() directly.
* Features:
* - Automatic FormData detection (avoids setting Content-Type header)
* - JSON API support with proper Content-Type headers
* - Built-in timeout and error handling
* - ETag/304 optimization through browser HTTP cache
*/
export async function callAPIWithETag<T = unknown>(endpoint: string, options: RequestInit = {}): Promise<T> {
try {
Expand All @@ -48,24 +51,31 @@ export async function callAPIWithETag<T = unknown>(endpoint: string, options: Re
// Construct the full URL
const fullUrl = buildFullUrl(cleanEndpoint);

// Build headers - only set Content-Type for requests with a body
// Detect FormData to avoid setting Content-Type (browser sets multipart/form-data with boundary)
// Guard against environments where FormData is undefined (Node.js, Jest, iframes)
const isFormData = typeof FormData !== "undefined" && options.body instanceof FormData;

// Build headers - normalize and handle Content-Type properly for FormData
// NOTE: We do NOT add If-None-Match headers; the browser handles ETag revalidation automatically
//
// Currently assumes headers are passed as plain objects (Record<string, string>)
// which works for all our current usage. The API doesn't require Accept headers
// since it always returns JSON, and we only set Content-Type when sending data.
const headers: Record<string, string> = {
...((options.headers as Record<string, string>) || {}),
};

// Only set Content-Type for requests that have a body (POST, PUT, PATCH, etc.)
// GET and DELETE requests should not have Content-Type header
const method = options.method?.toUpperCase() || 'GET';
const hasBody = options.body !== undefined && options.body !== null;
if (hasBody && !headers['Content-Type']) {
headers['Content-Type'] = 'application/json';
// Normalize headers to support Headers instances, [string, string][] tuples, and plain objects
const headersObj = new Headers(options.headers as HeadersInit | undefined);

// Only set Accept header if not already provided by caller (preserves caller-provided Accept headers)
if (!headersObj.has("Accept")) {
headersObj.set("Accept", "application/json");
}

if (isFormData) {
// For FormData, remove any Content-Type header to let browser set multipart/form-data with boundary
headersObj.delete("Content-Type");
} else if (!headersObj.has("Content-Type") && options.body != null) {
// Only set Content-Type if not already provided and body is present
headersObj.set("Content-Type", "application/json");
}

// Preserve Headers instance instead of converting to Record
const headers = headersObj;

// Make the request with timeout
// NOTE: Increased to 20s due to database performance issues with large DELETE operations
// Root cause: Sequential scan on crawled_pages table when deleting sources with 7K+ rows
Expand Down Expand Up @@ -104,15 +114,32 @@ export async function callAPIWithETag<T = unknown>(endpoint: string, options: Re
return undefined as T;
}

// Parse response data
const result = await response.json();
// Check content type before parsing as JSON
const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
if (contentType.includes("application/json") || contentType.includes("+json")) {
// Parse JSON response
const result = await response.json();
if (result && typeof result === "object" && "error" in result && result.error) {
throw new APIServiceError(result.error as string, "API_ERROR", response.status);
}
return result as T;
}

// Check for API errors
if (result.error) {
throw new APIServiceError(result.error, "API_ERROR", response.status);
// Handle binary responses (PDFs, images, octet-stream)
if (
contentType.includes("application/octet-stream") ||
contentType.includes("application/pdf") ||
contentType.startsWith("image/") ||
contentType.includes("video/") ||
contentType.includes("audio/")
) {
const blob = await response.blob();
return blob as unknown as T;
}

return result as T;
// Handle non-JSON or empty body responses
const text = await response.text().catch(() => "");
return text ? (text as unknown as T) : (undefined as T);
} catch (error) {
if (error instanceof APIServiceError) {
throw error;
Expand Down
Loading