|
@@ -0,0 +1,1198 @@
|
|
|
|
|
+# Custom Content RAG Feature - Implementation Guide
|
|
|
|
|
+
|
|
|
|
|
+## Overview
|
|
|
|
|
+
|
|
|
|
|
+This guide covers the remaining implementation steps for the Custom Content RAG feature, which allows users to upload PDFs and create text entries that are stored in Qdrant for AI-powered semantic search.
|
|
|
|
|
+
|
|
|
|
|
+## ✅ Completed Backend Implementation
|
|
|
|
|
+
|
|
|
|
|
+### 1. Database & Storage
|
|
|
|
|
+- **Migration applied**: `custom_content` table created with all fields
|
|
|
|
|
+- **Storage bucket**: `custom-content` bucket configured with RLS policies
|
|
|
|
|
+- **Indexes**: Performance indexes on store_id, user_id, content_type, sync_status
|
|
|
|
|
+- **Functions**: `get_custom_content_stats()` helper function
|
|
|
|
|
+
|
|
|
|
|
+### 2. Shared Utilities
|
|
|
|
|
+- `_shared/pdf-processor.ts` - PDF text extraction, SHA-256 checksum, validation
|
|
|
|
|
+- `_shared/text-chunker.ts` - Hybrid chunking (single vs overlapping chunks)
|
|
|
|
|
+- `_shared/qdrant-client.ts` - Extended with custom content methods:
|
|
|
|
|
+ - `syncCustomContent()` - Stores chunks in Qdrant
|
|
|
|
|
+ - `deleteCustomContent()` - Removes from Qdrant
|
|
|
|
|
+ - `searchCustomContent()` - Semantic search
|
|
|
|
|
+ - `getCustomContentCollectionName()` - Collection naming
|
|
|
|
|
+- `_shared/mcp-qdrant-helpers.ts` - Extended with `queryQdrantCustomContent()`
|
|
|
|
|
+
|
|
|
|
|
+### 3. Edge Functions (All Deployed ✅)
|
|
|
|
|
+- `custom-content-upload` - Multipart file upload with deduplication
|
|
|
|
|
+- `custom-content-process` - Async PDF processing pipeline
|
|
|
|
|
+- `custom-content-create` - Text entry creation
|
|
|
|
|
+- `custom-content-list` - List all content for a store
|
|
|
|
|
+- `custom-content-delete` - Hard delete from all systems
|
|
|
|
|
+- `custom-content-sync-status` - Real-time status polling
|
|
|
|
|
+
|
|
|
|
|
+All functions are deployed and accessible at:
|
|
|
|
|
+`https://ztklqodcdjeqpsvhlpud.supabase.co/functions/v1/custom-content-*`
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 🔄 Remaining Backend Work
|
|
|
|
|
+
|
|
|
|
|
+### MCP Tool Integration
|
|
|
|
|
+
|
|
|
|
|
+The `shopify_search_custom_content` tool has been added to `mcp-shopify`. The same needs to be done for `mcp-woocommerce` and `mcp-shoprenter`.
|
|
|
|
|
+
|
|
|
|
|
+#### For `mcp-woocommerce/index.ts`:
|
|
|
|
|
+
|
|
|
|
|
+**1. Update imports** (around line 40):
|
|
|
|
|
+```typescript
|
|
|
|
|
+import {
|
|
|
|
|
+ getStoreQdrantConfig,
|
|
|
|
|
+ queryQdrantProducts,
|
|
|
|
|
+ queryQdrantOrders,
|
|
|
|
|
+ queryQdrantCustomers,
|
|
|
|
|
+ queryQdrantCustomContent // ADD THIS LINE
|
|
|
|
|
+} from '../_shared/mcp-qdrant-helpers.ts';
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+**2. Add tool definition to TOOLS array** (before closing `]`):
|
|
|
|
|
+```typescript
|
|
|
|
|
+ {
|
|
|
|
|
+ name: 'woocommerce_search_custom_content',
|
|
|
|
|
+ description: 'Search custom knowledge base (uploaded PDFs and text entries) for relevant information using semantic search. Returns matching content with titles, excerpts, and relevance scores. Use this to find custom documentation, policies, FAQs, or any store-specific information that has been uploaded.',
|
|
|
|
|
+ inputSchema: {
|
|
|
|
|
+ type: 'object',
|
|
|
|
|
+ properties: {
|
|
|
|
|
+ shop_id: {
|
|
|
|
|
+ type: 'string',
|
|
|
|
|
+ description: 'The UUID of the WooCommerce store from the stores table'
|
|
|
|
|
+ },
|
|
|
|
|
+ query: {
|
|
|
|
|
+ type: 'string',
|
|
|
|
|
+ description: 'The search query to find relevant content'
|
|
|
|
|
+ },
|
|
|
|
|
+ limit: {
|
|
|
|
|
+ type: 'number',
|
|
|
|
|
+ description: 'Number of results to return (default: 5, max: 10)'
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ required: ['shop_id', 'query']
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+**3. Add handler function** (before `handleToolCall` function):
|
|
|
|
|
+```typescript
|
|
|
|
|
+/**
|
|
|
|
|
+ * Handle custom content search
|
|
|
|
|
+ */
|
|
|
|
|
+async function handleSearchCustomContent(args: Record<string, any>): Promise<ToolCallResult> {
|
|
|
|
|
+ const { shop_id, query, limit = 5 } = args;
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ // Get store config for Qdrant connection
|
|
|
|
|
+ const qdrantConfig = await getStoreQdrantConfig(shop_id);
|
|
|
|
|
+
|
|
|
|
|
+ if (!qdrantConfig) {
|
|
|
|
|
+ return {
|
|
|
|
|
+ content: [{
|
|
|
|
|
+ type: 'text',
|
|
|
|
|
+ text: JSON.stringify({
|
|
|
|
|
+ error: 'Store configuration not found'
|
|
|
|
|
+ })
|
|
|
|
|
+ }],
|
|
|
|
|
+ isError: true
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Query custom content from Qdrant
|
|
|
|
|
+ const results = await queryQdrantCustomContent(
|
|
|
|
|
+ shop_id,
|
|
|
|
|
+ qdrantConfig.shopname,
|
|
|
|
|
+ query,
|
|
|
|
|
+ Math.min(limit, 10) // Max 10 results
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ if (results.length === 0) {
|
|
|
|
|
+ return {
|
|
|
|
|
+ content: [{
|
|
|
|
|
+ type: 'text',
|
|
|
|
|
+ text: JSON.stringify({
|
|
|
|
|
+ message: 'No custom content found matching your query',
|
|
|
|
|
+ results: []
|
|
|
|
|
+ })
|
|
|
|
|
+ }],
|
|
|
|
|
+ isError: false
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return {
|
|
|
|
|
+ content: [{
|
|
|
|
|
+ type: 'text',
|
|
|
|
|
+ text: JSON.stringify({
|
|
|
|
|
+ results: results.map(r => ({
|
|
|
|
|
+ id: r.id,
|
|
|
|
|
+ title: r.title,
|
|
|
|
|
+ content_type: r.contentType,
|
|
|
|
|
+ excerpt: r.excerpt,
|
|
|
|
|
+ chunk_info: `Chunk ${r.chunkIndex + 1} of ${r.totalChunks}`,
|
|
|
|
|
+ relevance_score: Math.round(r.relevanceScore * 100) / 100,
|
|
|
|
|
+ original_filename: r.originalFilename,
|
|
|
|
|
+ page_count: r.pageCount
|
|
|
|
|
+ })),
|
|
|
|
|
+ total: results.length
|
|
|
|
|
+ })
|
|
|
|
|
+ }],
|
|
|
|
|
+ isError: false
|
|
|
|
|
+ };
|
|
|
|
|
+ } catch (error: any) {
|
|
|
|
|
+ console.error('[MCP WooCommerce] Error searching custom content:', error);
|
|
|
|
|
+ return {
|
|
|
|
|
+ content: [{
|
|
|
|
|
+ type: 'text',
|
|
|
|
|
+ text: JSON.stringify({ error: `Failed to search custom content: ${error instanceof Error ? error.message : 'Unknown error'}` })
|
|
|
|
|
+ }],
|
|
|
|
|
+ isError: true
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+**4. Add case to switch statement** (in `handleToolCall`, before default case):
|
|
|
|
|
+```typescript
|
|
|
|
|
+ case 'woocommerce_search_custom_content':
|
|
|
|
|
+ const customContentValidation = validateParams(args, ['shop_id', 'query']);
|
|
|
|
|
+ if (!customContentValidation.valid) {
|
|
|
|
|
+ return {
|
|
|
|
|
+ content: [{
|
|
|
|
|
+ type: 'text',
|
|
|
|
|
+ text: JSON.stringify({
|
|
|
|
|
+ error: `Missing required parameters: ${customContentValidation.missing?.join(', ')}`
|
|
|
|
|
+ })
|
|
|
|
|
+ }],
|
|
|
|
|
+ isError: true
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+ return await handleSearchCustomContent(args);
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+#### For `mcp-shoprenter/index.ts`:
|
|
|
|
|
+
|
|
|
|
|
+Apply the **exact same changes** as WooCommerce, but replace:
|
|
|
|
|
+- `woocommerce_search_custom_content` → `shoprenter_search_custom_content`
|
|
|
|
|
+- `'WooCommerce store'` → `'ShopRenter store'`
|
|
|
|
|
+- `'[MCP WooCommerce]'` → `'[MCP ShopRenter]'`
|
|
|
|
|
+
|
|
|
|
|
+#### Deploy MCP Changes:
|
|
|
|
|
+```bash
|
|
|
|
|
+cd /data/shopcall/supabase
|
|
|
|
|
+npx supabase functions deploy mcp-shopify mcp-woocommerce mcp-shoprenter --project-ref ztklqodcdjeqpsvhlpud
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 📱 Frontend Implementation Guide
|
|
|
|
|
+
|
|
|
|
|
+### Overview
|
|
|
|
|
+Create a new page `/custom-content` under the AI Configuration menu where users can:
|
|
|
|
|
+- Upload PDF files (with duplicate detection)
|
|
|
|
|
+- Create text entries
|
|
|
|
|
+- View all custom content with sync status
|
|
|
|
|
+- Delete content
|
|
|
|
|
+- See real-time processing status
|
|
|
|
|
+
|
|
|
|
|
+### File Structure
|
|
|
|
|
+```
|
|
|
|
|
+shopcall.ai-main/src/
|
|
|
|
|
+├── pages/
|
|
|
|
|
+│ └── CustomContent.tsx (main page)
|
|
|
|
|
+├── components/
|
|
|
|
|
+│ ├── CustomContentManager.tsx (container component)
|
|
|
|
|
+│ ├── CustomContentUpload.tsx (file upload UI)
|
|
|
|
|
+│ ├── CustomContentTextEntry.tsx (text entry form)
|
|
|
|
|
+│ ├── CustomContentList.tsx (content table/list)
|
|
|
|
|
+│ └── CustomContentStatusBadge.tsx (status indicator)
|
|
|
|
|
+└── App.tsx (add route)
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### Step 1: Create Main Page Component
|
|
|
|
|
+
|
|
|
|
|
+**File**: `shopcall.ai-main/src/pages/CustomContent.tsx`
|
|
|
|
|
+
|
|
|
|
|
+```typescript
|
|
|
|
|
+import { CustomContentManager } from "@/components/CustomContentManager";
|
|
|
|
|
+
|
|
|
|
|
+export default function CustomContent() {
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div className="flex flex-col gap-6 p-6">
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <h1 className="text-3xl font-bold">Custom Content</h1>
|
|
|
|
|
+ <p className="text-muted-foreground mt-2">
|
|
|
|
|
+ Upload PDFs and create text entries for your AI assistant's knowledge base
|
|
|
|
|
+ </p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <CustomContentManager />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ );
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### Step 2: Create Manager Component
|
|
|
|
|
+
|
|
|
|
|
+**File**: `shopcall.ai-main/src/components/CustomContentManager.tsx`
|
|
|
|
|
+
|
|
|
|
|
+```typescript
|
|
|
|
|
+import { useState } from "react";
|
|
|
|
|
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
|
|
|
+import { Button } from "@/components/ui/button";
|
|
|
|
|
+import { FileUp, FileText, Plus } from "lucide-react";
|
|
|
|
|
+import { CustomContentUpload } from "./CustomContentUpload";
|
|
|
|
|
+import { CustomContentTextEntry } from "./CustomContentTextEntry";
|
|
|
|
|
+import { CustomContentList } from "./CustomContentList";
|
|
|
|
|
+import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
|
|
|
|
|
+
|
|
|
|
|
+export function CustomContentManager() {
|
|
|
|
|
+ const [showUpload, setShowUpload] = useState(false);
|
|
|
|
|
+ const [showTextEntry, setShowTextEntry] = useState(false);
|
|
|
|
|
+ const [activeTab, setActiveTab] = useState<string>("all");
|
|
|
|
|
+ const [refreshKey, setRefreshKey] = useState(0);
|
|
|
|
|
+
|
|
|
|
|
+ const handleUploadSuccess = () => {
|
|
|
|
|
+ setShowUpload(false);
|
|
|
|
|
+ setRefreshKey(prev => prev + 1); // Trigger list refresh
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const handleTextEntrySuccess = () => {
|
|
|
|
|
+ setShowTextEntry(false);
|
|
|
|
|
+ setRefreshKey(prev => prev + 1); // Trigger list refresh
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div className="space-y-4">
|
|
|
|
|
+ {/* Action Buttons */}
|
|
|
|
|
+ <div className="flex gap-2">
|
|
|
|
|
+ <Dialog open={showUpload} onOpenChange={setShowUpload}>
|
|
|
|
|
+ <DialogTrigger asChild>
|
|
|
|
|
+ <Button>
|
|
|
|
|
+ <FileUp className="mr-2 h-4 w-4" />
|
|
|
|
|
+ Upload PDF
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ </DialogTrigger>
|
|
|
|
|
+ <DialogContent className="max-w-2xl">
|
|
|
|
|
+ <CustomContentUpload onSuccess={handleUploadSuccess} />
|
|
|
|
|
+ </DialogContent>
|
|
|
|
|
+ </Dialog>
|
|
|
|
|
+
|
|
|
|
|
+ <Dialog open={showTextEntry} onOpenChange={setShowTextEntry}>
|
|
|
|
|
+ <DialogTrigger asChild>
|
|
|
|
|
+ <Button variant="outline">
|
|
|
|
|
+ <Plus className="mr-2 h-4 w-4" />
|
|
|
|
|
+ Add Text Entry
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ </DialogTrigger>
|
|
|
|
|
+ <DialogContent className="max-w-2xl">
|
|
|
|
|
+ <CustomContentTextEntry onSuccess={handleTextEntrySuccess} />
|
|
|
|
|
+ </DialogContent>
|
|
|
|
|
+ </Dialog>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ {/* Tabs */}
|
|
|
|
|
+ <Tabs value={activeTab} onValueChange={setActiveTab}>
|
|
|
|
|
+ <TabsList>
|
|
|
|
|
+ <TabsTrigger value="all">All Content</TabsTrigger>
|
|
|
|
|
+ <TabsTrigger value="pdf_upload">PDFs</TabsTrigger>
|
|
|
|
|
+ <TabsTrigger value="text_entry">Text Entries</TabsTrigger>
|
|
|
|
|
+ </TabsList>
|
|
|
|
|
+
|
|
|
|
|
+ <TabsContent value="all" className="mt-4">
|
|
|
|
|
+ <CustomContentList
|
|
|
|
|
+ contentType={undefined}
|
|
|
|
|
+ refreshKey={refreshKey}
|
|
|
|
|
+ onDelete={() => setRefreshKey(prev => prev + 1)}
|
|
|
|
|
+ />
|
|
|
|
|
+ </TabsContent>
|
|
|
|
|
+
|
|
|
|
|
+ <TabsContent value="pdf_upload" className="mt-4">
|
|
|
|
|
+ <CustomContentList
|
|
|
|
|
+ contentType="pdf_upload"
|
|
|
|
|
+ refreshKey={refreshKey}
|
|
|
|
|
+ onDelete={() => setRefreshKey(prev => prev + 1)}
|
|
|
|
|
+ />
|
|
|
|
|
+ </TabsContent>
|
|
|
|
|
+
|
|
|
|
|
+ <TabsContent value="text_entry" className="mt-4">
|
|
|
|
|
+ <CustomContentList
|
|
|
|
|
+ contentType="text_entry"
|
|
|
|
|
+ refreshKey={refreshKey}
|
|
|
|
|
+ onDelete={() => setRefreshKey(prev => prev + 1)}
|
|
|
|
|
+ />
|
|
|
|
|
+ </TabsContent>
|
|
|
|
|
+ </Tabs>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ );
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### Step 3: Create File Upload Component
|
|
|
|
|
+
|
|
|
|
|
+**File**: `shopcall.ai-main/src/components/CustomContentUpload.tsx`
|
|
|
|
|
+
|
|
|
|
|
+```typescript
|
|
|
|
|
+import { useState, useCallback } from "react";
|
|
|
|
|
+import { useDropzone } from "react-dropzone";
|
|
|
|
|
+import { Button } from "@/components/ui/button";
|
|
|
|
|
+import { Input } from "@/components/ui/input";
|
|
|
|
|
+import { Label } from "@/components/ui/label";
|
|
|
|
|
+import { Alert, AlertDescription } from "@/components/ui/alert";
|
|
|
|
|
+import { Progress } from "@/components/ui/progress";
|
|
|
|
|
+import { Upload, FileText, AlertCircle, CheckCircle2 } from "lucide-react";
|
|
|
|
|
+import { useToast } from "@/hooks/use-toast";
|
|
|
|
|
+
|
|
|
|
|
+interface CustomContentUploadProps {
|
|
|
|
|
+ onSuccess: () => void;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export function CustomContentUpload({ onSuccess }: CustomContentUploadProps) {
|
|
|
|
|
+ const [file, setFile] = useState<File | null>(null);
|
|
|
|
|
+ const [title, setTitle] = useState("");
|
|
|
|
|
+ const [uploading, setUploading] = useState(false);
|
|
|
|
|
+ const [progress, setProgress] = useState(0);
|
|
|
|
|
+ const [error, setError] = useState<string | null>(null);
|
|
|
|
|
+ const { toast } = useToast();
|
|
|
|
|
+
|
|
|
|
|
+ // Get selected shop from context (implement based on your app's shop selector)
|
|
|
|
|
+ const selectedShop = "your-shop-id"; // TODO: Get from context
|
|
|
|
|
+
|
|
|
|
|
+ const onDrop = useCallback((acceptedFiles: File[]) => {
|
|
|
|
|
+ if (acceptedFiles.length > 0) {
|
|
|
|
|
+ const uploadedFile = acceptedFiles[0];
|
|
|
|
|
+ setFile(uploadedFile);
|
|
|
|
|
+ // Auto-fill title with filename (without extension)
|
|
|
|
|
+ const fileName = uploadedFile.name.replace(/\\.pdf$/i, "");
|
|
|
|
|
+ setTitle(fileName);
|
|
|
|
|
+ setError(null);
|
|
|
|
|
+ }
|
|
|
|
|
+ }, []);
|
|
|
|
|
+
|
|
|
|
|
+ const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
|
|
|
|
+ onDrop,
|
|
|
|
|
+ accept: {
|
|
|
|
|
+ 'application/pdf': ['.pdf']
|
|
|
|
|
+ },
|
|
|
|
|
+ maxFiles: 1,
|
|
|
|
|
+ maxSize: 10 * 1024 * 1024, // 10MB
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ const handleUpload = async () => {
|
|
|
|
|
+ if (!file || !title.trim()) {
|
|
|
|
|
+ setError("Please select a file and provide a title");
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ setUploading(true);
|
|
|
|
|
+ setError(null);
|
|
|
|
|
+ setProgress(10);
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ // Create FormData
|
|
|
|
|
+ const formData = new FormData();
|
|
|
|
|
+ formData.append("file", file);
|
|
|
|
|
+ formData.append("title", title.trim());
|
|
|
|
|
+ formData.append("store_id", selectedShop);
|
|
|
|
|
+
|
|
|
|
|
+ setProgress(30);
|
|
|
|
|
+
|
|
|
|
|
+ // Get auth token
|
|
|
|
|
+ const { data: { session } } = await supabase.auth.getSession();
|
|
|
|
|
+ if (!session) {
|
|
|
|
|
+ throw new Error("Not authenticated");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ setProgress(50);
|
|
|
|
|
+
|
|
|
|
|
+ // Upload to Edge Function
|
|
|
|
|
+ const response = await fetch(
|
|
|
|
|
+ `${import.meta.env.VITE_API_URL}/custom-content-upload`,
|
|
|
|
|
+ {
|
|
|
|
|
+ method: "POST",
|
|
|
|
|
+ headers: {
|
|
|
|
|
+ Authorization: `Bearer ${session.access_token}`,
|
|
|
|
|
+ },
|
|
|
|
|
+ body: formData,
|
|
|
|
|
+ }
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ setProgress(80);
|
|
|
|
|
+
|
|
|
|
|
+ const result = await response.json();
|
|
|
|
|
+
|
|
|
|
|
+ if (!response.ok) {
|
|
|
|
|
+ if (result.duplicate) {
|
|
|
|
|
+ throw new Error(
|
|
|
|
|
+ `This file already exists as "${result.existingContent.title}" (uploaded on ${result.existingContent.uploadedAt})`
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+ throw new Error(result.error || "Upload failed");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ setProgress(100);
|
|
|
|
|
+
|
|
|
|
|
+ toast({
|
|
|
|
|
+ title: "Upload successful",
|
|
|
|
|
+ description: `${result.fileName} is being processed. This may take a few moments.`,
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ onSuccess();
|
|
|
|
|
+ } catch (err: any) {
|
|
|
|
|
+ console.error("Upload error:", err);
|
|
|
|
|
+ setError(err.message || "Failed to upload file");
|
|
|
|
|
+ toast({
|
|
|
|
|
+ title: "Upload failed",
|
|
|
|
|
+ description: err.message || "An error occurred during upload",
|
|
|
|
|
+ variant: "destructive",
|
|
|
|
|
+ });
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ setUploading(false);
|
|
|
|
|
+ setProgress(0);
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div className="space-y-4">
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <h2 className="text-lg font-semibold">Upload PDF</h2>
|
|
|
|
|
+ <p className="text-sm text-muted-foreground">
|
|
|
|
|
+ Upload a PDF file to add to your AI assistant's knowledge base
|
|
|
|
|
+ </p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ {/* File Dropzone */}
|
|
|
|
|
+ <div
|
|
|
|
|
+ {...getRootProps()}
|
|
|
|
|
+ className={`
|
|
|
|
|
+ border-2 border-dashed rounded-lg p-8 text-center cursor-pointer
|
|
|
|
|
+ transition-colors
|
|
|
|
|
+ ${isDragActive ? "border-primary bg-primary/5" : "border-gray-300"}
|
|
|
|
|
+ ${file ? "bg-muted" : ""}
|
|
|
|
|
+ `}
|
|
|
|
|
+ >
|
|
|
|
|
+ <input {...getInputProps()} />
|
|
|
|
|
+ {file ? (
|
|
|
|
|
+ <div className="flex items-center justify-center gap-2">
|
|
|
|
|
+ <FileText className="h-8 w-8 text-primary" />
|
|
|
|
|
+ <div className="text-left">
|
|
|
|
|
+ <p className="font-medium">{file.name}</p>
|
|
|
|
|
+ <p className="text-sm text-muted-foreground">
|
|
|
|
|
+ {(file.size / 1024 / 1024).toFixed(2)} MB
|
|
|
|
|
+ </p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <Upload className="h-12 w-12 mx-auto text-muted-foreground mb-2" />
|
|
|
|
|
+ <p className="text-sm">
|
|
|
|
|
+ {isDragActive
|
|
|
|
|
+ ? "Drop the PDF file here"
|
|
|
|
|
+ : "Drag and drop a PDF file here, or click to select"}
|
|
|
|
|
+ </p>
|
|
|
|
|
+ <p className="text-xs text-muted-foreground mt-1">
|
|
|
|
|
+ Maximum file size: 10MB
|
|
|
|
|
+ </p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ {/* Title Input */}
|
|
|
|
|
+ <div className="space-y-2">
|
|
|
|
|
+ <Label htmlFor="title">Title *</Label>
|
|
|
|
|
+ <Input
|
|
|
|
|
+ id="title"
|
|
|
|
|
+ value={title}
|
|
|
|
|
+ onChange={(e) => setTitle(e.target.value)}
|
|
|
|
|
+ placeholder="Enter a descriptive title"
|
|
|
|
|
+ disabled={uploading}
|
|
|
|
|
+ />
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ {/* Progress Bar */}
|
|
|
|
|
+ {uploading && (
|
|
|
|
|
+ <div className="space-y-2">
|
|
|
|
|
+ <Progress value={progress} />
|
|
|
|
|
+ <p className="text-sm text-center text-muted-foreground">
|
|
|
|
|
+ Uploading... {progress}%
|
|
|
|
|
+ </p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+
|
|
|
|
|
+ {/* Error Alert */}
|
|
|
|
|
+ {error && (
|
|
|
|
|
+ <Alert variant="destructive">
|
|
|
|
|
+ <AlertCircle className="h-4 w-4" />
|
|
|
|
|
+ <AlertDescription>{error}</AlertDescription>
|
|
|
|
|
+ </Alert>
|
|
|
|
|
+ )}
|
|
|
|
|
+
|
|
|
|
|
+ {/* Action Buttons */}
|
|
|
|
|
+ <div className="flex justify-end gap-2">
|
|
|
|
|
+ <Button
|
|
|
|
|
+ variant="outline"
|
|
|
|
|
+ onClick={() => onSuccess()}
|
|
|
|
|
+ disabled={uploading}
|
|
|
|
|
+ >
|
|
|
|
|
+ Cancel
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ <Button
|
|
|
|
|
+ onClick={handleUpload}
|
|
|
|
|
+ disabled={!file || !title.trim() || uploading}
|
|
|
|
|
+ >
|
|
|
|
|
+ {uploading ? "Uploading..." : "Upload"}
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ );
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### Step 4: Create Text Entry Component
|
|
|
|
|
+
|
|
|
|
|
+**File**: `shopcall.ai-main/src/components/CustomContentTextEntry.tsx`
|
|
|
|
|
+
|
|
|
|
|
+```typescript
|
|
|
|
|
+import { useState } from "react";
|
|
|
|
|
+import { Button } from "@/components/ui/button";
|
|
|
|
|
+import { Input } from "@/components/ui/input";
|
|
|
|
|
+import { Label } from "@/components/ui/label";
|
|
|
|
|
+import { Textarea } from "@/components/ui/textarea";
|
|
|
|
|
+import { Alert, AlertDescription } from "@/components/ui/alert";
|
|
|
|
|
+import { AlertCircle } from "lucide-react";
|
|
|
|
|
+import { useToast } from "@/hooks/use-toast";
|
|
|
|
|
+
|
|
|
|
|
+interface CustomContentTextEntryProps {
|
|
|
|
|
+ onSuccess: () => void;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export function CustomContentTextEntry({ onSuccess }: CustomContentTextEntryProps) {
|
|
|
|
|
+ const [title, setTitle] = useState("");
|
|
|
|
|
+ const [content, setContent] = useState("");
|
|
|
|
|
+ const [saving, setSaving] = useState(false);
|
|
|
|
|
+ const [error, setError] = useState<string | null>(null);
|
|
|
|
|
+ const { toast } = useToast();
|
|
|
|
|
+
|
|
|
|
|
+ // Get selected shop from context
|
|
|
|
|
+ const selectedShop = "your-shop-id"; // TODO: Get from context
|
|
|
|
|
+
|
|
|
|
|
+ const handleSave = async () => {
|
|
|
|
|
+ if (!title.trim() || !content.trim()) {
|
|
|
|
|
+ setError("Please provide both title and content");
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (content.length > 50000) {
|
|
|
|
|
+ setError("Content exceeds maximum length of 50,000 characters");
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ setSaving(true);
|
|
|
|
|
+ setError(null);
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ // Get auth token
|
|
|
|
|
+ const { data: { session } } = await supabase.auth.getSession();
|
|
|
|
|
+ if (!session) {
|
|
|
|
|
+ throw new Error("Not authenticated");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Create entry via Edge Function
|
|
|
|
|
+ const response = await fetch(
|
|
|
|
|
+ `${import.meta.env.VITE_API_URL}/custom-content-create`,
|
|
|
|
|
+ {
|
|
|
|
|
+ method: "POST",
|
|
|
|
|
+ headers: {
|
|
|
|
|
+ Authorization: `Bearer ${session.access_token}`,
|
|
|
|
|
+ "Content-Type": "application/json",
|
|
|
|
|
+ },
|
|
|
|
|
+ body: JSON.stringify({
|
|
|
|
|
+ title: title.trim(),
|
|
|
|
|
+ content_text: content.trim(),
|
|
|
|
|
+ store_id: selectedShop,
|
|
|
|
|
+ }),
|
|
|
|
|
+ }
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ const result = await response.json();
|
|
|
|
|
+
|
|
|
|
|
+ if (!response.ok) {
|
|
|
|
|
+ throw new Error(result.error || "Failed to create entry");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ toast({
|
|
|
|
|
+ title: "Entry created",
|
|
|
|
|
+ description: `"${title}" has been added to your knowledge base`,
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ onSuccess();
|
|
|
|
|
+ } catch (err: any) {
|
|
|
|
|
+ console.error("Create error:", err);
|
|
|
|
|
+ setError(err.message || "Failed to create entry");
|
|
|
|
|
+ toast({
|
|
|
|
|
+ title: "Creation failed",
|
|
|
|
|
+ description: err.message || "An error occurred",
|
|
|
|
|
+ variant: "destructive",
|
|
|
|
|
+ });
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ setSaving(false);
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div className="space-y-4">
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <h2 className="text-lg font-semibold">Add Text Entry</h2>
|
|
|
|
|
+ <p className="text-sm text-muted-foreground">
|
|
|
|
|
+ Create a custom text entry for your AI assistant's knowledge base
|
|
|
|
|
+ </p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ {/* Title Input */}
|
|
|
|
|
+ <div className="space-y-2">
|
|
|
|
|
+ <Label htmlFor="entry-title">Title *</Label>
|
|
|
|
|
+ <Input
|
|
|
|
|
+ id="entry-title"
|
|
|
|
|
+ value={title}
|
|
|
|
|
+ onChange={(e) => setTitle(e.target.value)}
|
|
|
|
|
+ placeholder="Enter a title"
|
|
|
|
|
+ disabled={saving}
|
|
|
|
|
+ />
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ {/* Content Textarea */}
|
|
|
|
|
+ <div className="space-y-2">
|
|
|
|
|
+ <Label htmlFor="entry-content">Content *</Label>
|
|
|
|
|
+ <Textarea
|
|
|
|
|
+ id="entry-content"
|
|
|
|
|
+ value={content}
|
|
|
|
|
+ onChange={(e) => setContent(e.target.value)}
|
|
|
|
|
+ placeholder="Enter your content here..."
|
|
|
|
|
+ rows={12}
|
|
|
|
|
+ disabled={saving}
|
|
|
|
|
+ className="resize-none"
|
|
|
|
|
+ />
|
|
|
|
|
+ <p className="text-xs text-muted-foreground text-right">
|
|
|
|
|
+ {content.length.toLocaleString()} / 50,000 characters
|
|
|
|
|
+ </p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ {/* Error Alert */}
|
|
|
|
|
+ {error && (
|
|
|
|
|
+ <Alert variant="destructive">
|
|
|
|
|
+ <AlertCircle className="h-4 w-4" />
|
|
|
|
|
+ <AlertDescription>{error}</AlertDescription>
|
|
|
|
|
+ </Alert>
|
|
|
|
|
+ )}
|
|
|
|
|
+
|
|
|
|
|
+ {/* Action Buttons */}
|
|
|
|
|
+ <div className="flex justify-end gap-2">
|
|
|
|
|
+ <Button
|
|
|
|
|
+ variant="outline"
|
|
|
|
|
+ onClick={() => onSuccess()}
|
|
|
|
|
+ disabled={saving}
|
|
|
|
|
+ >
|
|
|
|
|
+ Cancel
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ <Button
|
|
|
|
|
+ onClick={handleSave}
|
|
|
|
|
+ disabled={!title.trim() || !content.trim() || saving}
|
|
|
|
|
+ >
|
|
|
|
|
+ {saving ? "Saving..." : "Save"}
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ );
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### Step 5: Create Content List Component
|
|
|
|
|
+
|
|
|
|
|
+**File**: `shopcall.ai-main/src/components/CustomContentList.tsx`
|
|
|
|
|
+
|
|
|
|
|
+```typescript
|
|
|
|
|
+import { useEffect, useState } from "react";
|
|
|
|
|
+import { useQuery } from "@tanstack/react-query";
|
|
|
|
|
+import {
|
|
|
|
|
+ Table,
|
|
|
|
|
+ TableBody,
|
|
|
|
|
+ TableCell,
|
|
|
|
|
+ TableHead,
|
|
|
|
|
+ TableHeader,
|
|
|
|
|
+ TableRow,
|
|
|
|
|
+} from "@/components/ui/table";
|
|
|
|
|
+import { Button } from "@/components/ui/button";
|
|
|
|
|
+import { Trash2, FileText, File } from "lucide-react";
|
|
|
|
|
+import { CustomContentStatusBadge } from "./CustomContentStatusBadge";
|
|
|
|
|
+import { useToast } from "@/hooks/use-toast";
|
|
|
|
|
+import {
|
|
|
|
|
+ AlertDialog,
|
|
|
|
|
+ AlertDialogAction,
|
|
|
|
|
+ AlertDialogCancel,
|
|
|
|
|
+ AlertDialogContent,
|
|
|
|
|
+ AlertDialogDescription,
|
|
|
|
|
+ AlertDialogFooter,
|
|
|
|
|
+ AlertDialogHeader,
|
|
|
|
|
+ AlertDialogTitle,
|
|
|
|
|
+} from "@/components/ui/alert-dialog";
|
|
|
|
|
+
|
|
|
|
|
+interface CustomContentListProps {
|
|
|
|
|
+ contentType?: "text_entry" | "pdf_upload";
|
|
|
|
|
+ refreshKey: number;
|
|
|
|
|
+ onDelete: () => void;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+interface ContentItem {
|
|
|
|
|
+ id: string;
|
|
|
|
|
+ title: string;
|
|
|
|
|
+ contentType: string;
|
|
|
|
|
+ originalFilename?: string;
|
|
|
|
|
+ fileSize?: number;
|
|
|
|
|
+ pageCount?: number;
|
|
|
|
|
+ chunkCount: number;
|
|
|
|
|
+ syncStatus: string;
|
|
|
|
|
+ syncError?: string;
|
|
|
|
|
+ createdAt: string;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export function CustomContentList({
|
|
|
|
|
+ contentType,
|
|
|
|
|
+ refreshKey,
|
|
|
|
|
+ onDelete
|
|
|
|
|
+}: CustomContentListProps) {
|
|
|
|
|
+ const [deleteId, setDeleteId] = useState<string | null>(null);
|
|
|
|
|
+ const { toast } = useToast();
|
|
|
|
|
+
|
|
|
|
|
+ // Get selected shop from context
|
|
|
|
|
+ const selectedShop = "your-shop-id"; // TODO: Get from context
|
|
|
|
|
+
|
|
|
|
|
+ // Fetch content list
|
|
|
|
|
+ const { data, isLoading, refetch } = useQuery({
|
|
|
|
|
+ queryKey: ["custom-content", selectedShop, contentType, refreshKey],
|
|
|
|
|
+ queryFn: async () => {
|
|
|
|
|
+ const { data: { session } } = await supabase.auth.getSession();
|
|
|
|
|
+ if (!session) throw new Error("Not authenticated");
|
|
|
|
|
+
|
|
|
|
|
+ const params = new URLSearchParams({
|
|
|
|
|
+ store_id: selectedShop,
|
|
|
|
|
+ ...(contentType && { content_type: contentType }),
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ const response = await fetch(
|
|
|
|
|
+ `${import.meta.env.VITE_API_URL}/custom-content-list?${params}`,
|
|
|
|
|
+ {
|
|
|
|
|
+ headers: {
|
|
|
|
|
+ Authorization: `Bearer ${session.access_token}`,
|
|
|
|
|
+ },
|
|
|
|
|
+ }
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ if (!response.ok) {
|
|
|
|
|
+ throw new Error("Failed to fetch content");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const result = await response.json();
|
|
|
|
|
+ return result.content as ContentItem[];
|
|
|
|
|
+ },
|
|
|
|
|
+ enabled: !!selectedShop,
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // Refetch on refreshKey change
|
|
|
|
|
+ useEffect(() => {
|
|
|
|
|
+ if (refreshKey > 0) {
|
|
|
|
|
+ refetch();
|
|
|
|
|
+ }
|
|
|
|
|
+ }, [refreshKey, refetch]);
|
|
|
|
|
+
|
|
|
|
|
+ const handleDelete = async (id: string) => {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const { data: { session } } = await supabase.auth.getSession();
|
|
|
|
|
+ if (!session) throw new Error("Not authenticated");
|
|
|
|
|
+
|
|
|
|
|
+ const response = await fetch(
|
|
|
|
|
+ `${import.meta.env.VITE_API_URL}/custom-content-delete`,
|
|
|
|
|
+ {
|
|
|
|
|
+ method: "DELETE",
|
|
|
|
|
+ headers: {
|
|
|
|
|
+ Authorization: `Bearer ${session.access_token}`,
|
|
|
|
|
+ "Content-Type": "application/json",
|
|
|
|
|
+ },
|
|
|
|
|
+ body: JSON.stringify({
|
|
|
|
|
+ content_id: id,
|
|
|
|
|
+ store_id: selectedShop,
|
|
|
|
|
+ }),
|
|
|
|
|
+ }
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ if (!response.ok) {
|
|
|
|
|
+ throw new Error("Failed to delete content");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ toast({
|
|
|
|
|
+ title: "Content deleted",
|
|
|
|
|
+ description: "The content has been removed from your knowledge base",
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ onDelete();
|
|
|
|
|
+ } catch (err: any) {
|
|
|
|
|
+ toast({
|
|
|
|
|
+ title: "Deletion failed",
|
|
|
|
|
+ description: err.message || "An error occurred",
|
|
|
|
|
+ variant: "destructive",
|
|
|
|
|
+ });
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ setDeleteId(null);
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ if (isLoading) {
|
|
|
|
|
+ return <div className="text-center py-8">Loading...</div>;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (!data || data.length === 0) {
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div className="text-center py-8 text-muted-foreground">
|
|
|
|
|
+ No custom content yet. Upload a PDF or create a text entry to get started.
|
|
|
|
|
+ </div>
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return (
|
|
|
|
|
+ <>
|
|
|
|
|
+ <Table>
|
|
|
|
|
+ <TableHeader>
|
|
|
|
|
+ <TableRow>
|
|
|
|
|
+ <TableHead>Type</TableHead>
|
|
|
|
|
+ <TableHead>Title</TableHead>
|
|
|
|
|
+ <TableHead>Details</TableHead>
|
|
|
|
|
+ <TableHead>Status</TableHead>
|
|
|
|
|
+ <TableHead>Chunks</TableHead>
|
|
|
|
|
+ <TableHead>Created</TableHead>
|
|
|
|
|
+ <TableHead className="text-right">Actions</TableHead>
|
|
|
|
|
+ </TableRow>
|
|
|
|
|
+ </TableHeader>
|
|
|
|
|
+ <TableBody>
|
|
|
|
|
+ {data.map((item) => (
|
|
|
|
|
+ <TableRow key={item.id}>
|
|
|
|
|
+ <TableCell>
|
|
|
|
|
+ {item.contentType === "pdf_upload" ? (
|
|
|
|
|
+ <File className="h-4 w-4 text-red-500" />
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ <FileText className="h-4 w-4 text-blue-500" />
|
|
|
|
|
+ )}
|
|
|
|
|
+ </TableCell>
|
|
|
|
|
+ <TableCell className="font-medium">{item.title}</TableCell>
|
|
|
|
|
+ <TableCell className="text-sm text-muted-foreground">
|
|
|
|
|
+ {item.contentType === "pdf_upload" ? (
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <div>{item.originalFilename}</div>
|
|
|
|
|
+ <div>
|
|
|
|
|
+ {item.pageCount} pages • {(item.fileSize! / 1024).toFixed(0)} KB
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ <div>Text entry</div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </TableCell>
|
|
|
|
|
+ <TableCell>
|
|
|
|
|
+ <CustomContentStatusBadge
|
|
|
|
|
+ contentId={item.id}
|
|
|
|
|
+ status={item.syncStatus}
|
|
|
|
|
+ error={item.syncError}
|
|
|
|
|
+ />
|
|
|
|
|
+ </TableCell>
|
|
|
|
|
+ <TableCell>{item.chunkCount || 0}</TableCell>
|
|
|
|
|
+ <TableCell>
|
|
|
|
|
+ {new Date(item.createdAt).toLocaleDateString()}
|
|
|
|
|
+ </TableCell>
|
|
|
|
|
+ <TableCell className="text-right">
|
|
|
|
|
+ <Button
|
|
|
|
|
+ variant="ghost"
|
|
|
|
|
+ size="icon"
|
|
|
|
|
+ onClick={() => setDeleteId(item.id)}
|
|
|
|
|
+ >
|
|
|
|
|
+ <Trash2 className="h-4 w-4" />
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ </TableCell>
|
|
|
|
|
+ </TableRow>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </TableBody>
|
|
|
|
|
+ </Table>
|
|
|
|
|
+
|
|
|
|
|
+ {/* Delete Confirmation Dialog */}
|
|
|
|
|
+ <AlertDialog open={!!deleteId} onOpenChange={() => setDeleteId(null)}>
|
|
|
|
|
+ <AlertDialogContent>
|
|
|
|
|
+ <AlertDialogHeader>
|
|
|
|
|
+ <AlertDialogTitle>Delete content?</AlertDialogTitle>
|
|
|
|
|
+ <AlertDialogDescription>
|
|
|
|
|
+ This will permanently remove the content from your knowledge base and cannot be undone.
|
|
|
|
|
+ </AlertDialogDescription>
|
|
|
|
|
+ </AlertDialogHeader>
|
|
|
|
|
+ <AlertDialogFooter>
|
|
|
|
|
+ <AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
|
|
|
+ <AlertDialogAction
|
|
|
|
|
+ onClick={() => deleteId && handleDelete(deleteId)}
|
|
|
|
|
+ className="bg-destructive text-destructive-foreground"
|
|
|
|
|
+ >
|
|
|
|
|
+ Delete
|
|
|
|
|
+ </AlertDialogAction>
|
|
|
|
|
+ </AlertDialogFooter>
|
|
|
|
|
+ </AlertDialogContent>
|
|
|
|
|
+ </AlertDialog>
|
|
|
|
|
+ </>
|
|
|
|
|
+ );
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### Step 6: Create Status Badge Component
|
|
|
|
|
+
|
|
|
|
|
+**File**: `shopcall.ai-main/src/components/CustomContentStatusBadge.tsx`
|
|
|
|
|
+
|
|
|
|
|
+```typescript
|
|
|
|
|
+import { useEffect, useState } from "react";
|
|
|
|
|
+import { Badge } from "@/components/ui/badge";
|
|
|
|
|
+import { Loader2, CheckCircle2, AlertCircle, Clock } from "lucide-react";
|
|
|
|
|
+import {
|
|
|
|
|
+ Tooltip,
|
|
|
|
|
+ TooltipContent,
|
|
|
|
|
+ TooltipProvider,
|
|
|
|
|
+ TooltipTrigger,
|
|
|
|
|
+} from "@/components/ui/tooltip";
|
|
|
|
|
+
|
|
|
|
|
+interface CustomContentStatusBadgeProps {
|
|
|
|
|
+ contentId: string;
|
|
|
|
|
+ status: string;
|
|
|
|
|
+ error?: string;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export function CustomContentStatusBadge({
|
|
|
|
|
+ contentId,
|
|
|
|
|
+ status: initialStatus,
|
|
|
|
|
+ error,
|
|
|
|
|
+}: CustomContentStatusBadgeProps) {
|
|
|
|
|
+ const [status, setStatus] = useState(initialStatus);
|
|
|
|
|
+ const [polling, setPolling] = useState(
|
|
|
|
|
+ initialStatus === "pending" || initialStatus === "processing"
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ useEffect(() => {
|
|
|
|
|
+ if (!polling) return;
|
|
|
|
|
+
|
|
|
|
|
+ const pollStatus = async () => {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const { data: { session } } = await supabase.auth.getSession();
|
|
|
|
|
+ if (!session) return;
|
|
|
|
|
+
|
|
|
|
|
+ const response = await fetch(
|
|
|
|
|
+ `${import.meta.env.VITE_API_URL}/custom-content-sync-status?content_id=${contentId}`,
|
|
|
|
|
+ {
|
|
|
|
|
+ headers: {
|
|
|
|
|
+ Authorization: `Bearer ${session.access_token}`,
|
|
|
|
|
+ },
|
|
|
|
|
+ }
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ if (response.ok) {
|
|
|
|
|
+ const result = await response.json();
|
|
|
|
|
+ setStatus(result.status);
|
|
|
|
|
+
|
|
|
|
|
+ // Stop polling if completed or failed
|
|
|
|
|
+ if (result.status === "completed" || result.status === "failed") {
|
|
|
|
|
+ setPolling(false);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ console.error("Status poll error:", err);
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ // Poll every 3 seconds
|
|
|
|
|
+ const interval = setInterval(pollStatus, 3000);
|
|
|
|
|
+ pollStatus(); // Initial poll
|
|
|
|
|
+
|
|
|
|
|
+ return () => clearInterval(interval);
|
|
|
|
|
+ }, [contentId, polling]);
|
|
|
|
|
+
|
|
|
|
|
+ const getStatusDisplay = () => {
|
|
|
|
|
+ switch (status) {
|
|
|
|
|
+ case "pending":
|
|
|
|
|
+ return {
|
|
|
|
|
+ label: "Pending",
|
|
|
|
|
+ icon: <Clock className="h-3 w-3" />,
|
|
|
|
|
+ variant: "secondary" as const,
|
|
|
|
|
+ tooltip: "Waiting to start processing",
|
|
|
|
|
+ };
|
|
|
|
|
+ case "processing":
|
|
|
|
|
+ return {
|
|
|
|
|
+ label: "Processing",
|
|
|
|
|
+ icon: <Loader2 className="h-3 w-3 animate-spin" />,
|
|
|
|
|
+ variant: "default" as const,
|
|
|
|
|
+ tooltip: "Extracting text and generating embeddings",
|
|
|
|
|
+ };
|
|
|
|
|
+ case "completed":
|
|
|
|
|
+ return {
|
|
|
|
|
+ label: "Ready",
|
|
|
|
|
+ icon: <CheckCircle2 className="h-3 w-3" />,
|
|
|
|
|
+ variant: "success" as const,
|
|
|
|
|
+ tooltip: "Successfully added to knowledge base",
|
|
|
|
|
+ };
|
|
|
|
|
+ case "failed":
|
|
|
|
|
+ return {
|
|
|
|
|
+ label: "Failed",
|
|
|
|
|
+ icon: <AlertCircle className="h-3 w-3" />,
|
|
|
|
|
+ variant: "destructive" as const,
|
|
|
|
|
+ tooltip: error || "Processing failed",
|
|
|
|
|
+ };
|
|
|
|
|
+ default:
|
|
|
|
|
+ return {
|
|
|
|
|
+ label: status,
|
|
|
|
|
+ icon: null,
|
|
|
|
|
+ variant: "secondary" as const,
|
|
|
|
|
+ tooltip: "",
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const statusDisplay = getStatusDisplay();
|
|
|
|
|
+
|
|
|
|
|
+ return (
|
|
|
|
|
+ <TooltipProvider>
|
|
|
|
|
+ <Tooltip>
|
|
|
|
|
+ <TooltipTrigger asChild>
|
|
|
|
|
+ <Badge variant={statusDisplay.variant} className="gap-1">
|
|
|
|
|
+ {statusDisplay.icon}
|
|
|
|
|
+ {statusDisplay.label}
|
|
|
|
|
+ </Badge>
|
|
|
|
|
+ </TooltipTrigger>
|
|
|
|
|
+ <TooltipContent>
|
|
|
|
|
+ <p>{statusDisplay.tooltip}</p>
|
|
|
|
|
+ </TooltipContent>
|
|
|
|
|
+ </Tooltip>
|
|
|
|
|
+ </TooltipProvider>
|
|
|
|
|
+ );
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### Step 7: Update Navigation
|
|
|
|
|
+
|
|
|
|
|
+**File**: `shopcall.ai-main/src/components/ui/app-sidebar.tsx`
|
|
|
|
|
+
|
|
|
|
|
+Find the AI Configuration menu items and add:
|
|
|
|
|
+
|
|
|
|
|
+```typescript
|
|
|
|
|
+{
|
|
|
|
|
+ title: "Custom Content",
|
|
|
|
|
+ url: "/custom-content",
|
|
|
|
|
+ icon: Database, // or FileText
|
|
|
|
|
+},
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+**File**: `shopcall.ai-main/src/App.tsx`
|
|
|
|
|
+
|
|
|
|
|
+Add the route inside the PrivateRoute wrapper:
|
|
|
|
|
+
|
|
|
|
|
+```typescript
|
|
|
|
|
+<Route path="/custom-content" element={<CustomContent />} />
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 🧪 Testing Checklist
|
|
|
|
|
+
|
|
|
|
|
+### Backend Testing:
|
|
|
|
|
+- [ ] Run migration and verify `custom_content` table exists
|
|
|
|
|
+- [ ] Verify Storage bucket `custom-content` is created
|
|
|
|
|
+- [ ] Test PDF upload endpoint (duplicate detection)
|
|
|
|
|
+- [ ] Test text entry creation endpoint
|
|
|
|
|
+- [ ] Test list endpoint with filters
|
|
|
|
|
+- [ ] Test delete endpoint (verify Qdrant + Storage cleanup)
|
|
|
|
|
+- [ ] Test MCP tool: `shopify_search_custom_content`
|
|
|
|
|
+- [ ] Test MCP tool: `woocommerce_search_custom_content`
|
|
|
|
|
+- [ ] Test MCP tool: `shoprenter_search_custom_content`
|
|
|
|
|
+
|
|
|
|
|
+### Frontend Testing:
|
|
|
|
|
+- [ ] Navigate to `/custom-content` page
|
|
|
|
|
+- [ ] Upload small PDF (<1MB)
|
|
|
|
|
+- [ ] Upload large PDF (>5MB)
|
|
|
|
|
+- [ ] Test duplicate file upload (should reject)
|
|
|
|
|
+- [ ] Create text entry
|
|
|
|
|
+- [ ] View all content (tabs work)
|
|
|
|
|
+- [ ] Watch real-time status updates
|
|
|
|
|
+- [ ] Delete content (confirm dialog)
|
|
|
|
|
+- [ ] Verify content appears in AI assistant responses
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 📚 API Endpoints Reference
|
|
|
|
|
+
|
|
|
|
|
+| Endpoint | Method | Description |
|
|
|
|
|
+|----------|--------|-------------|
|
|
|
|
|
+| `/custom-content-upload` | POST | Upload PDF file |
|
|
|
|
|
+| `/custom-content-process` | POST | Process PDF (internal) |
|
|
|
|
|
+| `/custom-content-create` | POST | Create text entry |
|
|
|
|
|
+| `/custom-content-list` | GET | List all content |
|
|
|
|
|
+| `/custom-content-delete` | DELETE | Delete content |
|
|
|
|
|
+| `/custom-content-sync-status` | GET | Get sync status |
|
|
|
|
|
+
|
|
|
|
|
+All endpoints require `Authorization: Bearer <token>` header.
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 🔍 Troubleshooting
|
|
|
|
|
+
|
|
|
|
|
+### PDF Processing Fails
|
|
|
|
|
+- Check Edge Function logs: `npx supabase functions logs custom-content-process`
|
|
|
|
|
+- Verify `OPENROUTER_API_KEY` is set in Supabase secrets
|
|
|
|
|
+- Check PDF is valid (not corrupted or password-protected)
|
|
|
|
|
+
|
|
|
|
|
+### Qdrant Sync Fails
|
|
|
|
|
+- Verify Qdrant URL and API key in `stores` table or environment
|
|
|
|
|
+- Check Qdrant collection exists: `{shopname}-custom-content`
|
|
|
|
|
+- Review Qdrant logs for connection errors
|
|
|
|
|
+
|
|
|
|
|
+### Upload Returns 413 Error
|
|
|
|
|
+- File exceeds 10MB limit
|
|
|
|
|
+- Adjust `file_size_limit` in Storage bucket settings
|
|
|
|
|
+
|
|
|
|
|
+### Status Never Updates
|
|
|
|
|
+- Frontend not polling (check interval is running)
|
|
|
|
|
+- Edge Function crashed during processing
|
|
|
|
|
+- Check sync status endpoint returns data
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 📊 Database Queries for Debugging
|
|
|
|
|
+
|
|
|
|
|
+```sql
|
|
|
|
|
+-- View all custom content
|
|
|
|
|
+SELECT * FROM custom_content ORDER BY created_at DESC;
|
|
|
|
|
+
|
|
|
|
|
+-- View content by status
|
|
|
|
|
+SELECT title, content_type, sync_status, created_at
|
|
|
|
|
+FROM custom_content
|
|
|
|
|
+WHERE sync_status = 'failed';
|
|
|
|
|
+
|
|
|
|
|
+-- Get content statistics
|
|
|
|
|
+SELECT * FROM get_custom_content_stats('store-uuid-here');
|
|
|
|
|
+
|
|
|
|
|
+-- View storage files
|
|
|
|
|
+SELECT * FROM storage.objects
|
|
|
|
|
+WHERE bucket_id = 'custom-content';
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 🎉 Summary
|
|
|
|
|
+
|
|
|
|
|
+Once completed, users will be able to:
|
|
|
|
|
+1. ✅ Upload PDF files with automatic deduplication
|
|
|
|
|
+2. ✅ Create text entries for custom knowledge
|
|
|
|
|
+3. ✅ View all content with real-time sync status
|
|
|
|
|
+4. ✅ Delete content (hard delete from all systems)
|
|
|
|
|
+5. ✅ AI assistants can search this content via MCP tools
|
|
|
|
|
+
|
|
|
|
|
+The backend is **fully deployed and operational**. The frontend implementation should take approximately 4-6 hours for an experienced React developer.
|