|
|
@@ -1,217 +1,234 @@
|
|
|
/**
|
|
|
- * Data Sources — Lyro-inspired AI Agent knowledge base & API connections
|
|
|
- * Manage URL imports, file uploads, Q&A pairs, and external API connections
|
|
|
+ * Knowledge Management — Q&A Pairs, Products, and Suggestions
|
|
|
+ * Three tabs: Data Source (Q&A), Product, Suggestions
|
|
|
*/
|
|
|
-import { useState } from "react";
|
|
|
+import { useState, useMemo } from "react";
|
|
|
import { trpc } from "@/lib/trpc";
|
|
|
import { Button } from "@/components/ui/button";
|
|
|
import { toast } from "sonner";
|
|
|
import {
|
|
|
- Database, Globe, FileText, MessageCircle, Plug,
|
|
|
- Plus, Trash2, RefreshCw, CheckCircle2, AlertTriangle,
|
|
|
- XCircle, ExternalLink, Play, Settings, ChevronRight,
|
|
|
- Package, Truck, RotateCw, Search, Zap,
|
|
|
- ArrowRight, Code2, TestTube,
|
|
|
+ Database, Plus, Trash2, RefreshCw, CheckCircle2, XCircle,
|
|
|
+ Edit2, Upload, Search, Package, Lightbulb, FileText, Save, X,
|
|
|
} from "lucide-react";
|
|
|
|
|
|
-/* ─── Action Templates (Lyro-style) ─── */
|
|
|
-const ACTION_TEMPLATES = [
|
|
|
- {
|
|
|
- id: "check_order",
|
|
|
- name: "Check Order Status",
|
|
|
- description: "Look up order status by order number or customer ID",
|
|
|
- category: "orders",
|
|
|
- icon: <Package className="w-4 h-4" />,
|
|
|
- color: "#14532D",
|
|
|
- method: "GET" as const,
|
|
|
- endpoint: "/api/orders/{orderId}/status",
|
|
|
- inputVars: [
|
|
|
- { name: "orderId", type: "string", description: "Order number", required: true },
|
|
|
- ],
|
|
|
- outputVars: [
|
|
|
- { name: "status", type: "string", description: "Current order status" },
|
|
|
- { name: "estimatedDelivery", type: "string", description: "Estimated delivery date" },
|
|
|
- { name: "trackingNumber", type: "string", description: "Shipping tracking number" },
|
|
|
- ],
|
|
|
- },
|
|
|
- {
|
|
|
- id: "track_shipment",
|
|
|
- name: "Track Shipment",
|
|
|
- description: "Get real-time shipping tracking information",
|
|
|
- category: "shipping",
|
|
|
- icon: <Truck className="w-4 h-4" />,
|
|
|
- color: "#0369a1",
|
|
|
- method: "GET" as const,
|
|
|
- endpoint: "/api/shipping/{trackingNumber}",
|
|
|
- inputVars: [
|
|
|
- { name: "trackingNumber", type: "string", description: "Tracking number", required: true },
|
|
|
- ],
|
|
|
- outputVars: [
|
|
|
- { name: "carrier", type: "string", description: "Shipping carrier" },
|
|
|
- { name: "currentLocation", type: "string", description: "Current package location" },
|
|
|
- { name: "estimatedArrival", type: "string", description: "ETA" },
|
|
|
- { name: "events", type: "array", description: "Tracking event history" },
|
|
|
- ],
|
|
|
- },
|
|
|
- {
|
|
|
- id: "return_request",
|
|
|
- name: "Submit Return Request",
|
|
|
- description: "Initiate a return for an order item",
|
|
|
- category: "returning",
|
|
|
- icon: <RotateCw className="w-4 h-4" />,
|
|
|
- color: "#ca8a04",
|
|
|
- method: "POST" as const,
|
|
|
- endpoint: "/api/returns",
|
|
|
- inputVars: [
|
|
|
- { name: "orderId", type: "string", description: "Original order number", required: true },
|
|
|
- { name: "itemId", type: "string", description: "Item to return", required: true },
|
|
|
- { name: "reason", type: "string", description: "Return reason", required: true },
|
|
|
- ],
|
|
|
- outputVars: [
|
|
|
- { name: "returnId", type: "string", description: "Return authorization number" },
|
|
|
- { name: "returnLabel", type: "string", description: "Shipping label URL" },
|
|
|
- { name: "refundEstimate", type: "number", description: "Estimated refund amount" },
|
|
|
- ],
|
|
|
- },
|
|
|
- {
|
|
|
- id: "cancel_order",
|
|
|
- name: "Cancel Order",
|
|
|
- description: "Cancel an order that hasn't shipped yet",
|
|
|
- category: "cancelling",
|
|
|
- icon: <XCircle className="w-4 h-4" />,
|
|
|
- color: "#dc2626",
|
|
|
- method: "POST" as const,
|
|
|
- endpoint: "/api/orders/{orderId}/cancel",
|
|
|
- inputVars: [
|
|
|
- { name: "orderId", type: "string", description: "Order to cancel", required: true },
|
|
|
- { name: "reason", type: "string", description: "Cancellation reason", required: false },
|
|
|
- ],
|
|
|
- outputVars: [
|
|
|
- { name: "cancelled", type: "boolean", description: "Whether cancellation succeeded" },
|
|
|
- { name: "refundAmount", type: "number", description: "Refund amount" },
|
|
|
- { name: "message", type: "string", description: "Status message" },
|
|
|
- ],
|
|
|
- },
|
|
|
- {
|
|
|
- id: "customer_lookup",
|
|
|
- name: "Customer Lookup",
|
|
|
- description: "Look up customer account details from CRM",
|
|
|
- category: "customer",
|
|
|
- icon: <Search className="w-4 h-4" />,
|
|
|
- color: "#7c3aed",
|
|
|
- method: "GET" as const,
|
|
|
- endpoint: "/api/crm/customers/{customerId}",
|
|
|
- inputVars: [
|
|
|
- { name: "customerId", type: "string", description: "Customer ID or email", required: true },
|
|
|
- ],
|
|
|
- outputVars: [
|
|
|
- { name: "name", type: "string", description: "Customer name" },
|
|
|
- { name: "email", type: "string", description: "Email address" },
|
|
|
- { name: "accountType", type: "string", description: "Account type (dealer/retail)" },
|
|
|
- { name: "territory", type: "string", description: "Sales territory" },
|
|
|
- { name: "assignedRep", type: "string", description: "Assigned sales representative" },
|
|
|
- ],
|
|
|
- },
|
|
|
-];
|
|
|
-
|
|
|
/* ─── Tab type ─── */
|
|
|
-type TabId = "sources" | "connections" | "templates";
|
|
|
-
|
|
|
-/* ─── Source type config ─── */
|
|
|
-const SOURCE_TYPES = {
|
|
|
- url: { icon: <Globe className="w-4 h-4" />, color: "#0369a1", label: "URL Import" },
|
|
|
- file: { icon: <FileText className="w-4 h-4" />, color: "#7c3aed", label: "File Upload" },
|
|
|
- qa_pair: { icon: <MessageCircle className="w-4 h-4" />, color: "#14532D", label: "Q&A Pair" },
|
|
|
- api: { icon: <Plug className="w-4 h-4" />, color: "#ca8a04", label: "API Connection" },
|
|
|
-};
|
|
|
-
|
|
|
-const STATUS_STYLES: Record<string, { bg: string; text: string; icon: React.ReactNode }> = {
|
|
|
- active: { bg: "#14532D14", text: "#14532D", icon: <CheckCircle2 className="w-3 h-3" /> },
|
|
|
- inactive: { bg: "#78716C14", text: "#78716C", icon: <XCircle className="w-3 h-3" /> },
|
|
|
- syncing: { bg: "#0369a114", text: "#0369a1", icon: <RefreshCw className="w-3 h-3 animate-spin" /> },
|
|
|
- error: { bg: "#dc262614", text: "#dc2626", icon: <AlertTriangle className="w-3 h-3" /> },
|
|
|
-};
|
|
|
+type TabId = "sources" | "products" | "suggestions";
|
|
|
+
|
|
|
+/* ─── CSV parsing util ─── */
|
|
|
+function parseCSV(text: string): string[][] {
|
|
|
+ const rows: string[][] = [];
|
|
|
+ const lines = text.split(/\r?\n/).filter(l => l.trim().length > 0);
|
|
|
+ for (const line of lines) {
|
|
|
+ const cells: string[] = [];
|
|
|
+ let cur = "";
|
|
|
+ let inQuotes = false;
|
|
|
+ for (let i = 0; i < line.length; i++) {
|
|
|
+ const c = line[i];
|
|
|
+ if (inQuotes) {
|
|
|
+ if (c === '"' && line[i + 1] === '"') { cur += '"'; i++; }
|
|
|
+ else if (c === '"') { inQuotes = false; }
|
|
|
+ else cur += c;
|
|
|
+ } else {
|
|
|
+ if (c === '"') inQuotes = true;
|
|
|
+ else if (c === ',') { cells.push(cur); cur = ""; }
|
|
|
+ else cur += c;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ cells.push(cur);
|
|
|
+ rows.push(cells.map(c => c.trim()));
|
|
|
+ }
|
|
|
+ return rows;
|
|
|
+}
|
|
|
|
|
|
export default function DataSources() {
|
|
|
const [activeTab, setActiveTab] = useState<TabId>("sources");
|
|
|
- const [showAddSource, setShowAddSource] = useState(false);
|
|
|
- const [showAddConnection, setShowAddConnection] = useState(false);
|
|
|
- const [newSource, setNewSource] = useState<{ name: string; type: "url" | "file" | "qa_pair" | "api"; config: any }>({ name: "", type: "url", config: {} });
|
|
|
- const [newConnection, setNewConnection] = useState<{
|
|
|
- name: string; description: string; category: string; method: "GET" | "POST" | "PUT" | "DELETE";
|
|
|
- endpoint: string; inputVariables: any[]; outputVariables: any[];
|
|
|
- }>({
|
|
|
- name: "", description: "", category: "", method: "GET",
|
|
|
- endpoint: "", inputVariables: [], outputVariables: [],
|
|
|
- });
|
|
|
+ const [search, setSearch] = useState("");
|
|
|
+ const [suggestionStatus, setSuggestionStatus] = useState<string>("pending");
|
|
|
+
|
|
|
+ // Modal state
|
|
|
+ const [showAddEntry, setShowAddEntry] = useState(false);
|
|
|
+ const [editingEntry, setEditingEntry] = useState<any | null>(null);
|
|
|
+ const [showImportEntries, setShowImportEntries] = useState(false);
|
|
|
+ const [showImportProducts, setShowImportProducts] = useState(false);
|
|
|
+ const [promotingSuggestion, setPromotingSuggestion] = useState<any | null>(null);
|
|
|
|
|
|
- const { data: sources, isLoading: loadingSources } = trpc.dataSources.list.useQuery();
|
|
|
- const { data: connections, isLoading: loadingConnections } = trpc.apiConnections.list.useQuery();
|
|
|
+ // Form state
|
|
|
+ const [entryForm, setEntryForm] = useState({ question: "", answer: "", category: "" });
|
|
|
+ const [csvText, setCsvText] = useState("");
|
|
|
+ const [productCsvText, setProductCsvText] = useState("");
|
|
|
+ const [replaceAllProducts, setReplaceAllProducts] = useState(false);
|
|
|
+ const [promoteForm, setPromoteForm] = useState({ answer: "", category: "" });
|
|
|
|
|
|
const utils = trpc.useUtils();
|
|
|
|
|
|
- const createSource = trpc.dataSources.create.useMutation({
|
|
|
+ /* ─── Queries ─── */
|
|
|
+ const { data: entries, isLoading: loadingEntries } = trpc.knowledge.listEntries.useQuery();
|
|
|
+ const { data: products, isLoading: loadingProducts } = trpc.knowledge.listProducts.useQuery();
|
|
|
+ const { data: suggestions, isLoading: loadingSuggestions } =
|
|
|
+ trpc.knowledge.listSuggestions.useQuery({ status: suggestionStatus || undefined });
|
|
|
+ const { data: allSuggestions } = trpc.knowledge.listSuggestions.useQuery();
|
|
|
+
|
|
|
+ /* ─── Mutations ─── */
|
|
|
+ const createEntry = trpc.knowledge.createEntry.useMutation({
|
|
|
onSuccess: () => {
|
|
|
- toast.success("Data source added");
|
|
|
- utils.dataSources.list.invalidate();
|
|
|
- setShowAddSource(false);
|
|
|
- setNewSource({ name: "", type: "url", config: {} });
|
|
|
+ toast.success("Knowledge entry added");
|
|
|
+ utils.knowledge.listEntries.invalidate();
|
|
|
+ setShowAddEntry(false);
|
|
|
+ setEntryForm({ question: "", answer: "", category: "" });
|
|
|
},
|
|
|
onError: (err) => toast.error(err.message),
|
|
|
});
|
|
|
|
|
|
- const deleteSource = trpc.dataSources.delete.useMutation({
|
|
|
+ const updateEntry = trpc.knowledge.updateEntry.useMutation({
|
|
|
onSuccess: () => {
|
|
|
- toast.success("Data source removed");
|
|
|
- utils.dataSources.list.invalidate();
|
|
|
+ toast.success("Knowledge entry updated");
|
|
|
+ utils.knowledge.listEntries.invalidate();
|
|
|
+ setEditingEntry(null);
|
|
|
},
|
|
|
+ onError: (err) => toast.error(err.message),
|
|
|
});
|
|
|
|
|
|
- const createConnection = trpc.apiConnections.create.useMutation({
|
|
|
+ const deleteEntry = trpc.knowledge.deleteEntry.useMutation({
|
|
|
onSuccess: () => {
|
|
|
- toast.success("API connection created");
|
|
|
- utils.apiConnections.list.invalidate();
|
|
|
- setShowAddConnection(false);
|
|
|
- setNewConnection({ name: "", description: "", category: "", method: "GET", endpoint: "", inputVariables: [], outputVariables: [] });
|
|
|
+ toast.success("Entry deleted");
|
|
|
+ utils.knowledge.listEntries.invalidate();
|
|
|
+ },
|
|
|
+ });
|
|
|
+
|
|
|
+ const importEntries = trpc.knowledge.importEntries.useMutation({
|
|
|
+ onSuccess: (data) => {
|
|
|
+ toast.success(`Imported ${data.created} entries`);
|
|
|
+ utils.knowledge.listEntries.invalidate();
|
|
|
+ setShowImportEntries(false);
|
|
|
+ setCsvText("");
|
|
|
},
|
|
|
onError: (err) => toast.error(err.message),
|
|
|
});
|
|
|
|
|
|
- const deleteConnection = trpc.apiConnections.delete.useMutation({
|
|
|
+ const importProducts = trpc.knowledge.importProducts.useMutation({
|
|
|
+ onSuccess: (data) => {
|
|
|
+ toast.success(`Imported ${data.created} products`);
|
|
|
+ utils.knowledge.listProducts.invalidate();
|
|
|
+ setShowImportProducts(false);
|
|
|
+ setProductCsvText("");
|
|
|
+ setReplaceAllProducts(false);
|
|
|
+ },
|
|
|
+ onError: (err) => toast.error(err.message),
|
|
|
+ });
|
|
|
+
|
|
|
+ const promoteSuggestion = trpc.knowledge.promoteSuggestion.useMutation({
|
|
|
onSuccess: () => {
|
|
|
- toast.success("API connection removed");
|
|
|
- utils.apiConnections.list.invalidate();
|
|
|
+ toast.success("Suggestion promoted to knowledge entry");
|
|
|
+ utils.knowledge.listSuggestions.invalidate();
|
|
|
+ utils.knowledge.listEntries.invalidate();
|
|
|
+ setPromotingSuggestion(null);
|
|
|
+ setPromoteForm({ answer: "", category: "" });
|
|
|
},
|
|
|
+ onError: (err) => toast.error(err.message),
|
|
|
});
|
|
|
|
|
|
- const testConnection = trpc.apiConnections.test.useMutation({
|
|
|
- onSuccess: (data) => {
|
|
|
- if (data.success) {
|
|
|
- toast.success(`${data.message} (${data.responseTime}ms)`);
|
|
|
- } else {
|
|
|
- toast.error(data.message);
|
|
|
- }
|
|
|
+ const dismissSuggestion = trpc.knowledge.dismissSuggestion.useMutation({
|
|
|
+ onSuccess: () => {
|
|
|
+ toast.success("Suggestion dismissed");
|
|
|
+ utils.knowledge.listSuggestions.invalidate();
|
|
|
},
|
|
|
});
|
|
|
|
|
|
- const handleUseTemplate = (template: typeof ACTION_TEMPLATES[0]) => {
|
|
|
- setNewConnection({
|
|
|
- name: template.name,
|
|
|
- description: template.description,
|
|
|
- category: template.category,
|
|
|
- method: template.method,
|
|
|
- endpoint: template.endpoint,
|
|
|
- inputVariables: template.inputVars,
|
|
|
- outputVariables: template.outputVars,
|
|
|
- });
|
|
|
- setShowAddConnection(true);
|
|
|
- setActiveTab("connections");
|
|
|
+ /* ─── Stats ─── */
|
|
|
+ const entryStats = useMemo(() => {
|
|
|
+ const list = entries || [];
|
|
|
+ return {
|
|
|
+ total: list.length,
|
|
|
+ active: list.filter((e: any) => e.status === "active").length,
|
|
|
+ totalUses: list.reduce((s: number, e: any) => s + (e.useCount || 0), 0),
|
|
|
+ };
|
|
|
+ }, [entries]);
|
|
|
+
|
|
|
+ const suggestionStats = useMemo(() => {
|
|
|
+ const list = allSuggestions || [];
|
|
|
+ return {
|
|
|
+ pending: list.filter((s: any) => s.status === "pending").length,
|
|
|
+ promoted: list.filter((s: any) => s.status === "promoted").length,
|
|
|
+ dismissed: list.filter((s: any) => s.status === "dismissed").length,
|
|
|
+ };
|
|
|
+ }, [allSuggestions]);
|
|
|
+
|
|
|
+ /* ─── Filtered entries ─── */
|
|
|
+ const filteredEntries = useMemo(() => {
|
|
|
+ if (!entries) return [];
|
|
|
+ if (!search.trim()) return entries;
|
|
|
+ const q = search.toLowerCase();
|
|
|
+ return entries.filter((e: any) =>
|
|
|
+ (e.question || "").toLowerCase().includes(q) ||
|
|
|
+ (e.answer || "").toLowerCase().includes(q) ||
|
|
|
+ (e.category || "").toLowerCase().includes(q)
|
|
|
+ );
|
|
|
+ }, [entries, search]);
|
|
|
+
|
|
|
+ /* ─── Handlers ─── */
|
|
|
+ const handleImportEntries = () => {
|
|
|
+ const rows = parseCSV(csvText);
|
|
|
+ if (rows.length === 0) { toast.error("No CSV rows found"); return; }
|
|
|
+ // Detect header row
|
|
|
+ const first = rows[0].map(c => c.toLowerCase());
|
|
|
+ const hasHeader = first.includes("question") && first.includes("answer");
|
|
|
+ const dataRows = hasHeader ? rows.slice(1) : rows;
|
|
|
+ const qIdx = hasHeader ? first.indexOf("question") : 0;
|
|
|
+ const aIdx = hasHeader ? first.indexOf("answer") : 1;
|
|
|
+ const cIdx = hasHeader ? first.indexOf("category") : 2;
|
|
|
+ const items = dataRows
|
|
|
+ .filter(r => r[qIdx] && r[aIdx])
|
|
|
+ .map(r => ({
|
|
|
+ question: r[qIdx] || "",
|
|
|
+ answer: r[aIdx] || "",
|
|
|
+ category: cIdx >= 0 ? (r[cIdx] || undefined) : undefined,
|
|
|
+ }));
|
|
|
+ if (items.length === 0) { toast.error("No valid Q&A rows"); return; }
|
|
|
+ importEntries.mutate({ entries: items, source: "csv" });
|
|
|
+ };
|
|
|
+
|
|
|
+ const handleImportProducts = () => {
|
|
|
+ const rows = parseCSV(productCsvText);
|
|
|
+ if (rows.length === 0) { toast.error("No CSV rows found"); return; }
|
|
|
+ const first = rows[0].map(c => c.toLowerCase());
|
|
|
+ const hasHeader = first.includes("model");
|
|
|
+ const dataRows = hasHeader ? rows.slice(1) : rows;
|
|
|
+ const idx = (name: string) => hasHeader ? first.indexOf(name.toLowerCase()) : -1;
|
|
|
+ const cols = ["model","description","categories","collection","price","availability","features","dimensions","imageurl"];
|
|
|
+ const colIdx: Record<string, number> = {};
|
|
|
+ cols.forEach(c => { colIdx[c] = idx(c); });
|
|
|
+ const items = dataRows
|
|
|
+ .filter(r => r[colIdx.model >= 0 ? colIdx.model : 0])
|
|
|
+ .map(r => ({
|
|
|
+ model: r[colIdx.model >= 0 ? colIdx.model : 0] || "",
|
|
|
+ description: colIdx.description >= 0 ? r[colIdx.description] || undefined : undefined,
|
|
|
+ categories: colIdx.categories >= 0 ? r[colIdx.categories] || undefined : undefined,
|
|
|
+ collection: colIdx.collection >= 0 ? r[colIdx.collection] || undefined : undefined,
|
|
|
+ price: colIdx.price >= 0 ? r[colIdx.price] || undefined : undefined,
|
|
|
+ availability: colIdx.availability >= 0 ? r[colIdx.availability] || undefined : undefined,
|
|
|
+ features: colIdx.features >= 0 ? r[colIdx.features] || undefined : undefined,
|
|
|
+ dimensions: colIdx.dimensions >= 0 ? r[colIdx.dimensions] || undefined : undefined,
|
|
|
+ imageUrl: colIdx.imageurl >= 0 ? r[colIdx.imageurl] || undefined : undefined,
|
|
|
+ }));
|
|
|
+ if (items.length === 0) { toast.error("No valid product rows"); return; }
|
|
|
+ importProducts.mutate({ products: items, replaceAll: replaceAllProducts });
|
|
|
+ };
|
|
|
+
|
|
|
+ const handleFileUpload = (
|
|
|
+ e: React.ChangeEvent<HTMLInputElement>,
|
|
|
+ setter: (s: string) => void
|
|
|
+ ) => {
|
|
|
+ const file = e.target.files?.[0];
|
|
|
+ if (!file) return;
|
|
|
+ const reader = new FileReader();
|
|
|
+ reader.onload = () => setter(String(reader.result || ""));
|
|
|
+ reader.readAsText(file);
|
|
|
};
|
|
|
|
|
|
const tabs: { id: TabId; label: string; icon: React.ReactNode }[] = [
|
|
|
- { id: "sources", label: "Data Sources", icon: <Database className="w-3.5 h-3.5" /> },
|
|
|
- { id: "connections", label: "API Connections", icon: <Plug className="w-3.5 h-3.5" /> },
|
|
|
- { id: "templates", label: "Action Templates", icon: <Zap className="w-3.5 h-3.5" /> },
|
|
|
+ { id: "sources", label: "Data Source", icon: <Database className="w-3.5 h-3.5" /> },
|
|
|
+ { id: "products", label: "Product", icon: <Package className="w-3.5 h-3.5" /> },
|
|
|
+ { id: "suggestions", label: "Suggestions", icon: <Lightbulb className="w-3.5 h-3.5" /> },
|
|
|
];
|
|
|
|
|
|
return (
|
|
|
@@ -222,17 +239,22 @@ export default function DataSources() {
|
|
|
<div className="w-7 h-7 rounded-lg flex items-center justify-center" style={{ background: "#059669" }}>
|
|
|
<Database className="w-3.5 h-3.5 text-white" />
|
|
|
</div>
|
|
|
- <span className="text-sm font-bold" style={{ color: "#059669", fontFamily: "'Playfair Display', serif" }}>Knowledge & Data</span>
|
|
|
+ <span className="text-sm font-bold" style={{ color: "#059669", fontFamily: "'Playfair Display', serif" }}>Knowledge Management</span>
|
|
|
</div>
|
|
|
<div className="flex items-center gap-2">
|
|
|
{activeTab === "sources" && (
|
|
|
- <Button onClick={() => setShowAddSource(true)} size="sm" className="text-xs text-white" style={{ background: "#059669" }}>
|
|
|
- <Plus className="w-3 h-3 mr-1" /> Add Source
|
|
|
- </Button>
|
|
|
+ <>
|
|
|
+ <Button onClick={() => setShowImportEntries(true)} size="sm" variant="outline" className="text-xs">
|
|
|
+ <Upload className="w-3 h-3 mr-1" /> Import CSV
|
|
|
+ </Button>
|
|
|
+ <Button onClick={() => setShowAddEntry(true)} size="sm" className="text-xs text-white" style={{ background: "#059669" }}>
|
|
|
+ <Plus className="w-3 h-3 mr-1" /> Add Knowledge
|
|
|
+ </Button>
|
|
|
+ </>
|
|
|
)}
|
|
|
- {activeTab === "connections" && (
|
|
|
- <Button onClick={() => setShowAddConnection(true)} size="sm" className="text-xs text-white" style={{ background: "#059669" }}>
|
|
|
- <Plus className="w-3 h-3 mr-1" /> Add Connection
|
|
|
+ {activeTab === "products" && (
|
|
|
+ <Button onClick={() => setShowImportProducts(true)} size="sm" className="text-xs text-white" style={{ background: "#059669" }}>
|
|
|
+ <Upload className="w-3 h-3 mr-1" /> Import Excel/CSV
|
|
|
</Button>
|
|
|
)}
|
|
|
</div>
|
|
|
@@ -257,396 +279,455 @@ export default function DataSources() {
|
|
|
|
|
|
{/* Content */}
|
|
|
<div className="flex-1 overflow-y-auto p-4" style={{ background: "#FFFBEB" }}>
|
|
|
- {/* ─── Data Sources Tab ─── */}
|
|
|
+ {/* ─── Data Source Tab (Q&A) ─── */}
|
|
|
{activeTab === "sources" && (
|
|
|
<div className="space-y-3">
|
|
|
- {/* Add source form */}
|
|
|
- {showAddSource && (
|
|
|
+ {/* Stats */}
|
|
|
+ <div className="grid grid-cols-3 gap-3">
|
|
|
+ <StatCard label="Total Q&A Pairs" value={entryStats.total} color="#059669" />
|
|
|
+ <StatCard label="Active" value={entryStats.active} color="#14532D" />
|
|
|
+ <StatCard label="Total Uses" value={entryStats.totalUses} color="#0369a1" />
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* Search */}
|
|
|
+ <div className="relative">
|
|
|
+ <Search className="w-3.5 h-3.5 absolute left-3 top-2.5" style={{ color: "#a8a29e" }} />
|
|
|
+ <input
|
|
|
+ type="text"
|
|
|
+ value={search}
|
|
|
+ onChange={(e) => setSearch(e.target.value)}
|
|
|
+ placeholder="Search questions, answers, categories..."
|
|
|
+ className="w-full pl-9 pr-3 py-2 text-sm rounded-lg border"
|
|
|
+ style={{ borderColor: "#e7e0d5", background: "#fff" }}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* Add modal */}
|
|
|
+ {showAddEntry && (
|
|
|
+ <EntryFormCard
|
|
|
+ title="Add Knowledge Entry"
|
|
|
+ form={entryForm}
|
|
|
+ setForm={setEntryForm}
|
|
|
+ onSave={() => createEntry.mutate({
|
|
|
+ question: entryForm.question,
|
|
|
+ answer: entryForm.answer,
|
|
|
+ category: entryForm.category || undefined,
|
|
|
+ })}
|
|
|
+ onCancel={() => { setShowAddEntry(false); setEntryForm({ question: "", answer: "", category: "" }); }}
|
|
|
+ saving={createEntry.isPending}
|
|
|
+ />
|
|
|
+ )}
|
|
|
+
|
|
|
+ {/* Edit modal */}
|
|
|
+ {editingEntry && (
|
|
|
+ <EntryFormCard
|
|
|
+ title="Edit Knowledge Entry"
|
|
|
+ form={{
|
|
|
+ question: editingEntry.question,
|
|
|
+ answer: editingEntry.answer,
|
|
|
+ category: editingEntry.category || "",
|
|
|
+ }}
|
|
|
+ setForm={(f) => setEditingEntry({ ...editingEntry, ...f })}
|
|
|
+ onSave={() => updateEntry.mutate({
|
|
|
+ id: editingEntry.id,
|
|
|
+ question: editingEntry.question,
|
|
|
+ answer: editingEntry.answer,
|
|
|
+ category: editingEntry.category || undefined,
|
|
|
+ })}
|
|
|
+ onCancel={() => setEditingEntry(null)}
|
|
|
+ saving={updateEntry.isPending}
|
|
|
+ />
|
|
|
+ )}
|
|
|
+
|
|
|
+ {/* Import CSV modal */}
|
|
|
+ {showImportEntries && (
|
|
|
<div className="p-4 rounded-xl border" style={{ background: "#fff", borderColor: "#e7e0d5" }}>
|
|
|
- <h3 className="text-sm font-semibold mb-3" style={{ color: "#292524" }}>Add Data Source</h3>
|
|
|
- <div className="space-y-3">
|
|
|
- <div className="grid grid-cols-4 gap-2">
|
|
|
- {(Object.entries(SOURCE_TYPES) as [string, any][]).map(([key, config]) => (
|
|
|
- <button
|
|
|
- key={key}
|
|
|
- onClick={() => setNewSource(prev => ({ ...prev, type: key as any }))}
|
|
|
- className="p-2 rounded-lg border text-center transition-colors"
|
|
|
- style={{
|
|
|
- borderColor: newSource.type === key ? config.color : "#e7e0d5",
|
|
|
- background: newSource.type === key ? `${config.color}08` : "#fff",
|
|
|
- }}
|
|
|
- >
|
|
|
- <div className="flex justify-center mb-1" style={{ color: config.color }}>{config.icon}</div>
|
|
|
- <span className="text-[10px] font-medium" style={{ color: newSource.type === key ? config.color : "#78716C" }}>{config.label}</span>
|
|
|
- </button>
|
|
|
- ))}
|
|
|
- </div>
|
|
|
- <input
|
|
|
- type="text"
|
|
|
- value={newSource.name}
|
|
|
- onChange={(e) => setNewSource(prev => ({ ...prev, name: e.target.value }))}
|
|
|
- placeholder="Source name"
|
|
|
- className="w-full px-3 py-2 text-sm rounded-lg border"
|
|
|
- style={{ borderColor: "#e7e0d5" }}
|
|
|
- />
|
|
|
- {newSource.type === "url" && (
|
|
|
- <input
|
|
|
- type="url"
|
|
|
- value={newSource.config.url || ""}
|
|
|
- onChange={(e) => setNewSource(prev => ({ ...prev, config: { ...prev.config, url: e.target.value } }))}
|
|
|
- placeholder="https://www.homelegance.com/faq"
|
|
|
- className="w-full px-3 py-2 text-sm rounded-lg border"
|
|
|
- style={{ borderColor: "#e7e0d5" }}
|
|
|
- />
|
|
|
- )}
|
|
|
- {newSource.type === "qa_pair" && (
|
|
|
- <>
|
|
|
- <input
|
|
|
- type="text"
|
|
|
- value={newSource.config.question || ""}
|
|
|
- onChange={(e) => setNewSource(prev => ({ ...prev, config: { ...prev.config, question: e.target.value } }))}
|
|
|
- placeholder="Question"
|
|
|
- className="w-full px-3 py-2 text-sm rounded-lg border"
|
|
|
- style={{ borderColor: "#e7e0d5" }}
|
|
|
- />
|
|
|
- <textarea
|
|
|
- value={newSource.config.answer || ""}
|
|
|
- onChange={(e) => setNewSource(prev => ({ ...prev, config: { ...prev.config, answer: e.target.value } }))}
|
|
|
- placeholder="Answer"
|
|
|
- className="w-full px-3 py-2 text-sm rounded-lg border resize-none"
|
|
|
- rows={3}
|
|
|
- style={{ borderColor: "#e7e0d5" }}
|
|
|
- />
|
|
|
- </>
|
|
|
- )}
|
|
|
- <div className="flex gap-2 justify-end">
|
|
|
- <Button variant="outline" size="sm" onClick={() => setShowAddSource(false)}>Cancel</Button>
|
|
|
- <Button
|
|
|
- size="sm"
|
|
|
- className="text-white"
|
|
|
- style={{ background: "#059669" }}
|
|
|
- onClick={() => createSource.mutate(newSource)}
|
|
|
- disabled={!newSource.name || createSource.isPending}
|
|
|
- >
|
|
|
- {createSource.isPending ? "Adding..." : "Add Source"}
|
|
|
- </Button>
|
|
|
- </div>
|
|
|
+ <h3 className="text-sm font-semibold mb-3" style={{ color: "#292524" }}>Import Q&A from CSV</h3>
|
|
|
+ <p className="text-[11px] mb-2" style={{ color: "#78716C" }}>
|
|
|
+ Format: <code>question,answer,category</code> (header row optional)
|
|
|
+ </p>
|
|
|
+ <input type="file" accept=".csv,text/csv,text/plain" onChange={(e) => handleFileUpload(e, setCsvText)} className="text-xs mb-2" />
|
|
|
+ <textarea
|
|
|
+ value={csvText}
|
|
|
+ onChange={(e) => setCsvText(e.target.value)}
|
|
|
+ placeholder="question,answer,category What is your warranty?,One year limited warranty.,warranty"
|
|
|
+ className="w-full px-3 py-2 text-xs rounded-lg border font-mono"
|
|
|
+ rows={8}
|
|
|
+ style={{ borderColor: "#e7e0d5" }}
|
|
|
+ />
|
|
|
+ <div className="flex gap-2 justify-end mt-3">
|
|
|
+ <Button variant="outline" size="sm" onClick={() => { setShowImportEntries(false); setCsvText(""); }}>Cancel</Button>
|
|
|
+ <Button size="sm" className="text-white" style={{ background: "#059669" }} onClick={handleImportEntries} disabled={!csvText.trim() || importEntries.isPending}>
|
|
|
+ {importEntries.isPending ? "Importing..." : "Import"}
|
|
|
+ </Button>
|
|
|
</div>
|
|
|
</div>
|
|
|
)}
|
|
|
|
|
|
- {/* Sources list */}
|
|
|
- {loadingSources ? (
|
|
|
- <div className="text-center py-12">
|
|
|
- <RefreshCw className="w-5 h-5 animate-spin mx-auto" style={{ color: "#a8a29e" }} />
|
|
|
- </div>
|
|
|
- ) : !sources || sources.length === 0 ? (
|
|
|
- <div className="text-center py-12">
|
|
|
- <Database className="w-10 h-10 mx-auto mb-3" style={{ color: "#d6d3d1" }} />
|
|
|
- <p className="text-sm" style={{ color: "#78716C" }}>No data sources yet</p>
|
|
|
- <p className="text-xs mt-1" style={{ color: "#a8a29e" }}>Add URLs, files, or Q&A pairs to train your chatbot</p>
|
|
|
- </div>
|
|
|
+ {/* Entries list */}
|
|
|
+ {loadingEntries ? (
|
|
|
+ <LoadingState />
|
|
|
+ ) : filteredEntries.length === 0 ? (
|
|
|
+ <EmptyState icon={<Database className="w-10 h-10" />} title={search ? "No matches" : "No knowledge entries yet"} hint="Add Q&A pairs or import a CSV to get started" />
|
|
|
) : (
|
|
|
- sources.map((source: any) => {
|
|
|
- const typeConfig = SOURCE_TYPES[source.type as keyof typeof SOURCE_TYPES] || SOURCE_TYPES.url;
|
|
|
- const statusStyle = STATUS_STYLES[source.status] || STATUS_STYLES.active;
|
|
|
- return (
|
|
|
- <div key={source.id} className="p-3 rounded-xl border flex items-center gap-3" style={{ background: "#fff", borderColor: "#e7e0d5" }}>
|
|
|
- <div className="w-9 h-9 rounded-lg flex items-center justify-center shrink-0" style={{ background: `${typeConfig.color}14`, color: typeConfig.color }}>
|
|
|
- {typeConfig.icon}
|
|
|
- </div>
|
|
|
- <div className="flex-1 min-w-0">
|
|
|
- <div className="flex items-center gap-2">
|
|
|
- <span className="text-sm font-semibold truncate" style={{ color: "#292524" }}>{source.name}</span>
|
|
|
- <span className="flex items-center gap-0.5 px-1.5 py-0.5 rounded-full text-[9px] font-medium" style={{ background: statusStyle.bg, color: statusStyle.text }}>
|
|
|
- {statusStyle.icon} {source.status}
|
|
|
- </span>
|
|
|
- </div>
|
|
|
- <div className="flex items-center gap-2 mt-0.5">
|
|
|
- <span className="text-[10px]" style={{ color: "#a8a29e" }}>{typeConfig.label}</span>
|
|
|
- <span className="text-[10px]" style={{ color: "#a8a29e" }}>· {source.itemCount} items</span>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- <button
|
|
|
- onClick={() => deleteSource.mutate({ id: source.id })}
|
|
|
- className="p-1.5 rounded-lg hover:bg-red-50 transition-colors"
|
|
|
- >
|
|
|
- <Trash2 className="w-3.5 h-3.5" style={{ color: "#dc2626" }} />
|
|
|
- </button>
|
|
|
- </div>
|
|
|
- );
|
|
|
- })
|
|
|
+ <div className="rounded-xl border overflow-hidden" style={{ background: "#fff", borderColor: "#e7e0d5" }}>
|
|
|
+ <table className="w-full text-xs">
|
|
|
+ <thead style={{ background: "#f5f0e8" }}>
|
|
|
+ <tr>
|
|
|
+ <Th>Question</Th>
|
|
|
+ <Th>Answer</Th>
|
|
|
+ <Th>Category</Th>
|
|
|
+ <Th>Source</Th>
|
|
|
+ <Th>Uses</Th>
|
|
|
+ <Th>Status</Th>
|
|
|
+ <Th>Actions</Th>
|
|
|
+ </tr>
|
|
|
+ </thead>
|
|
|
+ <tbody>
|
|
|
+ {filteredEntries.map((e: any) => (
|
|
|
+ <tr key={e.id} className="border-t" style={{ borderColor: "#f5f0e8" }}>
|
|
|
+ <Td className="max-w-[200px] truncate font-medium">{e.question}</Td>
|
|
|
+ <Td className="max-w-[300px] truncate" title={e.answer}>{e.answer}</Td>
|
|
|
+ <Td>{e.category || <span style={{ color: "#a8a29e" }}>—</span>}</Td>
|
|
|
+ <Td><Badge>{e.source || "manual"}</Badge></Td>
|
|
|
+ <Td>{e.useCount || 0}</Td>
|
|
|
+ <Td>
|
|
|
+ <span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full text-[9px] font-medium" style={{
|
|
|
+ background: e.status === "active" ? "#14532D14" : "#78716C14",
|
|
|
+ color: e.status === "active" ? "#14532D" : "#78716C",
|
|
|
+ }}>
|
|
|
+ {e.status === "active" ? <CheckCircle2 className="w-2.5 h-2.5" /> : <XCircle className="w-2.5 h-2.5" />}
|
|
|
+ {e.status}
|
|
|
+ </span>
|
|
|
+ </Td>
|
|
|
+ <Td>
|
|
|
+ <div className="flex gap-1">
|
|
|
+ <button onClick={() => setEditingEntry(e)} className="p-1 rounded hover:bg-blue-50">
|
|
|
+ <Edit2 className="w-3 h-3" style={{ color: "#0369a1" }} />
|
|
|
+ </button>
|
|
|
+ <button onClick={() => { if (confirm("Delete this entry?")) deleteEntry.mutate({ id: e.id }); }} className="p-1 rounded hover:bg-red-50">
|
|
|
+ <Trash2 className="w-3 h-3" style={{ color: "#dc2626" }} />
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </Td>
|
|
|
+ </tr>
|
|
|
+ ))}
|
|
|
+ </tbody>
|
|
|
+ </table>
|
|
|
+ </div>
|
|
|
)}
|
|
|
</div>
|
|
|
)}
|
|
|
|
|
|
- {/* ─── API Connections Tab ─── */}
|
|
|
- {activeTab === "connections" && (
|
|
|
+ {/* ─── Products Tab ─── */}
|
|
|
+ {activeTab === "products" && (
|
|
|
<div className="space-y-3">
|
|
|
- {/* Add connection form */}
|
|
|
- {showAddConnection && (
|
|
|
+ <div className="grid grid-cols-1 gap-3">
|
|
|
+ <StatCard label="Total Products" value={products?.length || 0} color="#059669" />
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* Import note */}
|
|
|
+ <div className="p-3 rounded-xl border text-xs" style={{ background: "#fff", borderColor: "#e7e0d5", color: "#78716C" }}>
|
|
|
+ <FileText className="w-3.5 h-3.5 inline mr-1" style={{ color: "#0369a1" }} />
|
|
|
+ Supports Homelegance Excel format (.xlsx) — convert to CSV for import.
|
|
|
+ Columns: <code>model,description,categories,collection,price,availability,features,dimensions,imageUrl</code>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* Import modal */}
|
|
|
+ {showImportProducts && (
|
|
|
<div className="p-4 rounded-xl border" style={{ background: "#fff", borderColor: "#e7e0d5" }}>
|
|
|
- <h3 className="text-sm font-semibold mb-3" style={{ color: "#292524" }}>
|
|
|
- {newConnection.name ? `Configure: ${newConnection.name}` : "Add API Connection"}
|
|
|
- </h3>
|
|
|
- <div className="space-y-3">
|
|
|
- <div className="grid grid-cols-2 gap-3">
|
|
|
- <div>
|
|
|
- <label className="text-[11px] font-medium" style={{ color: "#78716C" }}>Name</label>
|
|
|
- <input
|
|
|
- type="text"
|
|
|
- value={newConnection.name}
|
|
|
- onChange={(e) => setNewConnection(prev => ({ ...prev, name: e.target.value }))}
|
|
|
- placeholder="e.g., Check Order Status"
|
|
|
- className="mt-1 w-full px-3 py-2 text-sm rounded-lg border"
|
|
|
- style={{ borderColor: "#e7e0d5" }}
|
|
|
- />
|
|
|
- </div>
|
|
|
- <div>
|
|
|
- <label className="text-[11px] font-medium" style={{ color: "#78716C" }}>Category</label>
|
|
|
- <select
|
|
|
- value={newConnection.category}
|
|
|
- onChange={(e) => setNewConnection(prev => ({ ...prev, category: e.target.value }))}
|
|
|
- className="mt-1 w-full px-3 py-2 text-sm rounded-lg border"
|
|
|
- style={{ borderColor: "#e7e0d5" }}
|
|
|
- >
|
|
|
- <option value="">Select category</option>
|
|
|
- <option value="orders">Orders</option>
|
|
|
- <option value="shipping">Shipping</option>
|
|
|
- <option value="returning">Returning</option>
|
|
|
- <option value="cancelling">Cancelling</option>
|
|
|
- <option value="customer">Customer</option>
|
|
|
- </select>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- <div>
|
|
|
- <label className="text-[11px] font-medium" style={{ color: "#78716C" }}>Description</label>
|
|
|
- <input
|
|
|
- type="text"
|
|
|
- value={newConnection.description}
|
|
|
- onChange={(e) => setNewConnection(prev => ({ ...prev, description: e.target.value }))}
|
|
|
- placeholder="What does this API do?"
|
|
|
- className="mt-1 w-full px-3 py-2 text-sm rounded-lg border"
|
|
|
- style={{ borderColor: "#e7e0d5" }}
|
|
|
- />
|
|
|
- </div>
|
|
|
- <div className="grid grid-cols-4 gap-3">
|
|
|
- <div>
|
|
|
- <label className="text-[11px] font-medium" style={{ color: "#78716C" }}>Method</label>
|
|
|
- <select
|
|
|
- value={newConnection.method}
|
|
|
- onChange={(e) => setNewConnection(prev => ({ ...prev, method: e.target.value as any }))}
|
|
|
- className="mt-1 w-full px-3 py-2 text-sm rounded-lg border"
|
|
|
- style={{ borderColor: "#e7e0d5" }}
|
|
|
- >
|
|
|
- <option value="GET">GET</option>
|
|
|
- <option value="POST">POST</option>
|
|
|
- <option value="PUT">PUT</option>
|
|
|
- <option value="DELETE">DELETE</option>
|
|
|
- </select>
|
|
|
- </div>
|
|
|
- <div className="col-span-3">
|
|
|
- <label className="text-[11px] font-medium" style={{ color: "#78716C" }}>Endpoint</label>
|
|
|
- <input
|
|
|
- type="text"
|
|
|
- value={newConnection.endpoint}
|
|
|
- onChange={(e) => setNewConnection(prev => ({ ...prev, endpoint: e.target.value }))}
|
|
|
- placeholder="/api/orders/{orderId}/status"
|
|
|
- className="mt-1 w-full px-3 py-2 text-sm rounded-lg border font-mono"
|
|
|
- style={{ borderColor: "#e7e0d5" }}
|
|
|
- />
|
|
|
- </div>
|
|
|
- </div>
|
|
|
-
|
|
|
- {/* Input variables preview */}
|
|
|
- {newConnection.inputVariables.length > 0 && (
|
|
|
- <div className="p-2 rounded-lg" style={{ background: "#f5f0e8" }}>
|
|
|
- <span className="text-[10px] font-semibold uppercase tracking-wider" style={{ color: "#78716C" }}>Input Variables</span>
|
|
|
- <div className="mt-1 space-y-0.5">
|
|
|
- {newConnection.inputVariables.map((v: any, i: number) => (
|
|
|
- <div key={i} className="flex items-center gap-2 text-[11px]">
|
|
|
- <Code2 className="w-3 h-3" style={{ color: "#0369a1" }} />
|
|
|
- <span className="font-mono font-medium" style={{ color: "#292524" }}>{v.name}</span>
|
|
|
- <span style={{ color: "#a8a29e" }}>({v.type})</span>
|
|
|
- {v.required && <span className="text-[9px] px-1 rounded" style={{ background: "#dc262614", color: "#dc2626" }}>required</span>}
|
|
|
- </div>
|
|
|
- ))}
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- )}
|
|
|
-
|
|
|
- {/* Output variables preview */}
|
|
|
- {newConnection.outputVariables.length > 0 && (
|
|
|
- <div className="p-2 rounded-lg" style={{ background: "#f5f0e8" }}>
|
|
|
- <span className="text-[10px] font-semibold uppercase tracking-wider" style={{ color: "#78716C" }}>Output Variables</span>
|
|
|
- <div className="mt-1 space-y-0.5">
|
|
|
- {newConnection.outputVariables.map((v: any, i: number) => (
|
|
|
- <div key={i} className="flex items-center gap-2 text-[11px]">
|
|
|
- <ArrowRight className="w-3 h-3" style={{ color: "#14532D" }} />
|
|
|
- <span className="font-mono font-medium" style={{ color: "#292524" }}>{v.name}</span>
|
|
|
- <span style={{ color: "#a8a29e" }}>({v.type})</span>
|
|
|
- </div>
|
|
|
- ))}
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- )}
|
|
|
-
|
|
|
- <div className="flex gap-2 justify-end">
|
|
|
- <Button variant="outline" size="sm" onClick={() => { setShowAddConnection(false); setNewConnection({ name: "", description: "", category: "", method: "GET", endpoint: "", inputVariables: [], outputVariables: [] }); }}>
|
|
|
- Cancel
|
|
|
- </Button>
|
|
|
- <Button
|
|
|
- size="sm"
|
|
|
- className="text-white"
|
|
|
- style={{ background: "#059669" }}
|
|
|
- onClick={() => createConnection.mutate(newConnection)}
|
|
|
- disabled={!newConnection.name || !newConnection.endpoint || createConnection.isPending}
|
|
|
- >
|
|
|
- {createConnection.isPending ? "Creating..." : "Create Connection"}
|
|
|
- </Button>
|
|
|
- </div>
|
|
|
+ <h3 className="text-sm font-semibold mb-3" style={{ color: "#292524" }}>Import Products from CSV</h3>
|
|
|
+ <input type="file" accept=".csv,text/csv,text/plain" onChange={(e) => handleFileUpload(e, setProductCsvText)} className="text-xs mb-2" />
|
|
|
+ <textarea
|
|
|
+ value={productCsvText}
|
|
|
+ onChange={(e) => setProductCsvText(e.target.value)}
|
|
|
+ placeholder="model,description,categories,collection,price,availability,features,dimensions,imageUrl"
|
|
|
+ className="w-full px-3 py-2 text-xs rounded-lg border font-mono"
|
|
|
+ rows={8}
|
|
|
+ style={{ borderColor: "#e7e0d5" }}
|
|
|
+ />
|
|
|
+ <label className="flex items-center gap-2 mt-2 text-xs" style={{ color: "#78716C" }}>
|
|
|
+ <input type="checkbox" checked={replaceAllProducts} onChange={(e) => setReplaceAllProducts(e.target.checked)} />
|
|
|
+ Replace All (delete existing products before import)
|
|
|
+ </label>
|
|
|
+ <div className="flex gap-2 justify-end mt-3">
|
|
|
+ <Button variant="outline" size="sm" onClick={() => { setShowImportProducts(false); setProductCsvText(""); setReplaceAllProducts(false); }}>Cancel</Button>
|
|
|
+ <Button size="sm" className="text-white" style={{ background: "#059669" }} onClick={handleImportProducts} disabled={!productCsvText.trim() || importProducts.isPending}>
|
|
|
+ {importProducts.isPending ? "Importing..." : "Import"}
|
|
|
+ </Button>
|
|
|
</div>
|
|
|
</div>
|
|
|
)}
|
|
|
|
|
|
- {/* Connections list */}
|
|
|
- {loadingConnections ? (
|
|
|
- <div className="text-center py-12">
|
|
|
- <RefreshCw className="w-5 h-5 animate-spin mx-auto" style={{ color: "#a8a29e" }} />
|
|
|
- </div>
|
|
|
- ) : !connections || connections.length === 0 ? (
|
|
|
- <div className="text-center py-12">
|
|
|
- <Plug className="w-10 h-10 mx-auto mb-3" style={{ color: "#d6d3d1" }} />
|
|
|
- <p className="text-sm" style={{ color: "#78716C" }}>No API connections yet</p>
|
|
|
- <p className="text-xs mt-1" style={{ color: "#a8a29e" }}>
|
|
|
- Use Action Templates to quickly set up connections for orders, shipping, returns, and more
|
|
|
- </p>
|
|
|
- </div>
|
|
|
+ {loadingProducts ? (
|
|
|
+ <LoadingState />
|
|
|
+ ) : !products || products.length === 0 ? (
|
|
|
+ <EmptyState icon={<Package className="w-10 h-10" />} title="No products yet" hint="Import an Excel/CSV catalog to populate the product knowledge base" />
|
|
|
) : (
|
|
|
- connections.map((conn: any) => (
|
|
|
- <div key={conn.id} className="p-3 rounded-xl border" style={{ background: "#fff", borderColor: "#e7e0d5" }}>
|
|
|
- <div className="flex items-center gap-3">
|
|
|
- <div className="w-9 h-9 rounded-lg flex items-center justify-center shrink-0" style={{ background: "#ca8a0414", color: "#ca8a04" }}>
|
|
|
- <Plug className="w-4 h-4" />
|
|
|
- </div>
|
|
|
- <div className="flex-1 min-w-0">
|
|
|
- <div className="flex items-center gap-2">
|
|
|
- <span className="text-sm font-semibold truncate" style={{ color: "#292524" }}>{conn.name}</span>
|
|
|
- <span className="text-[9px] px-1.5 py-0.5 rounded font-mono font-medium" style={{ background: "#f5f0e8", color: "#78716C" }}>
|
|
|
- {conn.method}
|
|
|
- </span>
|
|
|
- {conn.isActive ? (
|
|
|
- <span className="flex items-center gap-0.5 px-1.5 py-0.5 rounded-full text-[9px] font-medium" style={{ background: "#14532D14", color: "#14532D" }}>
|
|
|
- <CheckCircle2 className="w-2.5 h-2.5" /> Active
|
|
|
- </span>
|
|
|
- ) : (
|
|
|
- <span className="flex items-center gap-0.5 px-1.5 py-0.5 rounded-full text-[9px] font-medium" style={{ background: "#78716C14", color: "#78716C" }}>
|
|
|
- Inactive
|
|
|
+ <div className="rounded-xl border overflow-hidden" style={{ background: "#fff", borderColor: "#e7e0d5" }}>
|
|
|
+ <table className="w-full text-xs">
|
|
|
+ <thead style={{ background: "#f5f0e8" }}>
|
|
|
+ <tr>
|
|
|
+ <Th>Model</Th>
|
|
|
+ <Th>Description</Th>
|
|
|
+ <Th>Categories</Th>
|
|
|
+ <Th>Collection</Th>
|
|
|
+ <Th>Price</Th>
|
|
|
+ <Th>Availability</Th>
|
|
|
+ <Th>Status</Th>
|
|
|
+ </tr>
|
|
|
+ </thead>
|
|
|
+ <tbody>
|
|
|
+ {products.map((p: any) => (
|
|
|
+ <tr key={p.id} className="border-t" style={{ borderColor: "#f5f0e8" }}>
|
|
|
+ <Td className="font-mono font-medium">{p.model}</Td>
|
|
|
+ <Td className="max-w-[250px] truncate" title={p.description || ""}>{p.description || <span style={{ color: "#a8a29e" }}>—</span>}</Td>
|
|
|
+ <Td>{p.categories || <span style={{ color: "#a8a29e" }}>—</span>}</Td>
|
|
|
+ <Td>{p.collection || <span style={{ color: "#a8a29e" }}>—</span>}</Td>
|
|
|
+ <Td>{p.price || <span style={{ color: "#a8a29e" }}>—</span>}</Td>
|
|
|
+ <Td>{p.availability || <span style={{ color: "#a8a29e" }}>—</span>}</Td>
|
|
|
+ <Td>
|
|
|
+ <span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full text-[9px] font-medium" style={{
|
|
|
+ background: p.status === "active" ? "#14532D14" : "#78716C14",
|
|
|
+ color: p.status === "active" ? "#14532D" : "#78716C",
|
|
|
+ }}>
|
|
|
+ {p.status}
|
|
|
</span>
|
|
|
- )}
|
|
|
- </div>
|
|
|
- <p className="text-[10px] font-mono truncate mt-0.5" style={{ color: "#a8a29e" }}>{conn.endpoint}</p>
|
|
|
- {conn.description && <p className="text-[10px] mt-0.5 truncate" style={{ color: "#78716C" }}>{conn.description}</p>}
|
|
|
- </div>
|
|
|
- <div className="flex items-center gap-1 shrink-0">
|
|
|
- <button
|
|
|
- onClick={() => testConnection.mutate({ id: conn.id })}
|
|
|
- disabled={testConnection.isPending}
|
|
|
- className="p-1.5 rounded-lg hover:bg-green-50 transition-colors"
|
|
|
- title="Test connection"
|
|
|
- >
|
|
|
- <TestTube className="w-3.5 h-3.5" style={{ color: "#059669" }} />
|
|
|
- </button>
|
|
|
- <button
|
|
|
- onClick={() => deleteConnection.mutate({ id: conn.id })}
|
|
|
- className="p-1.5 rounded-lg hover:bg-red-50 transition-colors"
|
|
|
- >
|
|
|
- <Trash2 className="w-3.5 h-3.5" style={{ color: "#dc2626" }} />
|
|
|
- </button>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- {conn.executionCount > 0 && (
|
|
|
- <div className="mt-2 pt-2 border-t flex items-center gap-3" style={{ borderColor: "#f5f0e8" }}>
|
|
|
- <span className="text-[10px]" style={{ color: "#a8a29e" }}>{conn.executionCount} executions</span>
|
|
|
- {conn.lastExecutedAt && (
|
|
|
- <span className="text-[10px]" style={{ color: "#a8a29e" }}>
|
|
|
- Last: {new Date(conn.lastExecutedAt).toLocaleDateString()}
|
|
|
- </span>
|
|
|
- )}
|
|
|
- </div>
|
|
|
- )}
|
|
|
- </div>
|
|
|
- ))
|
|
|
+ </Td>
|
|
|
+ </tr>
|
|
|
+ ))}
|
|
|
+ </tbody>
|
|
|
+ </table>
|
|
|
+ </div>
|
|
|
)}
|
|
|
</div>
|
|
|
)}
|
|
|
|
|
|
- {/* ─── Action Templates Tab ─── */}
|
|
|
- {activeTab === "templates" && (
|
|
|
+ {/* ─── Suggestions Tab ─── */}
|
|
|
+ {activeTab === "suggestions" && (
|
|
|
<div className="space-y-3">
|
|
|
- <div className="p-3 rounded-xl border" style={{ background: "#fff", borderColor: "#e7e0d5" }}>
|
|
|
- <p className="text-xs" style={{ color: "#78716C" }}>
|
|
|
- Action Templates provide pre-configured API connection patterns for common chatbot operations.
|
|
|
- Click "Use Template" to create a connection with pre-filled settings that you can customize for your specific API endpoints.
|
|
|
- </p>
|
|
|
+ <div className="grid grid-cols-3 gap-3">
|
|
|
+ <StatCard label="Pending" value={suggestionStats.pending} color="#ca8a04" />
|
|
|
+ <StatCard label="Promoted" value={suggestionStats.promoted} color="#14532D" />
|
|
|
+ <StatCard label="Dismissed" value={suggestionStats.dismissed} color="#78716C" />
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* Status filter */}
|
|
|
+ <div className="flex items-center gap-2">
|
|
|
+ <span className="text-xs" style={{ color: "#78716C" }}>Filter by status:</span>
|
|
|
+ <select
|
|
|
+ value={suggestionStatus}
|
|
|
+ onChange={(e) => setSuggestionStatus(e.target.value)}
|
|
|
+ className="px-3 py-1.5 text-xs rounded-lg border"
|
|
|
+ style={{ borderColor: "#e7e0d5", background: "#fff" }}
|
|
|
+ >
|
|
|
+ <option value="">All</option>
|
|
|
+ <option value="pending">Pending</option>
|
|
|
+ <option value="promoted">Promoted</option>
|
|
|
+ <option value="dismissed">Dismissed</option>
|
|
|
+ </select>
|
|
|
</div>
|
|
|
|
|
|
- {ACTION_TEMPLATES.map(template => (
|
|
|
- <div key={template.id} className="p-4 rounded-xl border" style={{ background: "#fff", borderColor: "#e7e0d5" }}>
|
|
|
- <div className="flex items-start gap-3">
|
|
|
- <div className="w-10 h-10 rounded-lg flex items-center justify-center shrink-0" style={{ background: `${template.color}14`, color: template.color }}>
|
|
|
- {template.icon}
|
|
|
- </div>
|
|
|
- <div className="flex-1 min-w-0">
|
|
|
- <div className="flex items-center gap-2">
|
|
|
- <span className="text-sm font-semibold" style={{ color: "#292524" }}>{template.name}</span>
|
|
|
- <span className="text-[9px] px-1.5 py-0.5 rounded font-mono font-medium" style={{ background: "#f5f0e8", color: "#78716C" }}>
|
|
|
- {template.method}
|
|
|
- </span>
|
|
|
- <span className="text-[9px] px-1.5 py-0.5 rounded-full capitalize" style={{ background: `${template.color}14`, color: template.color }}>
|
|
|
- {template.category}
|
|
|
- </span>
|
|
|
- </div>
|
|
|
- <p className="text-[11px] mt-0.5" style={{ color: "#78716C" }}>{template.description}</p>
|
|
|
- <p className="text-[10px] font-mono mt-1" style={{ color: "#a8a29e" }}>{template.endpoint}</p>
|
|
|
-
|
|
|
- <div className="flex gap-4 mt-2">
|
|
|
- <div>
|
|
|
- <span className="text-[9px] font-semibold uppercase tracking-wider" style={{ color: "#a8a29e" }}>Inputs</span>
|
|
|
- <div className="flex flex-wrap gap-1 mt-0.5">
|
|
|
- {template.inputVars.map(v => (
|
|
|
- <span key={v.name} className="text-[9px] px-1.5 py-0.5 rounded font-mono" style={{ background: "#0369a108", color: "#0369a1", border: "1px solid #0369a120" }}>
|
|
|
- {v.name}{v.required ? "*" : ""}
|
|
|
- </span>
|
|
|
- ))}
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- <div>
|
|
|
- <span className="text-[9px] font-semibold uppercase tracking-wider" style={{ color: "#a8a29e" }}>Outputs</span>
|
|
|
- <div className="flex flex-wrap gap-1 mt-0.5">
|
|
|
- {template.outputVars.map(v => (
|
|
|
- <span key={v.name} className="text-[9px] px-1.5 py-0.5 rounded font-mono" style={{ background: "#14532D08", color: "#14532D", border: "1px solid #14532D20" }}>
|
|
|
- {v.name}
|
|
|
- </span>
|
|
|
- ))}
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- <Button
|
|
|
- onClick={() => handleUseTemplate(template)}
|
|
|
- size="sm"
|
|
|
- className="text-xs text-white shrink-0"
|
|
|
- style={{ background: template.color }}
|
|
|
+ {/* Promote modal */}
|
|
|
+ {promotingSuggestion && (
|
|
|
+ <div className="p-4 rounded-xl border" style={{ background: "#fff", borderColor: "#e7e0d5" }}>
|
|
|
+ <h3 className="text-sm font-semibold mb-2" style={{ color: "#292524" }}>Promote to Knowledge Entry</h3>
|
|
|
+ <div className="p-2 rounded-lg mb-3" style={{ background: "#f5f0e8" }}>
|
|
|
+ <span className="text-[10px] font-semibold uppercase tracking-wider" style={{ color: "#78716C" }}>Question</span>
|
|
|
+ <p className="text-xs mt-1" style={{ color: "#292524" }}>{promotingSuggestion.question}</p>
|
|
|
+ </div>
|
|
|
+ <label className="text-[11px] font-medium" style={{ color: "#78716C" }}>Answer</label>
|
|
|
+ <textarea
|
|
|
+ value={promoteForm.answer}
|
|
|
+ onChange={(e) => setPromoteForm(prev => ({ ...prev, answer: e.target.value }))}
|
|
|
+ placeholder="Write the canonical answer for this question..."
|
|
|
+ className="mt-1 w-full px-3 py-2 text-sm rounded-lg border resize-none"
|
|
|
+ rows={4}
|
|
|
+ style={{ borderColor: "#e7e0d5" }}
|
|
|
+ />
|
|
|
+ <label className="text-[11px] font-medium mt-2 block" style={{ color: "#78716C" }}>Category (optional)</label>
|
|
|
+ <input
|
|
|
+ type="text"
|
|
|
+ value={promoteForm.category}
|
|
|
+ onChange={(e) => setPromoteForm(prev => ({ ...prev, category: e.target.value }))}
|
|
|
+ placeholder="e.g., shipping, warranty"
|
|
|
+ className="mt-1 w-full px-3 py-2 text-sm rounded-lg border"
|
|
|
+ style={{ borderColor: "#e7e0d5" }}
|
|
|
+ />
|
|
|
+ <div className="flex gap-2 justify-end mt-3">
|
|
|
+ <Button variant="outline" size="sm" onClick={() => { setPromotingSuggestion(null); setPromoteForm({ answer: "", category: "" }); }}>Cancel</Button>
|
|
|
+ <Button size="sm" className="text-white" style={{ background: "#059669" }}
|
|
|
+ onClick={() => promoteSuggestion.mutate({
|
|
|
+ id: promotingSuggestion.id,
|
|
|
+ answer: promoteForm.answer,
|
|
|
+ category: promoteForm.category || undefined,
|
|
|
+ })}
|
|
|
+ disabled={!promoteForm.answer || promoteSuggestion.isPending}
|
|
|
>
|
|
|
- <Zap className="w-3 h-3 mr-1" /> Use Template
|
|
|
+ {promoteSuggestion.isPending ? "Promoting..." : "Promote"}
|
|
|
</Button>
|
|
|
</div>
|
|
|
</div>
|
|
|
- ))}
|
|
|
+ )}
|
|
|
+
|
|
|
+ {loadingSuggestions ? (
|
|
|
+ <LoadingState />
|
|
|
+ ) : !suggestions || suggestions.length === 0 ? (
|
|
|
+ <EmptyState icon={<Lightbulb className="w-10 h-10" />} title="No suggestions" hint="Unanswered visitor questions will appear here as they accumulate" />
|
|
|
+ ) : (
|
|
|
+ <div className="rounded-xl border overflow-hidden" style={{ background: "#fff", borderColor: "#e7e0d5" }}>
|
|
|
+ <table className="w-full text-xs">
|
|
|
+ <thead style={{ background: "#f5f0e8" }}>
|
|
|
+ <tr>
|
|
|
+ <Th>Question</Th>
|
|
|
+ <Th>Occurrences</Th>
|
|
|
+ <Th>Last Seen</Th>
|
|
|
+ <Th>Status</Th>
|
|
|
+ <Th>Actions</Th>
|
|
|
+ </tr>
|
|
|
+ </thead>
|
|
|
+ <tbody>
|
|
|
+ {suggestions.map((s: any) => (
|
|
|
+ <tr key={s.id} className="border-t" style={{ borderColor: "#f5f0e8" }}>
|
|
|
+ <Td className="max-w-[400px]">{s.question}</Td>
|
|
|
+ <Td>{s.occurrenceCount || 1}</Td>
|
|
|
+ <Td>{s.lastSeen ? new Date(s.lastSeen).toLocaleString() : "—"}</Td>
|
|
|
+ <Td><Badge>{s.status}</Badge></Td>
|
|
|
+ <Td>
|
|
|
+ {s.status === "pending" ? (
|
|
|
+ <div className="flex gap-1">
|
|
|
+ <button
|
|
|
+ onClick={() => { setPromotingSuggestion(s); setPromoteForm({ answer: "", category: "" }); }}
|
|
|
+ className="px-2 py-0.5 text-[10px] rounded text-white"
|
|
|
+ style={{ background: "#059669" }}
|
|
|
+ >
|
|
|
+ Promote
|
|
|
+ </button>
|
|
|
+ <button
|
|
|
+ onClick={() => dismissSuggestion.mutate({ id: s.id })}
|
|
|
+ className="px-2 py-0.5 text-[10px] rounded border"
|
|
|
+ style={{ borderColor: "#e7e0d5", color: "#78716C" }}
|
|
|
+ >
|
|
|
+ Dismiss
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ ) : (
|
|
|
+ <span style={{ color: "#a8a29e" }}>—</span>
|
|
|
+ )}
|
|
|
+ </Td>
|
|
|
+ </tr>
|
|
|
+ ))}
|
|
|
+ </tbody>
|
|
|
+ </table>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
</div>
|
|
|
)}
|
|
|
</div>
|
|
|
</div>
|
|
|
);
|
|
|
}
|
|
|
+
|
|
|
+/* ─── Sub components ─── */
|
|
|
+function StatCard({ label, value, color }: { label: string; value: number; color: string }) {
|
|
|
+ return (
|
|
|
+ <div className="p-3 rounded-xl border" style={{ background: "#fff", borderColor: "#e7e0d5" }}>
|
|
|
+ <div className="text-[10px] font-semibold uppercase tracking-wider" style={{ color: "#78716C" }}>{label}</div>
|
|
|
+ <div className="text-xl font-bold mt-1" style={{ color }}>{value}</div>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+function Th({ children }: { children: React.ReactNode }) {
|
|
|
+ return <th className="px-3 py-2 text-left text-[10px] font-semibold uppercase tracking-wider" style={{ color: "#78716C" }}>{children}</th>;
|
|
|
+}
|
|
|
+
|
|
|
+function Td({ children, className = "", title }: { children: React.ReactNode; className?: string; title?: string }) {
|
|
|
+ return <td className={`px-3 py-2 ${className}`} style={{ color: "#292524" }} title={title}>{children}</td>;
|
|
|
+}
|
|
|
+
|
|
|
+function Badge({ children }: { children: React.ReactNode }) {
|
|
|
+ return (
|
|
|
+ <span className="text-[9px] px-1.5 py-0.5 rounded font-medium" style={{ background: "#f5f0e8", color: "#78716C" }}>
|
|
|
+ {children}
|
|
|
+ </span>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+function LoadingState() {
|
|
|
+ return (
|
|
|
+ <div className="text-center py-12">
|
|
|
+ <RefreshCw className="w-5 h-5 animate-spin mx-auto" style={{ color: "#a8a29e" }} />
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+function EmptyState({ icon, title, hint }: { icon: React.ReactNode; title: string; hint: string }) {
|
|
|
+ return (
|
|
|
+ <div className="text-center py-12">
|
|
|
+ <div className="mx-auto mb-3" style={{ color: "#d6d3d1" }}>{icon}</div>
|
|
|
+ <p className="text-sm" style={{ color: "#78716C" }}>{title}</p>
|
|
|
+ <p className="text-xs mt-1" style={{ color: "#a8a29e" }}>{hint}</p>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+function EntryFormCard({
|
|
|
+ title, form, setForm, onSave, onCancel, saving,
|
|
|
+}: {
|
|
|
+ title: string;
|
|
|
+ form: { question: string; answer: string; category: string };
|
|
|
+ setForm: (f: { question: string; answer: string; category: string }) => void;
|
|
|
+ onSave: () => void;
|
|
|
+ onCancel: () => void;
|
|
|
+ saving: boolean;
|
|
|
+}) {
|
|
|
+ return (
|
|
|
+ <div className="p-4 rounded-xl border" style={{ background: "#fff", borderColor: "#e7e0d5" }}>
|
|
|
+ <h3 className="text-sm font-semibold mb-3" style={{ color: "#292524" }}>{title}</h3>
|
|
|
+ <div className="space-y-3">
|
|
|
+ <div>
|
|
|
+ <label className="text-[11px] font-medium" style={{ color: "#78716C" }}>Question</label>
|
|
|
+ <input
|
|
|
+ type="text"
|
|
|
+ value={form.question}
|
|
|
+ onChange={(e) => setForm({ ...form, question: e.target.value })}
|
|
|
+ placeholder="What is your warranty policy?"
|
|
|
+ className="mt-1 w-full px-3 py-2 text-sm rounded-lg border"
|
|
|
+ style={{ borderColor: "#e7e0d5" }}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ <div>
|
|
|
+ <label className="text-[11px] font-medium" style={{ color: "#78716C" }}>Answer</label>
|
|
|
+ <textarea
|
|
|
+ value={form.answer}
|
|
|
+ onChange={(e) => setForm({ ...form, answer: e.target.value })}
|
|
|
+ placeholder="Write the canonical answer..."
|
|
|
+ className="mt-1 w-full px-3 py-2 text-sm rounded-lg border resize-none"
|
|
|
+ rows={4}
|
|
|
+ style={{ borderColor: "#e7e0d5" }}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ <div>
|
|
|
+ <label className="text-[11px] font-medium" style={{ color: "#78716C" }}>Category (optional)</label>
|
|
|
+ <input
|
|
|
+ type="text"
|
|
|
+ value={form.category}
|
|
|
+ onChange={(e) => setForm({ ...form, category: e.target.value })}
|
|
|
+ placeholder="e.g., shipping, warranty, returns"
|
|
|
+ className="mt-1 w-full px-3 py-2 text-sm rounded-lg border"
|
|
|
+ style={{ borderColor: "#e7e0d5" }}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ <div className="flex gap-2 justify-end">
|
|
|
+ <Button variant="outline" size="sm" onClick={onCancel}>
|
|
|
+ <X className="w-3 h-3 mr-1" /> Cancel
|
|
|
+ </Button>
|
|
|
+ <Button size="sm" className="text-white" style={{ background: "#059669" }} onClick={onSave} disabled={!form.question || !form.answer || saving}>
|
|
|
+ <Save className="w-3 h-3 mr-1" /> {saving ? "Saving..." : "Save"}
|
|
|
+ </Button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+}
|