diff --git a/PRPs/ai_docs/API_NAMING_CONVENTIONS.md b/PRPs/ai_docs/API_NAMING_CONVENTIONS.md
index 5688912b..2135bc8d 100644
--- a/PRPs/ai_docs/API_NAMING_CONVENTIONS.md
+++ b/PRPs/ai_docs/API_NAMING_CONVENTIONS.md
@@ -198,7 +198,7 @@ Database values used directly - no mapping layers:
- Operation statuses: `"pending"`, `"processing"`, `"completed"`, `"failed"`
### Time Constants
-**Location**: `archon-ui-main/src/features/shared/queryPatterns.ts`
+**Location**: `archon-ui-main/src/features/shared/config/queryPatterns.ts`
- `STALE_TIMES.instant` - 0ms
- `STALE_TIMES.realtime` - 3 seconds
- `STALE_TIMES.frequent` - 5 seconds
diff --git a/PRPs/ai_docs/ARCHITECTURE.md b/PRPs/ai_docs/ARCHITECTURE.md
index a5c0ae7a..eb3a7f81 100644
--- a/PRPs/ai_docs/ARCHITECTURE.md
+++ b/PRPs/ai_docs/ARCHITECTURE.md
@@ -88,8 +88,8 @@ Pattern: `{METHOD} /api/{resource}/{id?}/{sub-resource?}`
### Data Fetching
**Core**: TanStack Query v5
-**Configuration**: `archon-ui-main/src/features/shared/queryClient.ts`
-**Patterns**: `archon-ui-main/src/features/shared/queryPatterns.ts`
+**Configuration**: `archon-ui-main/src/features/shared/config/queryClient.ts`
+**Patterns**: `archon-ui-main/src/features/shared/config/queryPatterns.ts`
### State Management
- **Server State**: TanStack Query
@@ -139,7 +139,7 @@ TanStack Query is the single source of truth. No separate state management neede
No translation layers. Database values (e.g., `"todo"`, `"doing"`) used directly in UI.
### Browser-Native Caching
-ETags handled by browser, not JavaScript. See `archon-ui-main/src/features/shared/apiWithEtag.ts`.
+ETags handled by browser, not JavaScript. See `archon-ui-main/src/features/shared/api/apiClient.ts`.
## Deployment
diff --git a/PRPs/ai_docs/DATA_FETCHING_ARCHITECTURE.md b/PRPs/ai_docs/DATA_FETCHING_ARCHITECTURE.md
index d8a9822b..8d1bbb62 100644
--- a/PRPs/ai_docs/DATA_FETCHING_ARCHITECTURE.md
+++ b/PRPs/ai_docs/DATA_FETCHING_ARCHITECTURE.md
@@ -8,7 +8,7 @@ Archon uses **TanStack Query v5** for all data fetching, caching, and synchroniz
### 1. Query Client Configuration
-**Location**: `archon-ui-main/src/features/shared/queryClient.ts`
+**Location**: `archon-ui-main/src/features/shared/config/queryClient.ts`
Centralized QueryClient with:
@@ -30,7 +30,7 @@ Visibility-aware polling that:
### 3. Query Patterns
-**Location**: `archon-ui-main/src/features/shared/queryPatterns.ts`
+**Location**: `archon-ui-main/src/features/shared/config/queryPatterns.ts`
Shared constants:
@@ -64,7 +64,7 @@ Standard pattern across all features:
### ETag Support
-**Location**: `archon-ui-main/src/features/shared/apiWithEtag.ts`
+**Location**: `archon-ui-main/src/features/shared/api/apiClient.ts`
ETag implementation:
@@ -83,7 +83,7 @@ Backend endpoints follow RESTful patterns:
## Optimistic Updates
-**Utilities**: `archon-ui-main/src/features/shared/optimistic.ts`
+**Utilities**: `archon-ui-main/src/features/shared/utils/optimistic.ts`
All mutations use nanoid-based optimistic updates:
@@ -105,7 +105,7 @@ Polling intervals are defined in each feature's query hooks. See actual implemen
- **Progress**: `archon-ui-main/src/features/progress/hooks/useProgressQueries.ts`
- **MCP**: `archon-ui-main/src/features/mcp/hooks/useMcpQueries.ts`
-Standard intervals from `archon-ui-main/src/features/shared/queryPatterns.ts`:
+Standard intervals from `archon-ui-main/src/features/shared/config/queryPatterns.ts`:
- `STALE_TIMES.instant`: 0ms (always fresh)
- `STALE_TIMES.frequent`: 5 seconds (frequently changing data)
- `STALE_TIMES.normal`: 30 seconds (standard cache)
diff --git a/PRPs/ai_docs/ETAG_IMPLEMENTATION.md b/PRPs/ai_docs/ETAG_IMPLEMENTATION.md
index 70e4ce63..8560dbb5 100644
--- a/PRPs/ai_docs/ETAG_IMPLEMENTATION.md
+++ b/PRPs/ai_docs/ETAG_IMPLEMENTATION.md
@@ -17,7 +17,7 @@ The backend generates ETags for API responses:
- Returns `304 Not Modified` when ETags match
### Frontend Handling
-**Location**: `archon-ui-main/src/features/shared/apiWithEtag.ts`
+**Location**: `archon-ui-main/src/features/shared/api/apiClient.ts`
The frontend relies on browser-native HTTP caching:
- Browser automatically sends `If-None-Match` headers with cached ETags
@@ -28,7 +28,7 @@ The frontend relies on browser-native HTTP caching:
#### Browser vs Non-Browser Behavior
- **Standard Browsers**: Per the Fetch spec, a 304 response freshens the HTTP cache and returns the cached body to JavaScript
- **Non-Browser Runtimes** (React Native, custom fetch): May surface 304 with empty body to JavaScript
-- **Client Fallback**: The `apiWithEtag.ts` implementation handles both scenarios, ensuring consistent behavior across environments
+- **Client Fallback**: The `apiClient.ts` implementation handles both scenarios, ensuring consistent behavior across environments
## Implementation Details
@@ -81,8 +81,8 @@ Unlike previous implementations, the current approach:
### Configuration
Cache behavior is controlled through TanStack Query's `staleTime`:
-- See `archon-ui-main/src/features/shared/queryPatterns.ts` for standard times
-- See `archon-ui-main/src/features/shared/queryClient.ts` for global configuration
+- See `archon-ui-main/src/features/shared/config/queryPatterns.ts` for standard times
+- See `archon-ui-main/src/features/shared/config/queryClient.ts` for global configuration
## Performance Benefits
@@ -100,7 +100,7 @@ Cache behavior is controlled through TanStack Query's `staleTime`:
### Core Implementation
- **Backend Utilities**: `python/src/server/utils/etag_utils.py`
-- **Frontend Client**: `archon-ui-main/src/features/shared/apiWithEtag.ts`
+- **Frontend Client**: `archon-ui-main/src/features/shared/api/apiClient.ts`
- **Tests**: `python/tests/server/utils/test_etag_utils.py`
### Usage Examples
diff --git a/PRPs/ai_docs/QUERY_PATTERNS.md b/PRPs/ai_docs/QUERY_PATTERNS.md
index 3c3204db..499daa36 100644
--- a/PRPs/ai_docs/QUERY_PATTERNS.md
+++ b/PRPs/ai_docs/QUERY_PATTERNS.md
@@ -5,7 +5,7 @@ This guide documents the standardized patterns for using TanStack Query v5 in th
## Core Principles
1. **Feature Ownership**: Each feature owns its query keys in `{feature}/hooks/use{Feature}Queries.ts`
-2. **Consistent Patterns**: Always use shared patterns from `shared/queryPatterns.ts`
+2. **Consistent Patterns**: Always use shared patterns from `shared/config/queryPatterns.ts`
3. **No Hardcoded Values**: Never hardcode stale times or disabled keys
4. **Mirror Backend API**: Query keys should exactly match backend API structure
@@ -49,7 +49,7 @@ export const taskKeys = {
### Import Required Patterns
```typescript
-import { DISABLED_QUERY_KEY, STALE_TIMES } from "@/features/shared/queryPatterns";
+import { DISABLED_QUERY_KEY, STALE_TIMES } from "@/features/shared/config/queryPatterns";
```
### Disabled Queries
@@ -106,7 +106,7 @@ export function useFeatureDetail(id: string | undefined) {
## Mutations with Optimistic Updates
```typescript
-import { createOptimisticEntity, replaceOptimisticEntity } from "@/features/shared/optimistic";
+import { createOptimisticEntity, replaceOptimisticEntity } from "@/features/shared/utils/optimistic";
export function useCreateFeature() {
const queryClient = useQueryClient();
@@ -161,7 +161,7 @@ vi.mock("../../services", () => ({
}));
// Mock shared patterns with ALL values
-vi.mock("../../../shared/queryPatterns", () => ({
+vi.mock("../../../shared/config/queryPatterns", () => ({
DISABLED_QUERY_KEY: ["disabled"] as const,
STALE_TIMES: {
instant: 0,
diff --git a/PRPs/ai_docs/optimistic_updates.md b/PRPs/ai_docs/optimistic_updates.md
index 7be11ea6..219b7866 100644
--- a/PRPs/ai_docs/optimistic_updates.md
+++ b/PRPs/ai_docs/optimistic_updates.md
@@ -3,7 +3,7 @@
## Core Architecture
### Shared Utilities Module
-**Location**: `src/features/shared/optimistic.ts`
+**Location**: `src/features/shared/utils/optimistic.ts`
Provides type-safe utilities for managing optimistic state across all features:
- `createOptimisticId()` - Generates stable UUIDs using nanoid
@@ -73,13 +73,13 @@ Reusable component showing:
- Uses `createOptimisticId()` directly for progress tracking
### Toasts
-- **Location**: `src/features/ui/hooks/useToast.ts:43`
+- **Location**: `src/features/shared/hooks/useToast.ts:43`
- Uses `createOptimisticId()` for unique toast IDs
## Testing
### Unit Tests
-**Location**: `src/features/shared/optimistic.test.ts`
+**Location**: `src/features/shared/utils/tests/optimistic.test.ts`
Covers all utility functions with 8 test cases:
- ID uniqueness and format validation
diff --git a/README.md b/README.md
index d0440f1c..90f5f784 100644
--- a/README.md
+++ b/README.md
@@ -206,14 +206,18 @@ To upgrade Archon to the latest version:
git pull
```
-2. **Check for migrations**: Look in the `migration/` folder for any SQL files newer than your last update. Check the file created dates to determine if you need to run them. You can run these in the SQL editor just like you did when you first set up Archon. We are also working on a way to make handling these migrations automatic!
-
-3. **Rebuild and restart**:
+2. **Rebuild and restart containers**:
```bash
docker compose up -d --build
```
+ This rebuilds containers with the latest code and restarts all services.
-This is the same command used for initial setup - it rebuilds containers with the latest code and restarts services.
+3. **Check for database migrations**:
+ - Open the Archon settings in your browser: [http://localhost:3737/settings](http://localhost:3737/settings)
+ - Navigate to the **Database Migrations** section
+ - If there are pending migrations, the UI will display them with clear instructions
+ - Click on each migration to view and copy the SQL
+ - Run the SQL scripts in your Supabase SQL editor in the order shown
## What's Included
diff --git a/archon-ui-main/package-lock.json b/archon-ui-main/package-lock.json
index d37567a0..245127d5 100644
--- a/archon-ui-main/package-lock.json
+++ b/archon-ui-main/package-lock.json
@@ -34,6 +34,7 @@
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
"react-dom": "^18.3.1",
+ "react-icons": "^5.5.0",
"react-markdown": "^10.1.0",
"react-router-dom": "^6.26.2",
"tailwind-merge": "latest",
@@ -10038,6 +10039,15 @@
"react": "^16.8.0 || ^17 || ^18 || ^19"
}
},
+ "node_modules/react-icons": {
+ "version": "5.5.0",
+ "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz",
+ "integrity": "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "*"
+ }
+ },
"node_modules/react-is": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
diff --git a/archon-ui-main/package.json b/archon-ui-main/package.json
index 9856d9d0..bf4dcdb8 100644
--- a/archon-ui-main/package.json
+++ b/archon-ui-main/package.json
@@ -54,6 +54,7 @@
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
"react-dom": "^18.3.1",
+ "react-icons": "^5.5.0",
"react-markdown": "^10.1.0",
"react-router-dom": "^6.26.2",
"tailwind-merge": "latest",
diff --git a/archon-ui-main/src/App.tsx b/archon-ui-main/src/App.tsx
index 7404c610..36e0d375 100644
--- a/archon-ui-main/src/App.tsx
+++ b/archon-ui-main/src/App.tsx
@@ -2,7 +2,7 @@ import { useState, useEffect } from 'react';
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import { QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
-import { queryClient } from './features/shared/queryClient';
+import { queryClient } from './features/shared/config/queryClient';
import { KnowledgeBasePage } from './pages/KnowledgeBasePage';
import { SettingsPage } from './pages/SettingsPage';
import { MCPPage } from './pages/MCPPage';
diff --git a/archon-ui-main/src/components/bug-report/BugReportModal.tsx b/archon-ui-main/src/components/bug-report/BugReportModal.tsx
index 69b40262..2bfcb007 100644
--- a/archon-ui-main/src/components/bug-report/BugReportModal.tsx
+++ b/archon-ui-main/src/components/bug-report/BugReportModal.tsx
@@ -5,7 +5,7 @@ import { Button } from "../ui/Button";
import { Input } from "../ui/Input";
import { Card } from "../ui/Card";
import { Select } from "../ui/Select";
-import { useToast } from "../../features/ui/hooks/useToast";
+import { useToast } from "../../features/shared/hooks/useToast";
import {
bugReportService,
BugContext,
diff --git a/archon-ui-main/src/components/layout/MainLayout.tsx b/archon-ui-main/src/components/layout/MainLayout.tsx
index da0b2696..73fcc1de 100644
--- a/archon-ui-main/src/components/layout/MainLayout.tsx
+++ b/archon-ui-main/src/components/layout/MainLayout.tsx
@@ -2,7 +2,7 @@ import { AlertCircle, WifiOff } from "lucide-react";
import type React from "react";
import { useEffect } from "react";
import { useLocation, useNavigate } from "react-router-dom";
-import { useToast } from "../../features/ui/hooks/useToast";
+import { useToast } from "../../features/shared/hooks/useToast";
import { cn } from "../../lib/utils";
import { credentialsService } from "../../services/credentialsService";
import { isLmConfigured } from "../../utils/onboarding";
diff --git a/archon-ui-main/src/components/layout/hooks/useBackendHealth.ts b/archon-ui-main/src/components/layout/hooks/useBackendHealth.ts
index 626d23b6..59e9ccfa 100644
--- a/archon-ui-main/src/components/layout/hooks/useBackendHealth.ts
+++ b/archon-ui-main/src/components/layout/hooks/useBackendHealth.ts
@@ -1,6 +1,6 @@
import { useQuery } from "@tanstack/react-query";
-import { callAPIWithETag } from "../../../features/shared/apiWithEtag";
-import { createRetryLogic, STALE_TIMES } from "../../../features/shared/queryPatterns";
+import { callAPIWithETag } from "../../../features/shared/api/apiClient";
+import { createRetryLogic, STALE_TIMES } from "../../../features/shared/config/queryPatterns";
import type { HealthResponse } from "../types";
/**
diff --git a/archon-ui-main/src/components/onboarding/ProviderStep.tsx b/archon-ui-main/src/components/onboarding/ProviderStep.tsx
index 546be5f7..1beae073 100644
--- a/archon-ui-main/src/components/onboarding/ProviderStep.tsx
+++ b/archon-ui-main/src/components/onboarding/ProviderStep.tsx
@@ -3,7 +3,7 @@ import { Key, ExternalLink, Save, Loader } from "lucide-react";
import { Input } from "../ui/Input";
import { Button } from "../ui/Button";
import { Select } from "../ui/Select";
-import { useToast } from "../../features/ui/hooks/useToast";
+import { useToast } from "../../features/shared/hooks/useToast";
import { credentialsService } from "../../services/credentialsService";
interface ProviderStepProps {
diff --git a/archon-ui-main/src/components/settings/APIKeysSection.tsx b/archon-ui-main/src/components/settings/APIKeysSection.tsx
index 231e1125..0d926014 100644
--- a/archon-ui-main/src/components/settings/APIKeysSection.tsx
+++ b/archon-ui-main/src/components/settings/APIKeysSection.tsx
@@ -4,7 +4,7 @@ import { Input } from '../ui/Input';
import { Button } from '../ui/Button';
import { Card } from '../ui/Card';
import { credentialsService, Credential } from '../../services/credentialsService';
-import { useToast } from '../../features/ui/hooks/useToast';
+import { useToast } from '../../features/shared/hooks/useToast';
interface CustomCredential {
key: string;
diff --git a/archon-ui-main/src/components/settings/CodeExtractionSettings.tsx b/archon-ui-main/src/components/settings/CodeExtractionSettings.tsx
index 2e7d40fb..2dd322df 100644
--- a/archon-ui-main/src/components/settings/CodeExtractionSettings.tsx
+++ b/archon-ui-main/src/components/settings/CodeExtractionSettings.tsx
@@ -3,7 +3,7 @@ import { Code, Check, Save, Loader } from 'lucide-react';
import { Card } from '../ui/Card';
import { Input } from '../ui/Input';
import { Button } from '../ui/Button';
-import { useToast } from '../../features/ui/hooks/useToast';
+import { useToast } from '../../features/shared/hooks/useToast';
import { credentialsService } from '../../services/credentialsService';
interface CodeExtractionSettingsProps {
diff --git a/archon-ui-main/src/components/settings/FeaturesSection.tsx b/archon-ui-main/src/components/settings/FeaturesSection.tsx
index bb5b4e99..1f410baf 100644
--- a/archon-ui-main/src/components/settings/FeaturesSection.tsx
+++ b/archon-ui-main/src/components/settings/FeaturesSection.tsx
@@ -4,7 +4,7 @@ import { Switch } from '@/features/ui/primitives/switch';
import { Card } from '../ui/Card';
import { useTheme } from '../../contexts/ThemeContext';
import { credentialsService } from '../../services/credentialsService';
-import { useToast } from '../../features/ui/hooks/useToast';
+import { useToast } from '../../features/shared/hooks/useToast';
import { serverHealthService } from '../../services/serverHealthService';
import { useSettings } from '../../contexts/SettingsContext';
diff --git a/archon-ui-main/src/components/settings/IDEGlobalRules.tsx b/archon-ui-main/src/components/settings/IDEGlobalRules.tsx
index 7f65ce4b..b4e29ef9 100644
--- a/archon-ui-main/src/components/settings/IDEGlobalRules.tsx
+++ b/archon-ui-main/src/components/settings/IDEGlobalRules.tsx
@@ -2,7 +2,7 @@ import { useState } from 'react';
import { FileCode, Copy, Check } from 'lucide-react';
import { Card } from '../ui/Card';
import { Button } from '../ui/Button';
-import { useToast } from '../../features/ui/hooks/useToast';
+import { useToast } from '../../features/shared/hooks/useToast';
import { copyToClipboard } from '../../features/shared/utils/clipboard';
type RuleType = 'claude' | 'universal';
diff --git a/archon-ui-main/src/components/settings/OllamaConfigurationPanel.tsx b/archon-ui-main/src/components/settings/OllamaConfigurationPanel.tsx
index c4a9e267..4da6f9a0 100644
--- a/archon-ui-main/src/components/settings/OllamaConfigurationPanel.tsx
+++ b/archon-ui-main/src/components/settings/OllamaConfigurationPanel.tsx
@@ -3,7 +3,7 @@ import { Card } from '../ui/Card';
import { Button } from '../ui/Button';
import { Input } from '../ui/Input';
import { Badge } from '../ui/Badge';
-import { useToast } from '../../features/ui/hooks/useToast';
+import { useToast } from '../../features/shared/hooks/useToast';
import { cn } from '../../lib/utils';
import { credentialsService, OllamaInstance } from '../../services/credentialsService';
import { OllamaModelDiscoveryModal } from './OllamaModelDiscoveryModal';
diff --git a/archon-ui-main/src/components/settings/OllamaInstanceHealthIndicator.tsx b/archon-ui-main/src/components/settings/OllamaInstanceHealthIndicator.tsx
index c65b2159..4c646dfa 100644
--- a/archon-ui-main/src/components/settings/OllamaInstanceHealthIndicator.tsx
+++ b/archon-ui-main/src/components/settings/OllamaInstanceHealthIndicator.tsx
@@ -3,7 +3,7 @@ import { Badge } from '../ui/Badge';
import { Button } from '../ui/Button';
import { Card } from '../ui/Card';
import { cn } from '../../lib/utils';
-import { useToast } from '../../features/ui/hooks/useToast';
+import { useToast } from '../../features/shared/hooks/useToast';
import { ollamaService } from '../../services/ollamaService';
import type { HealthIndicatorProps } from './types/OllamaTypes';
diff --git a/archon-ui-main/src/components/settings/OllamaModelDiscoveryModal.tsx b/archon-ui-main/src/components/settings/OllamaModelDiscoveryModal.tsx
index 7525f1bd..53a698b5 100644
--- a/archon-ui-main/src/components/settings/OllamaModelDiscoveryModal.tsx
+++ b/archon-ui-main/src/components/settings/OllamaModelDiscoveryModal.tsx
@@ -13,7 +13,7 @@ import { Button } from '../ui/Button';
import { Input } from '../ui/Input';
import { Badge } from '../ui/Badge';
import { Card } from '../ui/Card';
-import { useToast } from '../../features/ui/hooks/useToast';
+import { useToast } from '../../features/shared/hooks/useToast';
import { ollamaService, type OllamaModel, type ModelDiscoveryResponse } from '../../services/ollamaService';
import type { OllamaInstance, ModelSelectionState } from './types/OllamaTypes';
diff --git a/archon-ui-main/src/components/settings/OllamaModelSelectionModal.tsx b/archon-ui-main/src/components/settings/OllamaModelSelectionModal.tsx
index 9933526a..3c539f9c 100644
--- a/archon-ui-main/src/components/settings/OllamaModelSelectionModal.tsx
+++ b/archon-ui-main/src/components/settings/OllamaModelSelectionModal.tsx
@@ -3,7 +3,7 @@ import ReactDOM from 'react-dom';
import { X, Search, RotateCcw, Zap, Server, Eye, Settings, Download, Box } from 'lucide-react';
import { Button } from '../ui/Button';
import { Input } from '../ui/Input';
-import { useToast } from '../../features/ui/hooks/useToast';
+import { useToast } from '../../features/shared/hooks/useToast';
interface ContextInfo {
current?: number;
diff --git a/archon-ui-main/src/components/settings/RAGSettings.tsx b/archon-ui-main/src/components/settings/RAGSettings.tsx
index 8cb721a7..62739fc7 100644
--- a/archon-ui-main/src/components/settings/RAGSettings.tsx
+++ b/archon-ui-main/src/components/settings/RAGSettings.tsx
@@ -1,14 +1,143 @@
-import React, { useState, useEffect, useRef } from 'react';
-import { Settings, Check, Save, Loader, ChevronDown, ChevronUp, Zap, Database, Trash2 } from 'lucide-react';
+import React, { useState, useEffect, useRef, useCallback } from 'react';
+import { Settings, Check, Save, Loader, ChevronDown, ChevronUp, Zap, Database, Trash2, Cog } from 'lucide-react';
import { Card } from '../ui/Card';
import { Input } from '../ui/Input';
import { Select } from '../ui/Select';
import { Button } from '../ui/Button';
-import { useToast } from '../../features/ui/hooks/useToast';
+import { Button as GlowButton } from '../../features/ui/primitives/button';
+import { LuBrainCircuit } from 'react-icons/lu';
+import { PiDatabaseThin } from 'react-icons/pi';
+import { useToast } from '../../features/shared/hooks/useToast';
import { credentialsService } from '../../services/credentialsService';
import OllamaModelDiscoveryModal from './OllamaModelDiscoveryModal';
import OllamaModelSelectionModal from './OllamaModelSelectionModal';
+type ProviderKey = 'openai' | 'google' | 'ollama' | 'anthropic' | 'grok' | 'openrouter';
+
+// Providers that support embedding models
+const EMBEDDING_CAPABLE_PROVIDERS: ProviderKey[] = ['openai', 'google', 'ollama'];
+
+interface ProviderModels {
+ chatModel: string;
+ embeddingModel: string;
+}
+
+type ProviderModelMap = Record;
+
+// Provider model persistence helpers
+const PROVIDER_MODELS_KEY = 'archon_provider_models';
+
+const getDefaultModels = (provider: ProviderKey): ProviderModels => {
+ const chatDefaults: Record = {
+ openai: 'gpt-4o-mini',
+ anthropic: 'claude-3-5-sonnet-20241022',
+ google: 'gemini-1.5-flash',
+ grok: 'grok-3-mini', // Updated to use grok-3-mini as default
+ openrouter: 'openai/gpt-4o-mini',
+ ollama: 'llama3:8b'
+ };
+
+ const embeddingDefaults: Record = {
+ openai: 'text-embedding-3-small',
+ anthropic: 'text-embedding-3-small', // Fallback to OpenAI
+ google: 'text-embedding-004',
+ grok: 'text-embedding-3-small', // Fallback to OpenAI
+ openrouter: 'text-embedding-3-small',
+ ollama: 'nomic-embed-text'
+ };
+
+ return {
+ chatModel: chatDefaults[provider],
+ embeddingModel: embeddingDefaults[provider]
+ };
+};
+
+const saveProviderModels = (providerModels: ProviderModelMap): void => {
+ try {
+ localStorage.setItem(PROVIDER_MODELS_KEY, JSON.stringify(providerModels));
+ } catch (error) {
+ console.error('Failed to save provider models:', error);
+ }
+};
+
+const loadProviderModels = (): ProviderModelMap => {
+ try {
+ const saved = localStorage.getItem(PROVIDER_MODELS_KEY);
+ if (saved) {
+ return JSON.parse(saved);
+ }
+ } catch (error) {
+ console.error('Failed to load provider models:', error);
+ }
+
+ // Return defaults for all providers if nothing saved
+ const providers: ProviderKey[] = ['openai', 'google', 'openrouter', 'ollama', 'anthropic', 'grok'];
+ const defaultModels: ProviderModelMap = {} as ProviderModelMap;
+
+ providers.forEach(provider => {
+ defaultModels[provider] = getDefaultModels(provider);
+ });
+
+ return defaultModels;
+};
+
+// Static color styles mapping (prevents Tailwind JIT purging)
+const colorStyles: Record = {
+ openai: 'border-green-500 bg-green-500/10',
+ google: 'border-blue-500 bg-blue-500/10',
+ openrouter: 'border-cyan-500 bg-cyan-500/10',
+ ollama: 'border-purple-500 bg-purple-500/10',
+ anthropic: 'border-orange-500 bg-orange-500/10',
+ grok: 'border-yellow-500 bg-yellow-500/10',
+};
+
+const providerWarningAlertStyle = 'bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-800 text-yellow-800 dark:text-yellow-300';
+const providerErrorAlertStyle = 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800 text-red-800 dark:text-red-300';
+const providerMissingAlertStyle = providerErrorAlertStyle;
+
+const providerDisplayNames: Record = {
+ openai: 'OpenAI',
+ google: 'Google',
+ openrouter: 'OpenRouter',
+ ollama: 'Ollama',
+ anthropic: 'Anthropic',
+ grok: 'Grok',
+};
+
+const isProviderKey = (value: unknown): value is ProviderKey =>
+ typeof value === 'string' && ['openai', 'google', 'openrouter', 'ollama', 'anthropic', 'grok'].includes(value);
+
+// Default base URL for Ollama instances when not explicitly configured
+const DEFAULT_OLLAMA_URL = 'http://host.docker.internal:11434/v1';
+
+const PROVIDER_CREDENTIAL_KEYS = [
+ 'OPENAI_API_KEY',
+ 'GOOGLE_API_KEY',
+ 'ANTHROPIC_API_KEY',
+ 'OPENROUTER_API_KEY',
+ 'GROK_API_KEY',
+] as const;
+
+type ProviderCredentialKey = typeof PROVIDER_CREDENTIAL_KEYS[number];
+
+const CREDENTIAL_PROVIDER_MAP: Record = {
+ OPENAI_API_KEY: 'openai',
+ GOOGLE_API_KEY: 'google',
+ ANTHROPIC_API_KEY: 'anthropic',
+ OPENROUTER_API_KEY: 'openrouter',
+ GROK_API_KEY: 'grok',
+};
+
+const normalizeBaseUrl = (url?: string | null): string | null => {
+ if (!url) return null;
+ const trimmed = url.trim();
+ if (!trimmed) return null;
+
+ let normalized = trimmed.replace(/\/+$/, '');
+ normalized = normalized.replace(/\/v1$/i, '');
+ return normalized || null;
+};
+
interface RAGSettingsProps {
ragSettings: {
MODEL_CHOICE: string;
@@ -19,8 +148,11 @@ interface RAGSettingsProps {
USE_RERANKING: boolean;
LLM_PROVIDER?: string;
LLM_BASE_URL?: string;
+ LLM_INSTANCE_NAME?: string;
EMBEDDING_MODEL?: string;
+ EMBEDDING_PROVIDER?: string;
OLLAMA_EMBEDDING_URL?: string;
+ OLLAMA_EMBEDDING_INSTANCE_NAME?: string;
// Crawling Performance Settings
CRAWL_BATCH_SIZE?: number;
CRAWL_MAX_CONCURRENT?: number;
@@ -49,6 +181,7 @@ export const RAGSettings = ({
const [showCrawlingSettings, setShowCrawlingSettings] = useState(false);
const [showStorageSettings, setShowStorageSettings] = useState(false);
const [showModelDiscoveryModal, setShowModelDiscoveryModal] = useState(false);
+ const [showOllamaConfig, setShowOllamaConfig] = useState(false);
// Edit modals state
const [showEditLLMModal, setShowEditLLMModal] = useState(false);
@@ -57,7 +190,20 @@ export const RAGSettings = ({
// Model selection modals state
const [showLLMModelSelectionModal, setShowLLMModelSelectionModal] = useState(false);
const [showEmbeddingModelSelectionModal, setShowEmbeddingModelSelectionModal] = useState(false);
-
+
+ // Provider-specific model persistence state
+ const [providerModels, setProviderModels] = useState(() => loadProviderModels());
+
+ // Independent provider selection state
+ const [chatProvider, setChatProvider] = useState(() =>
+ (ragSettings.LLM_PROVIDER as ProviderKey) || 'openai'
+ );
+ const [embeddingProvider, setEmbeddingProvider] = useState(() =>
+ // Default to openai if no specific embedding provider is set
+ (ragSettings.EMBEDDING_PROVIDER as ProviderKey) || 'openai'
+ );
+ const [activeSelection, setActiveSelection] = useState<'chat' | 'embedding'>('chat');
+
// Instance configurations
const [llmInstanceConfig, setLLMInstanceConfig] = useState({
name: '',
@@ -113,185 +259,299 @@ export const RAGSettings = ({
}
}, [ragSettings.OLLAMA_EMBEDDING_URL, ragSettings.OLLAMA_EMBEDDING_INSTANCE_NAME]);
- // Load API credentials for status checking
+ // Provider model persistence effects - separate for chat and embedding
useEffect(() => {
- const loadApiCredentials = async () => {
- try {
- // Get decrypted values for the API keys we need for status checking
- const keyNames = ['OPENAI_API_KEY', 'GOOGLE_API_KEY', 'ANTHROPIC_API_KEY'];
- const statusResults = await credentialsService.checkCredentialStatus(keyNames);
-
- const credentials: {[key: string]: string} = {};
-
- for (const [key, result] of Object.entries(statusResults)) {
- if (result.has_value && result.value && result.value.trim().length > 0) {
- credentials[key] = result.value;
+ // Update chat provider models when chat model changes
+ if (chatProvider && ragSettings.MODEL_CHOICE) {
+ setProviderModels(prev => {
+ const updated = {
+ ...prev,
+ [chatProvider]: {
+ ...prev[chatProvider],
+ chatModel: ragSettings.MODEL_CHOICE
}
- }
-
- console.log('🔑 Loaded API credentials for status checking:', Object.keys(credentials));
- setApiCredentials(credentials);
- } catch (error) {
- console.error('Failed to load API credentials for status checking:', error);
- }
- };
+ };
+ saveProviderModels(updated);
+ return updated;
+ });
+ }
+ }, [ragSettings.MODEL_CHOICE, chatProvider]);
- loadApiCredentials();
- }, []);
+ useEffect(() => {
+ // Update embedding provider models when embedding model changes
+ if (embeddingProvider && ragSettings.EMBEDDING_MODEL) {
+ setProviderModels(prev => {
+ const updated = {
+ ...prev,
+ [embeddingProvider]: {
+ ...prev[embeddingProvider],
+ embeddingModel: ragSettings.EMBEDDING_MODEL
+ }
+ };
+ saveProviderModels(updated);
+ return updated;
+ });
+ }
+ }, [ragSettings.EMBEDDING_MODEL, embeddingProvider]);
- // Reload API credentials when ragSettings change (e.g., after saving)
- // Use a ref to track if we've loaded credentials to prevent infinite loops
const hasLoadedCredentialsRef = useRef(false);
-
- // Manual reload function for external calls
- const reloadApiCredentials = async () => {
+
+ const reloadApiCredentials = useCallback(async () => {
try {
- // Get decrypted values for the API keys we need for status checking
- const keyNames = ['OPENAI_API_KEY', 'GOOGLE_API_KEY', 'ANTHROPIC_API_KEY'];
- const statusResults = await credentialsService.checkCredentialStatus(keyNames);
-
- const credentials: {[key: string]: string} = {};
-
- for (const [key, result] of Object.entries(statusResults)) {
- if (result.has_value && result.value && result.value.trim().length > 0) {
- credentials[key] = result.value;
- }
+ const statusResults = await credentialsService.checkCredentialStatus(
+ Array.from(PROVIDER_CREDENTIAL_KEYS),
+ );
+
+ const credentials: { [key: string]: boolean } = {};
+
+ for (const key of PROVIDER_CREDENTIAL_KEYS) {
+ const result = statusResults[key];
+ credentials[key] = !!result?.has_value;
}
-
- console.log('🔄 Reloaded API credentials for status checking:', Object.keys(credentials));
+
+ console.log(
+ '🔑 Updated API credential status snapshot:',
+ Object.keys(credentials),
+ );
setApiCredentials(credentials);
hasLoadedCredentialsRef.current = true;
} catch (error) {
- console.error('Failed to reload API credentials:', error);
+ console.error('Failed to load API credentials for status checking:', error);
}
- };
-
+ }, []);
+
useEffect(() => {
- // Only reload if we have ragSettings and haven't loaded yet, or if LLM_PROVIDER changed
- if (Object.keys(ragSettings).length > 0 && (!hasLoadedCredentialsRef.current || ragSettings.LLM_PROVIDER)) {
- reloadApiCredentials();
+ void reloadApiCredentials();
+ }, [reloadApiCredentials]);
+
+ useEffect(() => {
+ if (!hasLoadedCredentialsRef.current) {
+ return;
}
- }, [ragSettings.LLM_PROVIDER]); // Only depend on LLM_PROVIDER changes
-
- // Reload credentials periodically to catch updates from other components (like onboarding)
+
+ void reloadApiCredentials();
+ }, [ragSettings.LLM_PROVIDER, reloadApiCredentials]);
+
useEffect(() => {
- // Set up periodic reload every 30 seconds when component is active (reduced from 2s)
const interval = setInterval(() => {
if (Object.keys(ragSettings).length > 0) {
- reloadApiCredentials();
+ void reloadApiCredentials();
}
- }, 30000); // Changed from 2000ms to 30000ms (30 seconds)
+ }, 30000);
return () => clearInterval(interval);
- }, [ragSettings.LLM_PROVIDER]); // Only restart interval if provider changes
-
+ }, [ragSettings.LLM_PROVIDER, reloadApiCredentials]);
+
+ useEffect(() => {
+ const needsDetection = chatProvider === 'ollama' || embeddingProvider === 'ollama';
+
+ if (!needsDetection) {
+ setOllamaServerStatus('unknown');
+ return;
+ }
+
+ const baseUrl = (
+ ragSettings.LLM_BASE_URL?.trim() ||
+ llmInstanceConfig.url?.trim() ||
+ ragSettings.OLLAMA_EMBEDDING_URL?.trim() ||
+ embeddingInstanceConfig.url?.trim() ||
+ DEFAULT_OLLAMA_URL
+ );
+
+ const normalizedUrl = baseUrl.replace('/v1', '').replace(/\/$/, '');
+
+ let cancelled = false;
+
+ (async () => {
+ try {
+ const response = await fetch(
+ `/api/ollama/instances/health?instance_urls=${encodeURIComponent(normalizedUrl)}`,
+ { method: 'GET', headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(10000) }
+ );
+
+ if (cancelled) return;
+
+ if (!response.ok) {
+ setOllamaServerStatus('offline');
+ return;
+ }
+
+ const data = await response.json();
+ const instanceStatus = data.instance_status?.[normalizedUrl];
+ setOllamaServerStatus(instanceStatus?.is_healthy ? 'online' : 'offline');
+ } catch (error) {
+ if (!cancelled) {
+ setOllamaServerStatus('offline');
+ }
+ }
+ })();
+
+ return () => {
+ cancelled = true;
+ };
+ }, [chatProvider, embeddingProvider, ragSettings.LLM_BASE_URL, ragSettings.OLLAMA_EMBEDDING_URL, llmInstanceConfig.url, embeddingInstanceConfig.url]);
+
+ // Sync independent provider states with ragSettings (one-way: ragSettings -> local state)
+ useEffect(() => {
+ if (ragSettings.LLM_PROVIDER && ragSettings.LLM_PROVIDER !== chatProvider) {
+ setChatProvider(ragSettings.LLM_PROVIDER as ProviderKey);
+ }
+ }, [ragSettings.LLM_PROVIDER]); // Remove chatProvider dependency to avoid loops
+
+ useEffect(() => {
+ if (ragSettings.EMBEDDING_PROVIDER && ragSettings.EMBEDDING_PROVIDER !== embeddingProvider) {
+ setEmbeddingProvider(ragSettings.EMBEDDING_PROVIDER as ProviderKey);
+ }
+ }, [ragSettings.EMBEDDING_PROVIDER]); // Remove embeddingProvider dependency to avoid loops
+
+ useEffect(() => {
+ setOllamaManualConfirmed(false);
+ setOllamaServerStatus('unknown');
+ }, [ragSettings.LLM_BASE_URL, ragSettings.OLLAMA_EMBEDDING_URL, chatProvider, embeddingProvider]);
+
+ // Update ragSettings when independent providers change (one-way: local state -> ragSettings)
+ // Split the “first‐run” guard into two refs so chat and embedding effects don’t interfere.
+ const updateChatRagSettingsRef = useRef(true);
+ const updateEmbeddingRagSettingsRef = useRef(true);
+
+ useEffect(() => {
+ // Only update if this is a user‐initiated change, not a sync from ragSettings
+ if (updateChatRagSettingsRef.current && chatProvider !== ragSettings.LLM_PROVIDER) {
+ setRagSettings(prev => ({
+ ...prev,
+ LLM_PROVIDER: chatProvider
+ }));
+ }
+ updateChatRagSettingsRef.current = true;
+ }, [chatProvider]);
+
+ useEffect(() => {
+ // Only update if this is a user‐initiated change, not a sync from ragSettings
+ if (updateEmbeddingRagSettingsRef.current && embeddingProvider && embeddingProvider !== ragSettings.EMBEDDING_PROVIDER) {
+ setRagSettings(prev => ({
+ ...prev,
+ EMBEDDING_PROVIDER: embeddingProvider
+ }));
+ }
+ updateEmbeddingRagSettingsRef.current = true;
+ }, [embeddingProvider]);
+
+
// Status tracking
const [llmStatus, setLLMStatus] = useState({ online: false, responseTime: null, checking: false });
const [embeddingStatus, setEmbeddingStatus] = useState({ online: false, responseTime: null, checking: false });
+ const llmRetryTimeoutRef = useRef(null);
+ const embeddingRetryTimeoutRef = useRef(null);
// API key credentials for status checking
- const [apiCredentials, setApiCredentials] = useState<{[key: string]: string}>({});
+ const [apiCredentials, setApiCredentials] = useState<{[key: string]: boolean}>({});
// Provider connection status tracking
const [providerConnectionStatus, setProviderConnectionStatus] = useState<{
[key: string]: { connected: boolean; checking: boolean; lastChecked?: Date }
}>({});
+ const [ollamaServerStatus, setOllamaServerStatus] = useState<'unknown' | 'online' | 'offline'>('unknown');
+ const [ollamaManualConfirmed, setOllamaManualConfirmed] = useState(false);
+
+ useEffect(() => {
+ return () => {
+ if (llmRetryTimeoutRef.current) {
+ clearTimeout(llmRetryTimeoutRef.current);
+ llmRetryTimeoutRef.current = null;
+ }
+ if (embeddingRetryTimeoutRef.current) {
+ clearTimeout(embeddingRetryTimeoutRef.current);
+ embeddingRetryTimeoutRef.current = null;
+ }
+ };
+ }, []);
// Test connection to external providers
- const testProviderConnection = async (provider: string, apiKey: string): Promise => {
+ const testProviderConnection = useCallback(async (provider: string): Promise => {
setProviderConnectionStatus(prev => ({
...prev,
[provider]: { ...prev[provider], checking: true }
}));
try {
- switch (provider) {
- case 'openai':
- // Test OpenAI connection with a simple completion request
- const openaiResponse = await fetch('https://api.openai.com/v1/models', {
- method: 'GET',
- headers: {
- 'Authorization': `Bearer ${apiKey}`,
- 'Content-Type': 'application/json'
- }
- });
-
- if (openaiResponse.ok) {
- setProviderConnectionStatus(prev => ({
- ...prev,
- openai: { connected: true, checking: false, lastChecked: new Date() }
- }));
- return true;
- } else {
- throw new Error(`OpenAI API returned ${openaiResponse.status}`);
- }
+ // Use server-side API endpoint for secure connectivity testing
+ const response = await fetch(`/api/providers/${provider}/status`);
+ const result = await response.json();
- case 'google':
- // Test Google Gemini connection
- const googleResponse = await fetch(`https://generativelanguage.googleapis.com/v1/models?key=${apiKey}`, {
- method: 'GET',
- headers: {
- 'Content-Type': 'application/json'
- }
- });
-
- if (googleResponse.ok) {
- setProviderConnectionStatus(prev => ({
- ...prev,
- google: { connected: true, checking: false, lastChecked: new Date() }
- }));
- return true;
- } else {
- throw new Error(`Google API returned ${googleResponse.status}`);
- }
+ const isConnected = result.ok && result.reason === 'connected';
- default:
- return false;
- }
+ setProviderConnectionStatus(prev => ({
+ ...prev,
+ [provider]: { connected: isConnected, checking: false, lastChecked: new Date() }
+ }));
+
+ return isConnected;
} catch (error) {
- console.error(`Failed to test ${provider} connection:`, error);
+ console.error(`Error testing ${provider} connection:`, error);
setProviderConnectionStatus(prev => ({
...prev,
[provider]: { connected: false, checking: false, lastChecked: new Date() }
}));
return false;
}
- };
+ }, []);
// Test provider connections when API credentials change
useEffect(() => {
const testConnections = async () => {
- const providers = ['openai', 'google'];
-
+ // Test all supported providers
+ const providers = ['openai', 'google', 'anthropic', 'openrouter', 'grok'];
+
for (const provider of providers) {
- const keyName = provider === 'openai' ? 'OPENAI_API_KEY' : 'GOOGLE_API_KEY';
- const apiKey = Object.keys(apiCredentials).find(key => key.toUpperCase() === keyName);
- const keyValue = apiKey ? apiCredentials[apiKey] : undefined;
-
- if (keyValue && keyValue.trim().length > 0) {
- // Don't test if we've already checked recently (within last 30 seconds)
- const lastChecked = providerConnectionStatus[provider]?.lastChecked;
- const now = new Date();
- const timeSinceLastCheck = lastChecked ? now.getTime() - lastChecked.getTime() : Infinity;
-
- if (timeSinceLastCheck > 30000) { // 30 seconds
- console.log(`🔄 Testing ${provider} connection...`);
- await testProviderConnection(provider, keyValue);
- }
- } else {
- // No API key, mark as disconnected
- setProviderConnectionStatus(prev => ({
- ...prev,
- [provider]: { connected: false, checking: false, lastChecked: new Date() }
- }));
+ // Don't test if we've already checked recently (within last 30 seconds)
+ const lastChecked = providerConnectionStatus[provider]?.lastChecked;
+ const now = new Date();
+ const timeSinceLastCheck = lastChecked ? now.getTime() - lastChecked.getTime() : Infinity;
+
+ if (timeSinceLastCheck > 30000) { // 30 seconds
+ console.log(`🔄 Testing ${provider} connection...`);
+ await testProviderConnection(provider);
}
}
};
- // Only test if we have credentials loaded
- if (Object.keys(apiCredentials).length > 0) {
- testConnections();
- }
- }, [apiCredentials]); // Test when credentials change
+ // Test connections periodically (every 60 seconds)
+ testConnections();
+ const interval = setInterval(testConnections, 60000);
+
+ return () => clearInterval(interval);
+ }, [apiCredentials, testProviderConnection]); // Test when credentials change
+
+ useEffect(() => {
+ const handleCredentialUpdate = (event: Event) => {
+ const detail = (event as CustomEvent<{ keys?: string[] }>).detail;
+ const updatedKeys = (detail?.keys ?? []).map(key => key.toUpperCase());
+
+ if (updatedKeys.length === 0) {
+ void reloadApiCredentials();
+ return;
+ }
+
+ const touchedProviderKeys = updatedKeys.filter(key => key in CREDENTIAL_PROVIDER_MAP);
+ if (touchedProviderKeys.length === 0) {
+ return;
+ }
+
+ void reloadApiCredentials();
+
+ touchedProviderKeys.forEach(key => {
+ const provider = CREDENTIAL_PROVIDER_MAP[key as ProviderCredentialKey];
+ if (provider) {
+ void testProviderConnection(provider);
+ }
+ });
+ };
+
+ window.addEventListener('archon:credentials-updated', handleCredentialUpdate);
+
+ return () => {
+ window.removeEventListener('archon:credentials-updated', handleCredentialUpdate);
+ };
+ }, [reloadApiCredentials, testProviderConnection]);
// Ref to track if initial test has been run (will be used after function definitions)
const hasRunInitialTestRef = useRef(false);
@@ -364,7 +624,14 @@ export const RAGSettings = ({
};
// Manual test function with user feedback using backend proxy
- const manualTestConnection = async (url: string, setStatus: React.Dispatch>, instanceName: string) => {
+const manualTestConnection = async (
+ url: string,
+ setStatus: React.Dispatch>,
+ instanceName: string,
+ context?: 'chat' | 'embedding',
+ options?: { suppressToast?: boolean }
+ ): Promise => {
+ const suppressToast = options?.suppressToast ?? false;
setStatus(prev => ({ ...prev, checking: true }));
const startTime = Date.now();
@@ -391,31 +658,58 @@ export const RAGSettings = ({
if (instanceStatus?.is_healthy) {
const responseTime = Math.round(instanceStatus.response_time_ms || (Date.now() - startTime));
setStatus({ online: true, responseTime, checking: false });
- showToast(`${instanceName} connection successful: ${instanceStatus.models_available || 0} models available (${responseTime}ms)`, 'success');
-
+
+ // Context-aware model count display
+ let modelCount = instanceStatus.models_available || 0;
+ let modelType = 'models';
+
+ if (context === 'chat') {
+ modelCount = ollamaMetrics.llmInstanceModels?.chat || 0;
+ modelType = 'chat models';
+ } else if (context === 'embedding') {
+ modelCount = ollamaMetrics.embeddingInstanceModels?.embedding || 0;
+ modelType = 'embedding models';
+ }
+
+ if (!suppressToast) {
+ showToast(`${instanceName} connection successful: ${modelCount} ${modelType} available (${responseTime}ms)`, 'success');
+ }
+
// Scenario 2: Manual "Test Connection" button - refresh Ollama metrics if Ollama provider is selected
- if (ragSettings.LLM_PROVIDER === 'ollama') {
+ if (ragSettings.LLM_PROVIDER === 'ollama' || embeddingProvider === 'ollama' || context === 'embedding') {
console.log('🔄 Fetching Ollama metrics - Test Connection button clicked');
fetchOllamaMetrics();
}
+
+ return true;
} else {
setStatus({ online: false, responseTime: null, checking: false });
- showToast(`${instanceName} connection failed: ${instanceStatus?.error_message || 'Instance is not healthy'}`, 'error');
+ if (!suppressToast) {
+ showToast(`${instanceName} connection failed: ${instanceStatus?.error_message || 'Instance is not healthy'}`, 'error');
+ }
+ return false;
}
} else {
setStatus({ online: false, responseTime: null, checking: false });
- showToast(`${instanceName} connection failed: Backend proxy error (HTTP ${response.status})`, 'error');
+ if (!suppressToast) {
+ showToast(`${instanceName} connection failed: Backend proxy error (HTTP ${response.status})`, 'error');
+ }
+ return false;
}
} catch (error: any) {
setStatus({ online: false, responseTime: null, checking: false });
-
- if (error.name === 'AbortError') {
- showToast(`${instanceName} connection failed: Request timeout (>15s)`, 'error');
- } else {
- showToast(`${instanceName} connection failed: ${error.message || 'Unknown error'}`, 'error');
+
+ if (!suppressToast) {
+ if (error.name === 'AbortError') {
+ showToast(`${instanceName} connection failed: Request timeout (>15s)`, 'error');
+ } else {
+ showToast(`${instanceName} connection failed: ${error.message || 'Unknown error'}`, 'error');
+ }
}
+
+ return false;
}
- };;
+ };
// Function to handle LLM instance deletion
const handleDeleteLLMInstance = () => {
@@ -466,11 +760,14 @@ export const RAGSettings = ({
try {
setOllamaMetrics(prev => ({ ...prev, loading: true }));
- // Prepare instance URLs for the API call
- const instanceUrls = [];
- if (llmInstanceConfig.url) instanceUrls.push(llmInstanceConfig.url);
- if (embeddingInstanceConfig.url && embeddingInstanceConfig.url !== llmInstanceConfig.url) {
- instanceUrls.push(embeddingInstanceConfig.url);
+ // Prepare normalized instance URLs for the API call
+ const instanceUrls: string[] = [];
+ const llmUrlBase = normalizeBaseUrl(llmInstanceConfig.url);
+ const embUrlBase = normalizeBaseUrl(embeddingInstanceConfig.url);
+
+ if (llmUrlBase) instanceUrls.push(llmUrlBase);
+ if (embUrlBase && embUrlBase !== llmUrlBase) {
+ instanceUrls.push(embUrlBase);
}
if (instanceUrls.length === 0) {
@@ -494,18 +791,18 @@ export const RAGSettings = ({
// Count models for LLM instance
const llmChatModels = allChatModels.filter((model: any) =>
- model.instance_url === llmInstanceConfig.url
+ normalizeBaseUrl(model.instance_url) === llmUrlBase
);
const llmEmbeddingModels = allEmbeddingModels.filter((model: any) =>
- model.instance_url === llmInstanceConfig.url
+ normalizeBaseUrl(model.instance_url) === llmUrlBase
);
-
+
// Count models for Embedding instance
const embChatModels = allChatModels.filter((model: any) =>
- model.instance_url === embeddingInstanceConfig.url
+ normalizeBaseUrl(model.instance_url) === embUrlBase
);
const embEmbeddingModels = allEmbeddingModels.filter((model: any) =>
- model.instance_url === embeddingInstanceConfig.url
+ normalizeBaseUrl(model.instance_url) === embUrlBase
);
// Calculate totals
@@ -544,7 +841,7 @@ export const RAGSettings = ({
// Use refs to prevent infinite connection testing
const lastTestedLLMConfigRef = useRef({ url: '', name: '', provider: '' });
const lastTestedEmbeddingConfigRef = useRef({ url: '', name: '', provider: '' });
- const lastMetricsFetchRef = useRef({ provider: '', llmUrl: '', embUrl: '', llmOnline: false, embOnline: false });
+ const lastMetricsFetchRef = useRef({ provider: '', embProvider: '', llmUrl: '', embUrl: '', llmOnline: false, embOnline: false });
// Auto-testing disabled to prevent API calls on every keystroke per user request
// Connection testing should only happen on manual "Test Connection" or "Save Changes" button clicks
@@ -592,95 +889,254 @@ export const RAGSettings = ({
// }
// }, [embeddingInstanceConfig.url, embeddingInstanceConfig.name, ragSettings.LLM_PROVIDER]);
- // Fetch Ollama metrics only when Ollama provider is initially selected (not on URL changes during typing)
React.useEffect(() => {
- if (ragSettings.LLM_PROVIDER === 'ollama') {
- const currentProvider = ragSettings.LLM_PROVIDER;
- const lastProvider = lastMetricsFetchRef.current.provider;
-
- // Only fetch if provider changed to Ollama (scenario 1: user clicks on Ollama Provider)
- if (currentProvider !== lastProvider) {
- lastMetricsFetchRef.current = {
- provider: currentProvider,
- llmUrl: llmInstanceConfig.url,
- embUrl: embeddingInstanceConfig.url,
- llmOnline: llmStatus.online,
- embOnline: embeddingStatus.online
- };
- console.log('🔄 Fetching Ollama metrics - Provider selected');
- fetchOllamaMetrics();
- }
+ const current = {
+ provider: ragSettings.LLM_PROVIDER,
+ embProvider: embeddingProvider,
+ llmUrl: normalizeBaseUrl(llmInstanceConfig.url) ?? '',
+ embUrl: normalizeBaseUrl(embeddingInstanceConfig.url) ?? '',
+ llmOnline: llmStatus.online,
+ embOnline: embeddingStatus.online,
+ };
+ const last = lastMetricsFetchRef.current;
+
+ const meaningfulChange =
+ current.provider !== last.provider ||
+ current.embProvider !== last.embProvider ||
+ current.llmUrl !== last.llmUrl ||
+ current.embUrl !== last.embUrl ||
+ current.llmOnline !== last.llmOnline ||
+ current.embOnline !== last.embOnline;
+
+ if ((current.provider === 'ollama' || current.embProvider === 'ollama') && meaningfulChange) {
+ lastMetricsFetchRef.current = current;
+ console.log('🔄 Fetching Ollama metrics - state changed');
+ fetchOllamaMetrics();
}
- }, [ragSettings.LLM_PROVIDER]); // Only watch provider changes, not URL changes
+ }, [ragSettings.LLM_PROVIDER, embeddingProvider, llmStatus.online, embeddingStatus.online]);
+
+ const hasApiCredential = (credentialKey: ProviderCredentialKey): boolean => {
+ if (credentialKey in apiCredentials) {
+ return Boolean(apiCredentials[credentialKey]);
+ }
+
+ const fallbackKey = Object.keys(apiCredentials).find(
+ key => key.toUpperCase() === credentialKey,
+ );
+
+ return fallbackKey ? Boolean(apiCredentials[fallbackKey]) : false;
+ };
// Function to check if a provider is properly configured
const getProviderStatus = (providerKey: string): 'configured' | 'missing' | 'partial' => {
switch (providerKey) {
case 'openai':
- // Check if OpenAI API key is configured (case insensitive)
- const openAIKey = Object.keys(apiCredentials).find(key => key.toUpperCase() === 'OPENAI_API_KEY');
- const keyValue = openAIKey ? apiCredentials[openAIKey] : undefined;
- // Don't consider encrypted placeholders as valid API keys for connection testing
- const hasOpenAIKey = openAIKey && keyValue && keyValue.trim().length > 0 && !keyValue.includes('[ENCRYPTED]');
-
+ const hasOpenAIKey = hasApiCredential('OPENAI_API_KEY');
+
// Only show configured if we have both API key AND confirmed connection
const openAIConnected = providerConnectionStatus['openai']?.connected || false;
const isChecking = providerConnectionStatus['openai']?.checking || false;
-
- console.log('🔍 OpenAI status check:', {
- openAIKey,
- keyValue: keyValue ? `${keyValue.substring(0, 10)}...` : keyValue,
- hasValue: !!keyValue,
- hasOpenAIKey,
- openAIConnected,
- isChecking,
- allCredentials: Object.keys(apiCredentials)
- });
-
+
+ // Intentionally avoid logging API key material.
+
if (!hasOpenAIKey) return 'missing';
if (isChecking) return 'partial';
return openAIConnected ? 'configured' : 'missing';
case 'google':
- // Check if Google API key is configured (case insensitive)
- const googleKey = Object.keys(apiCredentials).find(key => key.toUpperCase() === 'GOOGLE_API_KEY');
- const googleKeyValue = googleKey ? apiCredentials[googleKey] : undefined;
- // Don't consider encrypted placeholders as valid API keys for connection testing
- const hasGoogleKey = googleKey && googleKeyValue && googleKeyValue.trim().length > 0 && !googleKeyValue.includes('[ENCRYPTED]');
+ const hasGoogleKey = hasApiCredential('GOOGLE_API_KEY');
// Only show configured if we have both API key AND confirmed connection
const googleConnected = providerConnectionStatus['google']?.connected || false;
const googleChecking = providerConnectionStatus['google']?.checking || false;
-
+
if (!hasGoogleKey) return 'missing';
if (googleChecking) return 'partial';
return googleConnected ? 'configured' : 'missing';
case 'ollama':
- // Check if both LLM and embedding instances are configured and online
- if (llmStatus.online && embeddingStatus.online) return 'configured';
- if (llmStatus.online || embeddingStatus.online) return 'partial';
- return 'missing';
+ {
+ if (ollamaManualConfirmed || llmStatus.online || embeddingStatus.online) {
+ return 'configured';
+ }
+
+ if (ollamaServerStatus === 'online') {
+ return 'partial';
+ }
+
+ if (ollamaServerStatus === 'offline') {
+ return 'missing';
+ }
+
+ return 'missing';
+ }
case 'anthropic':
- // Check if Anthropic API key is configured (case insensitive)
- const anthropicKey = Object.keys(apiCredentials).find(key => key.toUpperCase() === 'ANTHROPIC_API_KEY');
- const hasAnthropicKey = anthropicKey && apiCredentials[anthropicKey] && apiCredentials[anthropicKey].trim().length > 0;
- return hasAnthropicKey ? 'configured' : 'missing';
+ const hasAnthropicKey = hasApiCredential('ANTHROPIC_API_KEY');
+ const anthropicConnected = providerConnectionStatus['anthropic']?.connected || false;
+ const anthropicChecking = providerConnectionStatus['anthropic']?.checking || false;
+ if (!hasAnthropicKey) return 'missing';
+ if (anthropicChecking) return 'partial';
+ return anthropicConnected ? 'configured' : 'missing';
case 'grok':
- // Check if Grok API key is configured (case insensitive)
- const grokKey = Object.keys(apiCredentials).find(key => key.toUpperCase() === 'GROK_API_KEY');
- const hasGrokKey = grokKey && apiCredentials[grokKey] && apiCredentials[grokKey].trim().length > 0;
- return hasGrokKey ? 'configured' : 'missing';
+ const hasGrokKey = hasApiCredential('GROK_API_KEY');
+ const grokConnected = providerConnectionStatus['grok']?.connected || false;
+ const grokChecking = providerConnectionStatus['grok']?.checking || false;
+ if (!hasGrokKey) return 'missing';
+ if (grokChecking) return 'partial';
+ return grokConnected ? 'configured' : 'missing';
case 'openrouter':
- // Check if OpenRouter API key is configured (case insensitive)
- const openRouterKey = Object.keys(apiCredentials).find(key => key.toUpperCase() === 'OPENROUTER_API_KEY');
- const hasOpenRouterKey = openRouterKey && apiCredentials[openRouterKey] && apiCredentials[openRouterKey].trim().length > 0;
- return hasOpenRouterKey ? 'configured' : 'missing';
+ const hasOpenRouterKey = hasApiCredential('OPENROUTER_API_KEY');
+ const openRouterConnected = providerConnectionStatus['openrouter']?.connected || false;
+ const openRouterChecking = providerConnectionStatus['openrouter']?.checking || false;
+ if (!hasOpenRouterKey) return 'missing';
+ if (openRouterChecking) return 'partial';
+ return openRouterConnected ? 'configured' : 'missing';
default:
return 'missing';
}
- };;
+ };
+
+ const resolvedProviderForAlert = activeSelection === 'chat' ? chatProvider : embeddingProvider;
+ const activeProviderKey = isProviderKey(resolvedProviderForAlert)
+ ? (resolvedProviderForAlert as ProviderKey)
+ : undefined;
+ const selectedProviderStatus = activeProviderKey ? getProviderStatus(activeProviderKey) : undefined;
+
+ let providerAlertMessage: string | null = null;
+ let providerAlertClassName = '';
+
+ if (activeProviderKey === 'ollama') {
+ if (ollamaServerStatus === 'offline') {
+ providerAlertMessage = 'Local Ollama service is not running. Start the Ollama server and ensure it is reachable at the configured URL.';
+ providerAlertClassName = providerErrorAlertStyle;
+ } else if (selectedProviderStatus === 'partial' && ollamaServerStatus === 'online') {
+ providerAlertMessage = 'Local Ollama service detected. Click "Test Connection" to confirm model availability.';
+ providerAlertClassName = providerWarningAlertStyle;
+ }
+ } else if (activeProviderKey && selectedProviderStatus === 'missing') {
+ const providerName = providerDisplayNames[activeProviderKey] ?? activeProviderKey;
+ providerAlertMessage = `${providerName} API key is not configured. Add it in Settings > API Keys.`;
+ providerAlertClassName = providerMissingAlertStyle;
+ }
+
+ const shouldShowProviderAlert = Boolean(providerAlertMessage);
+ useEffect(() => {
+ if (chatProvider !== 'ollama') {
+ if (llmRetryTimeoutRef.current) {
+ clearTimeout(llmRetryTimeoutRef.current);
+ llmRetryTimeoutRef.current = null;
+ }
+ return;
+ }
+
+ const baseUrl = (
+ ragSettings.LLM_BASE_URL?.trim() ||
+ llmInstanceConfig.url?.trim() ||
+ DEFAULT_OLLAMA_URL
+ );
+
+ if (!baseUrl) {
+ return;
+ }
+
+ const instanceName = llmInstanceConfig.name?.trim().length
+ ? llmInstanceConfig.name
+ : 'LLM Instance';
+
+ let cancelled = false;
+
+ const runTest = async () => {
+ if (cancelled) return;
+
+ const success = await manualTestConnection(
+ baseUrl,
+ setLLMStatus,
+ instanceName,
+ 'chat',
+ { suppressToast: true }
+ );
+
+ if (!success && chatProvider === 'ollama' && !cancelled) {
+ llmRetryTimeoutRef.current = window.setTimeout(runTest, 5000);
+ }
+ };
+
+ if (llmRetryTimeoutRef.current) {
+ clearTimeout(llmRetryTimeoutRef.current);
+ llmRetryTimeoutRef.current = null;
+ }
+
+ setLLMStatus(prev => ({ ...prev, checking: true }));
+ runTest();
+
+ return () => {
+ cancelled = true;
+ if (llmRetryTimeoutRef.current) {
+ clearTimeout(llmRetryTimeoutRef.current);
+ llmRetryTimeoutRef.current = null;
+ }
+ };
+ }, [chatProvider, ragSettings.LLM_BASE_URL, ragSettings.LLM_INSTANCE_NAME, llmInstanceConfig.url, llmInstanceConfig.name]);
+
+ useEffect(() => {
+ if (embeddingProvider !== 'ollama') {
+ if (embeddingRetryTimeoutRef.current) {
+ clearTimeout(embeddingRetryTimeoutRef.current);
+ embeddingRetryTimeoutRef.current = null;
+ }
+ return;
+ }
+
+ const baseUrl = (
+ ragSettings.OLLAMA_EMBEDDING_URL?.trim() ||
+ embeddingInstanceConfig.url?.trim() ||
+ DEFAULT_OLLAMA_URL
+ );
+
+ if (!baseUrl) {
+ return;
+ }
+
+ const instanceName = embeddingInstanceConfig.name?.trim().length
+ ? embeddingInstanceConfig.name
+ : 'Embedding Instance';
+
+ let cancelled = false;
+
+ const runTest = async () => {
+ if (cancelled) return;
+
+ const success = await manualTestConnection(
+ baseUrl,
+ setEmbeddingStatus,
+ instanceName,
+ 'embedding',
+ { suppressToast: true }
+ );
+
+ if (!success && embeddingProvider === 'ollama' && !cancelled) {
+ embeddingRetryTimeoutRef.current = window.setTimeout(runTest, 5000);
+ }
+ };
+
+ if (embeddingRetryTimeoutRef.current) {
+ clearTimeout(embeddingRetryTimeoutRef.current);
+ embeddingRetryTimeoutRef.current = null;
+ }
+
+ setEmbeddingStatus(prev => ({ ...prev, checking: true }));
+ runTest();
+
+ return () => {
+ cancelled = true;
+ if (embeddingRetryTimeoutRef.current) {
+ clearTimeout(embeddingRetryTimeoutRef.current);
+ embeddingRetryTimeoutRef.current = null;
+ }
+ };
+ }, [embeddingProvider, ragSettings.OLLAMA_EMBEDDING_URL, ragSettings.OLLAMA_EMBEDDING_INSTANCE_NAME, embeddingInstanceConfig.url, embeddingInstanceConfig.name]);
+
// Test Ollama connectivity when Settings page loads (scenario 4: page load)
// This useEffect is placed after function definitions to ensure access to manualTestConnection
useEffect(() => {
@@ -695,35 +1151,74 @@ export const RAGSettings = ({
});
// Only run once when data is properly loaded and not run before
- if (!hasRunInitialTestRef.current &&
- ragSettings.LLM_PROVIDER === 'ollama' &&
- Object.keys(ragSettings).length > 0 &&
- (llmInstanceConfig.url || embeddingInstanceConfig.url)) {
+ if (
+ !hasRunInitialTestRef.current &&
+ (ragSettings.LLM_PROVIDER === 'ollama' || embeddingProvider === 'ollama') &&
+ Object.keys(ragSettings).length > 0
+ ) {
hasRunInitialTestRef.current = true;
console.log('🔄 Settings page loaded with Ollama - Testing connectivity');
-
- // Test LLM instance if configured (use URL presence as the key indicator)
- // Only test if URL is explicitly set in ragSettings, not just using the default
- if (llmInstanceConfig.url && ragSettings.LLM_BASE_URL) {
+
+ // Test LLM instance if a URL is available (either saved or default)
+ if (llmInstanceConfig.url) {
setTimeout(() => {
const instanceName = llmInstanceConfig.name || 'LLM Instance';
console.log('🔍 Testing LLM instance on page load:', instanceName, llmInstanceConfig.url);
- manualTestConnection(llmInstanceConfig.url, setLLMStatus, instanceName);
+ manualTestConnection(
+ llmInstanceConfig.url,
+ setLLMStatus,
+ instanceName,
+ 'chat',
+ { suppressToast: true }
+ );
}, 1000); // Increased delay to ensure component is fully ready
}
-
+ // If no saved URL, run tests against default endpoint
+ else {
+ setTimeout(() => {
+ const defaultInstanceName = 'Local Ollama (Default)';
+ console.log('🔍 Testing default Ollama chat instance on page load:', DEFAULT_OLLAMA_URL);
+ manualTestConnection(
+ DEFAULT_OLLAMA_URL,
+ setLLMStatus,
+ defaultInstanceName,
+ 'chat',
+ { suppressToast: true }
+ );
+ }, 1000);
+ }
+
// Test Embedding instance if configured and different from LLM instance
- // Only test if URL is explicitly set in ragSettings, not just using the default
- if (embeddingInstanceConfig.url && ragSettings.OLLAMA_EMBEDDING_URL &&
+ if (embeddingInstanceConfig.url &&
embeddingInstanceConfig.url !== llmInstanceConfig.url) {
setTimeout(() => {
const instanceName = embeddingInstanceConfig.name || 'Embedding Instance';
console.log('🔍 Testing Embedding instance on page load:', instanceName, embeddingInstanceConfig.url);
- manualTestConnection(embeddingInstanceConfig.url, setEmbeddingStatus, instanceName);
+ manualTestConnection(
+ embeddingInstanceConfig.url,
+ setEmbeddingStatus,
+ instanceName,
+ 'embedding',
+ { suppressToast: true }
+ );
}, 1500); // Stagger the tests
}
-
+ // If embedding provider is also Ollama but no specific URL is set, test default as fallback
+ else if (embeddingProvider === 'ollama' && !embeddingInstanceConfig.url) {
+ setTimeout(() => {
+ const defaultEmbeddingName = 'Local Ollama (Default)';
+ console.log('🔍 Testing default Ollama embedding instance on page load:', DEFAULT_OLLAMA_URL);
+ manualTestConnection(
+ DEFAULT_OLLAMA_URL,
+ setEmbeddingStatus,
+ defaultEmbeddingName,
+ 'embedding',
+ { suppressToast: true }
+ );
+ }, 1500);
+ }
+
// Fetch Ollama metrics after testing connections
setTimeout(() => {
console.log('📊 Fetching Ollama metrics on page load');
@@ -741,87 +1236,125 @@ export const RAGSettings = ({
knowledge retrieval.
- {/* Provider Selection - 6 Button Layout */}
+ {/* LLM Provider Settings Header */}
+
+
+ LLM Provider Settings
+
+
+
+ {/* Provider Selection Buttons */}
+
+
setActiveSelection('chat')}
+ variant="ghost"
+ className={`min-w-[180px] px-5 py-3 font-semibold text-white dark:text-white
+ border border-emerald-400/70 dark:border-emerald-400/40
+ bg-black/40 backdrop-blur-md
+ shadow-[inset_0_0_16px_rgba(15,118,110,0.38)]
+ hover:bg-emerald-500/12 dark:hover:bg-emerald-500/20
+ hover:border-emerald-300/80 hover:shadow-[0_0_22px_rgba(16,185,129,0.5)]
+ ${(activeSelection === 'chat')
+ ? 'shadow-[0_0_25px_rgba(16,185,129,0.5)] ring-2 ring-emerald-400/50'
+ : 'shadow-[0_0_15px_rgba(16,185,129,0.25)]'}
+ `}
+ >
+
+
+ Chat: {chatProvider}
+
+
+
setActiveSelection('embedding')}
+ variant="ghost"
+ className={`min-w-[180px] px-5 py-3 font-semibold text-white dark:text-white
+ border border-purple-400/70 dark:border-purple-400/40
+ bg-black/40 backdrop-blur-md
+ shadow-[inset_0_0_16px_rgba(109,40,217,0.38)]
+ hover:bg-purple-500/12 dark:hover:bg-purple-500/20
+ hover:border-purple-300/80 hover:shadow-[0_0_24px_rgba(168,85,247,0.52)]
+ ${(activeSelection === 'embedding')
+ ? 'shadow-[0_0_26px_rgba(168,85,247,0.55)] ring-2 ring-purple-400/60'
+ : 'shadow-[0_0_15px_rgba(168,85,247,0.25)]'}
+ `}
+ >
+
+
+ Embeddings: {embeddingProvider}
+
+
+
+
+ {/* Context-Aware Provider Grid */}
-
+
{[
{ key: 'openai', name: 'OpenAI', logo: '/img/OpenAI.png', color: 'green' },
{ key: 'google', name: 'Google', logo: '/img/google-logo.svg', color: 'blue' },
+ { key: 'openrouter', name: 'OpenRouter', logo: '/img/OpenRouter.png', color: 'cyan' },
{ key: 'ollama', name: 'Ollama', logo: '/img/Ollama.png', color: 'purple' },
{ key: 'anthropic', name: 'Anthropic', logo: '/img/claude-logo.svg', color: 'orange' },
- { key: 'grok', name: 'Grok', logo: '/img/Grok.png', color: 'yellow' },
- { key: 'openrouter', name: 'OpenRouter', logo: '/img/OpenRouter.png', color: 'cyan' }
- ].map(provider => (
+ { key: 'grok', name: 'Grok', logo: '/img/Grok.png', color: 'yellow' }
+ ]
+ .filter(provider =>
+ activeSelection === 'chat' || EMBEDDING_CAPABLE_PROVIDERS.includes(provider.key as ProviderKey)
+ )
+ .map(provider => (