Ver código fonte

feat: complete frontend UI for Qdrant vector search

- Add comprehensive Qdrant types (QdrantEmbedding, QdrantError, QdrantStats, etc.)
- Extend ApiService with Qdrant and MCP management methods
- Enhanced ShopDetailPage with Qdrant settings toggle and error displays
- Add new QdrantPage for system-wide Qdrant management and monitoring
- Update Router to include /qdrant route and navigation
- Add Qdrant navigation item to Layout sidebar
- Include new icons for Qdrant functionality (qdrant, cleanup, health, error)

Frontend now provides complete UI for:
- Enabling/disabling Qdrant per shop with toggle
- Real-time embedding status display (completed/pending/failed)
- Error tracking and clearing functionality
- System health monitoring and statistics
- Administrative cleanup operations
- Custom ID warnings for Qdrant requirements

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Fszontagh 8 meses atrás
pai
commit
4141d20152

+ 4 - 0
web/src/app/Router.ts

@@ -7,6 +7,7 @@ import { JobsPage } from '../components/pages/JobsPage';
 import { ContentPage } from '../components/pages/ContentPage';
 import { ContentPage } from '../components/pages/ContentPage';
 import { SchedulesPage } from '../components/pages/SchedulesPage';
 import { SchedulesPage } from '../components/pages/SchedulesPage';
 import { WebhooksPage } from '../components/pages/WebhooksPage';
 import { WebhooksPage } from '../components/pages/WebhooksPage';
+import { QdrantPage } from '../components/pages/QdrantPage';
 import { DocumentationPage } from '../components/pages/DocumentationPage';
 import { DocumentationPage } from '../components/pages/DocumentationPage';
 
 
 export interface RouteConfig {
 export interface RouteConfig {
@@ -69,6 +70,8 @@ export class Router {
       component = new SchedulesPage(this.apiService, this.toastService, this);
       component = new SchedulesPage(this.apiService, this.toastService, this);
     } else if (path === '/webhooks') {
     } else if (path === '/webhooks') {
       component = new WebhooksPage(this.apiService, this.toastService, this);
       component = new WebhooksPage(this.apiService, this.toastService, this);
+    } else if (path === '/qdrant') {
+      component = new QdrantPage(this.apiService, this.toastService);
     } else if (path === '/documentation') {
     } else if (path === '/documentation') {
       component = new DocumentationPage(this.apiService, this.toastService);
       component = new DocumentationPage(this.apiService, this.toastService);
     }
     }
@@ -102,6 +105,7 @@ export class Router {
     if (path === '/content') return 'Content';
     if (path === '/content') return 'Content';
     if (path === '/schedules') return 'Schedules';
     if (path === '/schedules') return 'Schedules';
     if (path === '/webhooks') return 'Webhooks';
     if (path === '/webhooks') return 'Webhooks';
+    if (path === '/qdrant') return 'Vector Search (Qdrant)';
     if (path === '/documentation') return 'API Documentation';
     if (path === '/documentation') return 'API Documentation';
     return '';
     return '';
   }
   }

+ 1 - 0
web/src/components/Layout.ts

@@ -16,6 +16,7 @@ export class Layout {
     { name: 'Content', href: '/content', icon: 'content', current: false },
     { name: 'Content', href: '/content', icon: 'content', current: false },
     { name: 'Schedules', href: '/schedules', icon: 'schedules', current: false },
     { name: 'Schedules', href: '/schedules', icon: 'schedules', current: false },
     { name: 'Webhooks', href: '/webhooks', icon: 'webhooks', current: false },
     { name: 'Webhooks', href: '/webhooks', icon: 'webhooks', current: false },
+    { name: 'Qdrant', href: '/qdrant', icon: 'qdrant', current: false },
     { name: 'Documentation', href: '/documentation', icon: 'documentation', current: false },
     { name: 'Documentation', href: '/documentation', icon: 'documentation', current: false },
   ];
   ];
 
 

+ 334 - 0
web/src/components/pages/QdrantPage.ts

@@ -0,0 +1,334 @@
+import { ApiService } from '../../services/ApiService';
+import { ToastService } from '../../services/ToastService';
+import { QdrantStats, QdrantHealth } from '../../types';
+import { getIcon } from '../../utils/icons';
+import { formatNumber, formatRelativeTime } from '../../utils/helpers';
+
+export class QdrantPage {
+  private element: HTMLElement | null = null;
+  private stats: QdrantStats | null = null;
+  private health: QdrantHealth | null = null;
+  private eventsbound: boolean = false;
+
+  constructor(
+    private apiService: ApiService,
+    private toastService: ToastService
+  ) {}
+
+  render(): HTMLElement {
+    this.element = document.createElement('div');
+    this.element.className = 'space-y-6';
+
+    this.element.innerHTML = `
+      <!-- Loading state -->
+      <div class="flex items-center justify-center py-12" id="loading">
+        <div class="w-8 h-8 spinner"></div>
+        <span class="ml-3 text-gray-600 dark:text-gray-400">Loading Qdrant information...</span>
+      </div>
+    `;
+
+    this.loadQdrantData();
+    return this.element;
+  }
+
+  private async loadQdrantData(): Promise<void> {
+    try {
+      const [statsResponse, healthResponse] = await Promise.all([
+        this.apiService.getQdrantStats(),
+        this.apiService.getQdrantHealth()
+      ]);
+
+      if (statsResponse.success && statsResponse.data) {
+        this.stats = statsResponse.data;
+      }
+
+      if (healthResponse.success && healthResponse.data) {
+        this.health = healthResponse.data;
+      }
+
+      this.renderQdrantOverview();
+    } catch (error) {
+      this.toastService.error('Error', 'Failed to load Qdrant information');
+      this.renderError();
+    }
+  }
+
+  private renderError(): void {
+    if (!this.element) return;
+
+    this.element.innerHTML = `
+      <div class="text-center py-12">
+        <div class="w-12 h-12 bg-red-100 dark:bg-red-900/20 rounded-lg flex items-center justify-center mx-auto mb-3">
+          <div class="w-6 h-6 text-red-600 dark:text-red-400">${getIcon('error')}</div>
+        </div>
+        <h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2">Failed to Load Qdrant Data</h3>
+        <p class="text-gray-500 dark:text-gray-400 mb-4">Unable to connect to Qdrant service</p>
+        <button class="btn btn-primary" id="retry-load">
+          <div class="w-4 h-4 mr-2">${getIcon('refresh')}</div>
+          Retry
+        </button>
+      </div>
+    `;
+
+    this.element.querySelector('#retry-load')?.addEventListener('click', () => {
+      this.loadQdrantData();
+    });
+  }
+
+  private renderQdrantOverview(): void {
+    if (!this.element) return;
+
+    this.element.innerHTML = `
+      <!-- Header -->
+      <div class="md:flex md:items-center md:justify-between">
+        <div class="flex-1 min-w-0">
+          <h2 class="text-2xl font-bold leading-7 text-gray-900 dark:text-white sm:text-3xl sm:truncate">
+            Vector Search (Qdrant)
+          </h2>
+          <p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
+            Manage and monitor the Qdrant vector database integration
+          </p>
+        </div>
+        <div class="mt-4 flex md:mt-0 md:ml-4 space-x-3">
+          <button type="button" class="btn btn-secondary" id="refresh-data">
+            <div class="w-4 h-4 mr-2">${getIcon('refresh')}</div>
+            Refresh
+          </button>
+          <button type="button" class="btn btn-primary" id="trigger-cleanup">
+            <div class="w-4 h-4 mr-2">${getIcon('cleanup')}</div>
+            Run Cleanup
+          </button>
+        </div>
+      </div>
+
+      <!-- Health Status -->
+      <div class="card">
+        <div class="card-header">
+          <h3 class="text-lg font-medium text-gray-900 dark:text-white">Service Health</h3>
+          <p class="text-sm text-gray-500 dark:text-gray-400">Current status of Qdrant services</p>
+        </div>
+        <div class="card-body">
+          ${this.renderHealthStatus()}
+        </div>
+      </div>
+
+      <!-- Statistics -->
+      ${this.stats ? `
+        <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
+          <div class="card">
+            <div class="card-body text-center">
+              <div class="text-2xl font-bold text-gray-900 dark:text-white">${formatNumber(this.stats.total_collections)}</div>
+              <div class="text-sm text-gray-500 dark:text-gray-400">Total Collections</div>
+            </div>
+          </div>
+          <div class="card">
+            <div class="card-body text-center">
+              <div class="text-2xl font-bold text-gray-900 dark:text-white">${formatNumber(this.stats.total_vectors)}</div>
+              <div class="text-sm text-gray-500 dark:text-gray-400">Total Vectors</div>
+            </div>
+          </div>
+          <div class="card">
+            <div class="card-body text-center">
+              <div class="text-2xl font-bold text-gray-900 dark:text-white">${formatNumber(this.stats.enabled_shops)}</div>
+              <div class="text-sm text-gray-500 dark:text-gray-400">Enabled Shops</div>
+            </div>
+          </div>
+          <div class="card">
+            <div class="card-body text-center">
+              <div class="text-2xl font-bold text-gray-900 dark:text-white">${formatNumber(this.stats.recent_embeddings)}</div>
+              <div class="text-sm text-gray-500 dark:text-gray-400">Recent Embeddings</div>
+            </div>
+          </div>
+        </div>
+      ` : ''}
+
+      <!-- Configuration Note -->
+      <div class="card">
+        <div class="card-header">
+          <h3 class="text-lg font-medium text-gray-900 dark:text-white">Configuration</h3>
+          <p class="text-sm text-gray-500 dark:text-gray-400">Qdrant and embedding service settings</p>
+        </div>
+        <div class="card-body">
+          <div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
+            <div class="flex">
+              <div class="flex-shrink-0">
+                <div class="w-5 h-5 text-blue-400">${getIcon('info')}</div>
+              </div>
+              <div class="ml-3">
+                <h4 class="text-sm font-medium text-blue-800 dark:text-blue-300">Environment Configuration</h4>
+                <div class="text-sm text-blue-700 dark:text-blue-400 mt-1 space-y-1">
+                  <p>• Qdrant API URL and credentials are configured via environment variables</p>
+                  <p>• OpenRouter API is used for text embeddings (text-embedding-3-large model)</p>
+                  <p>• Collections follow the naming pattern: {shop_custom_id}-{category}_{iterator}</p>
+                  <p>• Vector dimension: 3072 (OpenAI text-embedding-3-large)</p>
+                </div>
+              </div>
+            </div>
+          </div>
+        </div>
+      </div>
+
+      <!-- Actions -->
+      <div class="card">
+        <div class="card-header">
+          <h3 class="text-lg font-medium text-gray-900 dark:text-white">Management Actions</h3>
+          <p class="text-sm text-gray-500 dark:text-gray-400">Administrative operations for Qdrant</p>
+        </div>
+        <div class="card-body">
+          <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
+            <div class="p-4 border border-gray-200 dark:border-gray-700 rounded-lg">
+              <h4 class="text-sm font-medium text-gray-900 dark:text-white mb-2">Cleanup Operations</h4>
+              <p class="text-sm text-gray-500 dark:text-gray-400 mb-3">
+                Remove orphaned collections and process deletion queue
+              </p>
+              <button type="button" class="btn btn-sm btn-secondary" id="run-cleanup">
+                <div class="w-4 h-4 mr-2">${getIcon('cleanup')}</div>
+                Run Cleanup
+              </button>
+            </div>
+            <div class="p-4 border border-gray-200 dark:border-gray-700 rounded-lg">
+              <h4 class="text-sm font-medium text-gray-900 dark:text-white mb-2">Health Check</h4>
+              <p class="text-sm text-gray-500 dark:text-gray-400 mb-3">
+                Test connectivity and service availability
+              </p>
+              <button type="button" class="btn btn-sm btn-secondary" id="run-health-check">
+                <div class="w-4 h-4 mr-2">${getIcon('health')}</div>
+                Run Health Check
+              </button>
+            </div>
+          </div>
+        </div>
+      </div>
+    `;
+
+    this.bindEvents();
+  }
+
+  private renderHealthStatus(): string {
+    if (!this.health) {
+      return `
+        <div class="text-center py-6">
+          <div class="w-8 h-8 spinner mx-auto mb-3"></div>
+          <p class="text-gray-500 dark:text-gray-400">Checking health status...</p>
+        </div>
+      `;
+    }
+
+    const overallStatus = this.health.healthy;
+    const services = this.health.services;
+
+    return `
+      <div class="space-y-4">
+        <!-- Overall Status -->
+        <div class="flex items-center justify-between p-4 ${overallStatus ? 'bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800' : 'bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800'} rounded-lg">
+          <div class="flex items-center">
+            <div class="w-6 h-6 ${overallStatus ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'} mr-3">
+              ${getIcon(overallStatus ? 'check' : 'error')}
+            </div>
+            <div>
+              <h4 class="text-sm font-medium ${overallStatus ? 'text-green-800 dark:text-green-300' : 'text-red-800 dark:text-red-300'}">
+                System ${overallStatus ? 'Healthy' : 'Unhealthy'}
+              </h4>
+              <p class="text-sm ${overallStatus ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}">
+                ${overallStatus ? 'All services are operational' : 'Some services are experiencing issues'}
+              </p>
+            </div>
+          </div>
+          <span class="badge ${overallStatus ? 'badge-success' : 'badge-danger'}">
+            ${overallStatus ? 'Healthy' : 'Unhealthy'}
+          </span>
+        </div>
+
+        <!-- Individual Services -->
+        <div class="grid grid-cols-1 md:grid-cols-3 gap-4">
+          ${Object.entries(services).map(([serviceName, healthy]) => `
+            <div class="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
+              <div class="flex items-center">
+                <div class="w-4 h-4 ${healthy ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'} mr-2">
+                  ${getIcon(healthy ? 'check' : 'error')}
+                </div>
+                <span class="text-sm font-medium text-gray-900 dark:text-white capitalize">${serviceName}</span>
+              </div>
+              <span class="badge badge-sm ${healthy ? 'badge-success' : 'badge-danger'}">
+                ${healthy ? 'OK' : 'Error'}
+              </span>
+            </div>
+          `).join('')}
+        </div>
+
+        <!-- Errors -->
+        ${this.health.errors.length > 0 ? `
+          <div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
+            <h4 class="text-sm font-medium text-red-800 dark:text-red-300 mb-2">Error Details</h4>
+            <ul class="space-y-1">
+              ${this.health.errors.map(error => `
+                <li class="text-sm text-red-600 dark:text-red-400">• ${error}</li>
+              `).join('')}
+            </ul>
+          </div>
+        ` : ''}
+      </div>
+    `;
+  }
+
+  private bindEvents(): void {
+    if (!this.element || this.eventsbound) return;
+    this.eventsbound = true;
+
+    // Refresh data
+    this.element.querySelector('#refresh-data')?.addEventListener('click', () => {
+      this.loadQdrantData();
+    });
+
+    // Trigger cleanup
+    this.element.querySelector('#trigger-cleanup')?.addEventListener('click', async () => {
+      await this.runCleanup();
+    });
+
+    this.element.querySelector('#run-cleanup')?.addEventListener('click', async () => {
+      await this.runCleanup();
+    });
+
+    // Run health check
+    this.element.querySelector('#run-health-check')?.addEventListener('click', async () => {
+      await this.runHealthCheck();
+    });
+  }
+
+  private async runCleanup(): Promise<void> {
+    try {
+      const response = await this.apiService.triggerQdrantCleanup();
+
+      if (response.success) {
+        this.toastService.success('Cleanup Started', 'Qdrant cleanup process has been initiated');
+        // Refresh data after cleanup
+        setTimeout(() => this.loadQdrantData(), 2000);
+      } else {
+        this.toastService.error('Cleanup Failed', response.error || 'Failed to start cleanup process');
+      }
+    } catch (error) {
+      this.toastService.error('Error', 'Failed to start cleanup process');
+    }
+  }
+
+  private async runHealthCheck(): Promise<void> {
+    try {
+      const response = await this.apiService.getQdrantHealth();
+
+      if (response.success && response.data) {
+        this.health = response.data;
+        this.renderQdrantOverview();
+
+        if (this.health.healthy) {
+          this.toastService.success('Health Check Passed', 'All Qdrant services are operational');
+        } else {
+          this.toastService.warning('Health Check Issues', 'Some services are experiencing problems');
+        }
+      } else {
+        this.toastService.error('Health Check Failed', response.error || 'Unable to perform health check');
+      }
+    } catch (error) {
+      this.toastService.error('Error', 'Failed to perform health check');
+    }
+  }
+}

+ 179 - 5
web/src/components/pages/ShopDetailPage.ts

@@ -1,7 +1,7 @@
 import { ApiService } from '../../services/ApiService';
 import { ApiService } from '../../services/ApiService';
 import { ToastService } from '../../services/ToastService';
 import { ToastService } from '../../services/ToastService';
 import { Router } from '../../app/Router';
 import { Router } from '../../app/Router';
-import { ShopDetail } from '../../types';
+import { ShopDetail, QdrantEmbedding, QdrantError } from '../../types';
 import { getIcon } from '../../utils/icons';
 import { getIcon } from '../../utils/icons';
 import { formatRelativeTime, getDomainFromUrl, getStatusBadgeClass, getContentTypeLabel } from '../../utils/helpers';
 import { formatRelativeTime, getDomainFromUrl, getStatusBadgeClass, getContentTypeLabel } from '../../utils/helpers';
 
 
@@ -9,6 +9,8 @@ export class ShopDetailPage {
   private element: HTMLElement | null = null;
   private element: HTMLElement | null = null;
   private shopDetail: ShopDetail | null = null;
   private shopDetail: ShopDetail | null = null;
   private eventsbound: boolean = false;
   private eventsbound: boolean = false;
+  private qdrantEmbeddings: QdrantEmbedding[] = [];
+  private qdrantErrors: QdrantError[] = [];
 
 
   constructor(
   constructor(
     private shopId: string,
     private shopId: string,
@@ -35,13 +37,27 @@ export class ShopDetailPage {
 
 
   private async loadShopDetail(): Promise<void> {
   private async loadShopDetail(): Promise<void> {
     try {
     try {
-      const response = await this.apiService.getShop(this.shopId);
+      // Load shop details and Qdrant data in parallel
+      const [shopResponse, embeddingsResponse, errorsResponse] = await Promise.all([
+        this.apiService.getShop(this.shopId),
+        this.apiService.getShopQdrantEmbeddings(this.shopId),
+        this.apiService.getShopQdrantErrors(this.shopId)
+      ]);
+
+      if (shopResponse.success && shopResponse.data) {
+        this.shopDetail = shopResponse.data;
+
+        // Load Qdrant data (these can fail without breaking the page)
+        if (embeddingsResponse.success && embeddingsResponse.data) {
+          this.qdrantEmbeddings = embeddingsResponse.data.embeddings;
+        }
+        if (errorsResponse.success && errorsResponse.data) {
+          this.qdrantErrors = errorsResponse.data.errors;
+        }
 
 
-      if (response.success && response.data) {
-        this.shopDetail = response.data;
         this.renderShopDetail();
         this.renderShopDetail();
       } else {
       } else {
-        this.toastService.error('Load Error', response.error || 'Failed to load shop details');
+        this.toastService.error('Load Error', shopResponse.error || 'Failed to load shop details');
         this.router.navigate('/shops');
         this.router.navigate('/shops');
       }
       }
     } catch (error) {
     } catch (error) {
@@ -189,6 +205,119 @@ export class ShopDetailPage {
           `}
           `}
         </div>
         </div>
       </div>
       </div>
+
+      <!-- Qdrant Settings -->
+      <div class="card">
+        <div class="card-header">
+          <div class="flex items-center justify-between">
+            <div>
+              <h3 class="text-lg font-medium text-gray-900 dark:text-white">Vector Search (Qdrant)</h3>
+              <p class="text-sm text-gray-500 dark:text-gray-400">Enable semantic search for this shop's content</p>
+            </div>
+            <div class="flex items-center space-x-3">
+              <label class="flex items-center cursor-pointer">
+                <input type="checkbox" id="qdrant-toggle" class="sr-only" ${shop.qdrant_enabled ? 'checked' : ''}>
+                <div class="relative">
+                  <div class="block bg-gray-600 w-14 h-8 rounded-full"></div>
+                  <div class="dot absolute left-1 top-1 bg-white w-6 h-6 rounded-full transition ${shop.qdrant_enabled ? 'transform translate-x-6 bg-blue-600' : ''}"></div>
+                </div>
+                <span class="ml-3 text-sm font-medium text-gray-900 dark:text-white">
+                  ${shop.qdrant_enabled ? 'Enabled' : 'Disabled'}
+                </span>
+              </label>
+            </div>
+          </div>
+        </div>
+        <div class="card-body">
+          ${shop.qdrant_enabled ? `
+            <div class="space-y-6">
+              <!-- Embeddings Status -->
+              <div>
+                <h4 class="text-sm font-medium text-gray-900 dark:text-white mb-3">Embeddings Status</h4>
+                <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
+                  <div class="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4">
+                    <div class="text-lg font-semibold text-green-800 dark:text-green-300">
+                      ${this.qdrantEmbeddings.filter(e => e.embedding_status === 'completed').length}
+                    </div>
+                    <div class="text-sm text-green-600 dark:text-green-400">Completed</div>
+                  </div>
+                  <div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
+                    <div class="text-lg font-semibold text-yellow-800 dark:text-yellow-300">
+                      ${this.qdrantEmbeddings.filter(e => e.embedding_status === 'pending').length}
+                    </div>
+                    <div class="text-sm text-yellow-600 dark:text-yellow-400">Pending</div>
+                  </div>
+                  <div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
+                    <div class="text-lg font-semibold text-red-800 dark:text-red-300">
+                      ${this.qdrantEmbeddings.filter(e => e.embedding_status === 'failed').length}
+                    </div>
+                    <div class="text-sm text-red-600 dark:text-red-400">Failed</div>
+                  </div>
+                </div>
+              </div>
+
+              <!-- Qdrant Errors -->
+              ${this.qdrantErrors.length > 0 ? `
+                <div>
+                  <div class="flex items-center justify-between mb-3">
+                    <h4 class="text-sm font-medium text-gray-900 dark:text-white">Recent Errors</h4>
+                    <button type="button" id="clear-qdrant-errors" class="btn btn-sm btn-secondary">
+                      <div class="w-4 h-4 mr-1">${getIcon('delete')}</div>
+                      Clear Errors
+                    </button>
+                  </div>
+                  <div class="space-y-2 max-h-48 overflow-y-auto">
+                    ${this.qdrantErrors.slice(0, 10).map(error => `
+                      <div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-3">
+                        <div class="flex items-start justify-between">
+                          <div class="flex-1">
+                            <p class="text-sm font-medium text-red-800 dark:text-red-300 capitalize">
+                              ${error.error_type} Error
+                            </p>
+                            <p class="text-sm text-red-600 dark:text-red-400 mt-1">
+                              ${error.error_message}
+                            </p>
+                          </div>
+                          <span class="text-xs text-red-500 dark:text-red-400">
+                            ${formatRelativeTime(error.created_at)}
+                          </span>
+                        </div>
+                      </div>
+                    `).join('')}
+                  </div>
+                </div>
+              ` : ''}
+
+              <!-- Custom ID Warning -->
+              ${!shop.custom_id ? `
+                <div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
+                  <div class="flex">
+                    <div class="flex-shrink-0">
+                      <div class="w-5 h-5 text-yellow-400">${getIcon('warning')}</div>
+                    </div>
+                    <div class="ml-3">
+                      <h4 class="text-sm font-medium text-yellow-800 dark:text-yellow-300">Custom ID Required</h4>
+                      <p class="text-sm text-yellow-700 dark:text-yellow-400 mt-1">
+                        This shop needs a custom ID to use Qdrant features. The custom ID cannot be changed once set when Qdrant is enabled.
+                      </p>
+                    </div>
+                  </div>
+                </div>
+              ` : ''}
+            </div>
+          ` : `
+            <div class="text-center py-8">
+              <div class="w-12 h-12 bg-gray-100 dark:bg-gray-700 rounded-lg flex items-center justify-center mx-auto mb-3">
+                <div class="w-6 h-6 text-gray-400">${getIcon('search')}</div>
+              </div>
+              <p class="text-gray-500 dark:text-gray-400 mb-4">Vector search is disabled for this shop</p>
+              <p class="text-sm text-gray-400 dark:text-gray-500">
+                Enable Qdrant to automatically create embeddings of scraped content for semantic search via MCP tools.
+              </p>
+            </div>
+          `}
+        </div>
+      </div>
     `;
     `;
 
 
     this.bindDetailEvents();
     this.bindDetailEvents();
@@ -257,5 +386,50 @@ export class ShopDetailPage {
         }
         }
       }
       }
     });
     });
+
+    // Qdrant toggle
+    this.element.querySelector('#qdrant-toggle')?.addEventListener('change', async (e) => {
+      const checkbox = e.target as HTMLInputElement;
+      const enabled = checkbox.checked;
+
+      try {
+        const response = await this.apiService.updateShopQdrant(this.shopId, enabled);
+
+        if (response.success) {
+          this.toastService.success(
+            'Qdrant Updated',
+            `Vector search has been ${enabled ? 'enabled' : 'disabled'} for this shop`
+          );
+          // Reload the page to show updated status
+          await this.loadShopDetail();
+        } else {
+          // Revert checkbox state on error
+          checkbox.checked = !enabled;
+          this.toastService.error('Update Failed', response.error || 'Failed to update Qdrant settings');
+        }
+      } catch (error) {
+        // Revert checkbox state on error
+        checkbox.checked = !enabled;
+        this.toastService.error('Error', 'Failed to update Qdrant settings');
+      }
+    });
+
+    // Clear Qdrant errors
+    this.element.querySelector('#clear-qdrant-errors')?.addEventListener('click', async () => {
+      try {
+        const response = await this.apiService.clearShopQdrantErrors(this.shopId);
+
+        if (response.success) {
+          this.toastService.success('Errors Cleared', 'Qdrant errors have been cleared');
+          // Clear errors from local state and re-render
+          this.qdrantErrors = [];
+          this.renderShopDetail();
+        } else {
+          this.toastService.error('Clear Failed', response.error || 'Failed to clear Qdrant errors');
+        }
+      } catch (error) {
+        this.toastService.error('Error', 'Failed to clear Qdrant errors');
+      }
+    });
   }
   }
 }
 }

+ 105 - 1
web/src/services/ApiService.ts

@@ -11,7 +11,14 @@ import {
   CustomUrl,
   CustomUrl,
   ContentFilters,
   ContentFilters,
   ShopResultsResponse,
   ShopResultsResponse,
-  ApiResponse
+  ApiResponse,
+  QdrantEmbedding,
+  QdrantError,
+  QdrantHealth,
+  QdrantCollection,
+  QdrantStats,
+  McpLog,
+  McpStats
 } from '../types';
 } from '../types';
 import { AuthService } from './AuthService';
 import { AuthService } from './AuthService';
 
 
@@ -207,6 +214,103 @@ export class ApiService {
     });
     });
   }
   }
 
 
+  // Qdrant Management
+  async updateShopQdrant(
+    shopId: string,
+    enabled: boolean
+  ): Promise<ApiResponse<{ shop_id: string; qdrant_enabled: boolean; message: string }>> {
+    return this.request(`/api/shops/${shopId}/qdrant`, {
+      method: 'PATCH',
+      body: JSON.stringify({ enabled }),
+    });
+  }
+
+  async getQdrantStats(): Promise<ApiResponse<QdrantStats>> {
+    return this.request('/api/qdrant/stats');
+  }
+
+  async getQdrantHealth(): Promise<ApiResponse<QdrantHealth>> {
+    return this.request('/api/qdrant/health');
+  }
+
+  async getShopQdrantInfo(shopId: string): Promise<ApiResponse<{
+    shop: Shop;
+    embeddings: QdrantEmbedding[];
+    errors: QdrantError[];
+    collections: QdrantCollection[];
+  }>> {
+    return this.request(`/api/qdrant/shops/${shopId}`);
+  }
+
+  async getShopQdrantEmbeddings(shopId: string): Promise<ApiResponse<{ embeddings: QdrantEmbedding[] }>> {
+    return this.request(`/api/qdrant/shops/${shopId}/embeddings`);
+  }
+
+  async getShopQdrantErrors(shopId: string): Promise<ApiResponse<{ errors: QdrantError[] }>> {
+    return this.request(`/api/qdrant/shops/${shopId}/errors`);
+  }
+
+  async clearShopQdrantErrors(shopId: string): Promise<ApiResponse<{ message: string }>> {
+    return this.request(`/api/qdrant/shops/${shopId}/errors`, {
+      method: 'DELETE',
+    });
+  }
+
+  async deleteQdrantCollection(collectionName: string): Promise<ApiResponse<{ message: string }>> {
+    return this.request(`/api/qdrant/collections/${collectionName}`, {
+      method: 'DELETE',
+    });
+  }
+
+  async triggerQdrantCleanup(): Promise<ApiResponse<{ message: string }>> {
+    return this.request('/api/qdrant/cleanup', {
+      method: 'POST',
+    });
+  }
+
+  async scheduleShopQdrantDeletion(shopId: string): Promise<ApiResponse<{ message: string; scheduled_at: string }>> {
+    return this.request(`/api/qdrant/shops/${shopId}/schedule-deletion`, {
+      method: 'POST',
+    });
+  }
+
+  // MCP Management
+  async getMcpStats(): Promise<ApiResponse<McpStats>> {
+    return this.request('/api/mcp/stats');
+  }
+
+  async getShopMcpLogs(
+    shopId: string,
+    filters: { limit?: number; success?: boolean; tool_name?: string } = {}
+  ): Promise<ApiResponse<{ logs: McpLog[] }>> {
+    const params = new URLSearchParams();
+    if (filters.limit) params.append('limit', filters.limit.toString());
+    if (filters.success !== undefined) params.append('success', filters.success.toString());
+    if (filters.tool_name) params.append('tool_name', filters.tool_name);
+
+    const query = params.toString();
+    const url = `/api/mcp/shops/${shopId}/logs${query ? `?${query}` : ''}`;
+    return this.request(url);
+  }
+
+  async clearShopMcpLogs(shopId: string): Promise<ApiResponse<{ message: string }>> {
+    return this.request(`/api/mcp/shops/${shopId}/logs`, {
+      method: 'DELETE',
+    });
+  }
+
+  async getMcpAnalytics(): Promise<ApiResponse<{
+    total_calls: number;
+    successful_calls: number;
+    failed_calls: number;
+    average_response_time: number;
+    shops_using_mcp: number;
+    top_tools: Array<{ tool_name: string; call_count: number }>;
+    recent_activity: McpLog[];
+  }>> {
+    return this.request('/api/mcp/analytics');
+  }
+
   // Documentation (no auth required)
   // Documentation (no auth required)
   async getApiDocumentation(): Promise<ApiResponse<string>> {
   async getApiDocumentation(): Promise<ApiResponse<string>> {
     try {
     try {

+ 85 - 1
web/src/types/index.ts

@@ -5,6 +5,7 @@ export interface Shop {
   url: string;
   url: string;
   sitemap_url?: string;
   sitemap_url?: string;
   webshop_type: 'shopify' | 'woocommerce' | 'shoprenter' | string;
   webshop_type: 'shopify' | 'woocommerce' | 'shoprenter' | string;
+  qdrant_enabled: boolean;
   created_at: string;
   created_at: string;
   updated_at: string;
   updated_at: string;
 }
 }
@@ -190,4 +191,87 @@ export type NavigationItem = {
   icon: string;
   icon: string;
   current: boolean;
   current: boolean;
   count?: number;
   count?: number;
-};
+};
+
+// Qdrant Types
+export interface QdrantEmbedding {
+  id: string;
+  shop_content_id: string;
+  collection_name: string;
+  point_id: string;
+  embedding_status: 'pending' | 'completed' | 'failed';
+  created_at: string;
+  updated_at: string;
+}
+
+export interface QdrantError {
+  id: string;
+  shop_id: string;
+  error_type: 'connection' | 'embedding' | 'search' | 'collection';
+  error_message: string;
+  metadata: any;
+  created_at: string;
+}
+
+export interface QdrantHealth {
+  healthy: boolean;
+  services: {
+    qdrant: boolean;
+    embedding: boolean;
+    database: boolean;
+  };
+  errors: string[];
+}
+
+export interface QdrantCollection {
+  collection_name: string;
+  vectors_count: number;
+  config: {
+    vector_size: number;
+    distance: string;
+  };
+}
+
+export interface QdrantSearchResult {
+  id: string;
+  score: number;
+  payload: {
+    shop_id: string;
+    content_id: string;
+    content_type: 'shipping' | 'contacts' | 'terms' | 'faq';
+    url: string;
+    title?: string;
+    content_hash: string;
+    created_at: string;
+  };
+}
+
+export interface QdrantStats {
+  total_collections: number;
+  total_vectors: number;
+  enabled_shops: number;
+  recent_embeddings: number;
+  embedding_queue_size: number;
+}
+
+// MCP Types
+export interface McpLog {
+  id: string;
+  shop_id: string;
+  tool_name: string;
+  query: string;
+  response_time_ms: number;
+  results_count: number;
+  success: boolean;
+  error_message?: string;
+  created_at: string;
+}
+
+export interface McpStats {
+  total_calls: number;
+  successful_calls: number;
+  failed_calls: number;
+  average_response_time: number;
+  top_tools: Array<{ tool_name: string; call_count: number }>;
+  recent_activity: McpLog[];
+}

+ 6 - 0
web/src/utils/icons.ts

@@ -50,6 +50,12 @@ export const icons = {
   // Arrows
   // Arrows
   'arrow-left': '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path></svg>',
   'arrow-left': '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path></svg>',
   'arrow-right': '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path></svg>',
   'arrow-right': '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path></svg>',
+
+  // Qdrant specific
+  qdrant: '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4M7.835 4.697a3.42 3.42 0 001.946-.806 3.42 3.42 0 014.438 0 3.42 3.42 0 001.946.806 3.42 3.42 0 013.138 3.138 3.42 3.42 0 00.806 1.946 3.42 3.42 0 010 4.438 3.42 3.42 0 00-.806 1.946 3.42 3.42 0 01-3.138 3.138 3.42 3.42 0 00-1.946.806 3.42 3.42 0 01-4.438 0 3.42 3.42 0 00-1.946-.806 3.42 3.42 0 01-3.138-3.138 3.42 3.42 0 00-.806-1.946 3.42 3.42 0 010-4.438 3.42 3.42 0 00.806-1.946 3.42 3.42 0 013.138-3.138z"></path></svg>',
+  cleanup: '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg>',
+  health: '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"></path></svg>',
+  error: '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>',
 };
 };
 
 
 export function getIcon(name: keyof typeof icons): string {
 export function getIcon(name: keyof typeof icons): string {