Parcourir la source

Phase A/B/C: Knowledge-First, Quick Replies, Flow Engine + 开发日志

Phase A — Knowledge Base:
- schema.ts: add knowledge_entries, knowledge_suggestions, knowledge_products tables
- db.ts: add 16 knowledge helpers (search, CRUD, suggestions, products)
- routers.ts: KB-first search before LLM; auto-log suggestions after LLM;
  add knowledge.* tRPC router (entries/suggestions/products CRUD + CSV import)

Phase B — Product Experience:
- startSession: welcome message now includes quickReplies metadata
  ["🔥 Hot Deals", "📦 Order Status", "🛋️ Product Catalog"]
- ChatbotWidget.tsx: render quick-reply buttons from message metadata
- DataSources.tsx: full rewrite as 3-tab Knowledge Management page
  (Data Source Q&A | Product catalog | Suggestions auto-capture)

Phase C — Flow Engine:
- flowEngine.ts (new): detectFlowIntent() + executeFlow() for 5 Support
  Flows (check-order-status, track-shipment, submit-return, cancel-order,
  faq-deflection); tries DB nodes first, falls back to static responses
- routers.ts: flow engine wired into sendMessage between KB and ERP checks

Processing order: KB → Flow → ERP context → LLM → log Suggestion

deploy/开发日志.md: full Chinese dev log covering all phases

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Tony T il y a 1 semaine
Parent
commit
7292a4871b
7 fichiers modifiés avec 1544 ajouts et 542 suppressions
  1. 11 3
      client/src/components/ChatbotWidget.tsx
  2. 618 537
      client/src/pages/DataSources.tsx
  3. 416 0
      deploy/开发日志.md
  4. 53 0
      drizzle/schema.ts
  5. 169 0
      server/db.ts
  6. 111 0
      server/flowEngine.ts
  7. 166 2
      server/routers.ts

+ 11 - 3
client/src/components/ChatbotWidget.tsx

@@ -12,6 +12,8 @@ interface Message {
   text: string;
   sender: "bot" | "user";
   options?: string[];
+  quickReplies?: string[];
+  metadata?: { quickReplies?: string[]; [k: string]: any };
 }
 
 const INITIAL_MESSAGES: Message[] = [
@@ -244,9 +246,14 @@ export default function ChatbotWidget() {
                     >
                       {msg.text}
                     </div>
-                    {msg.options && (
+                    {(() => {
+                      const quickReplies = msg.options
+                        ?? msg.quickReplies
+                        ?? msg.metadata?.quickReplies;
+                      if (!quickReplies || quickReplies.length === 0) return null;
+                      return (
                       <div className="flex flex-wrap gap-1.5 mt-2">
-                        {msg.options.map((opt) => (
+                        {quickReplies.map((opt) => (
                           <button
                             key={opt}
                             onClick={() => handleSend(opt)}
@@ -271,7 +278,8 @@ export default function ChatbotWidget() {
                           </button>
                         ))}
                       </div>
-                    )}
+                      );
+                    })()}
                   </div>
                   {msg.sender === "user" && (
                     <div className="w-7 h-7 rounded-full shrink-0 flex items-center justify-center mt-1" style={{ background: "#78716C" }}>

+ 618 - 537
client/src/pages/DataSources.tsx

@@ -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&#10;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>
+  );
+}

+ 416 - 0
deploy/开发日志.md

@@ -0,0 +1,416 @@
+# Homelegance Chatbot — 开发与部署日志
+
+> 记录从初始部署到 ERP 集成、知识库等完整开发过程
+
+---
+
+## 目录
+
+1. [项目概述](#1-项目概述)
+2. [部署架构](#2-部署架构)
+3. [Phase 1 — 基础部署(MySQL → PostgreSQL)](#3-phase-1--基础部署)
+4. [Phase 2 — 修复部署问题](#4-phase-2--修复部署问题)
+5. [Phase 3 — ERP 集成(FastAPI Bridge)](#5-phase-3--erp-集成)
+6. [Phase A — 知识库(Knowledge-First)](#6-phase-a--知识库)
+7. [Phase B — 产品体验(快速回复按钮)](#7-phase-b--产品体验)
+8. [Phase C — Workflow 流程引擎](#8-phase-c--workflow-流程引擎)
+9. [服务器操作速查](#9-服务器操作速查)
+10. [待完成事项](#10-待完成事项)
+
+---
+
+## 1. 项目概述
+
+**产品名称**:Homelegance AI Chatbot(Ellie)  
+**部署地址**:`www.homelegance.com/chat/`  
+**服务器**:AlmaLinux 8.10,Apache httpd,同一台 Web 服务器  
+**技术栈**:
+
+| 层 | 技术 |
+|---|---|
+| 前端 | React 19 + Vite + TypeScript + Tailwind CSS + shadcn/ui |
+| 后端 | Node.js + Express + tRPC |
+| ORM | Drizzle ORM(drizzle-orm/pg-core) |
+| 数据库 | PostgreSQL(独立 DB 服务器,`chatbot` Schema) |
+| AI | Anthropic Claude API(`claude-sonnet-4-6`) |
+| 进程管理 | PM2 |
+| ERP 桥接 | Python 3.12 + FastAPI(内网 127.0.0.1:8080) |
+
+---
+
+## 2. 部署架构
+
+```
+Internet
+   │
+   ▼
+Apache :443 (www.homelegance.com)
+   ├─ /chat/api   → ProxyPass → Node.js :3000
+   ├─ /chat/      → Alias → /redant/web/homelegance-chatbot/dist/public/
+   └─ /           → AJP → Tomcat(原有 Dealer Portal)
+
+Node.js :3000 (PM2)
+   ├─ PostgreSQL :5432 (chatbot schema — 自身数据库)
+   └─ FastAPI :8080 (内网 ERP 桥接)
+         └─ ERP PostgreSQL(只读)
+```
+
+### 关键路径
+- 代码目录:`/redant/web/homelegance-chatbot/`
+- ERP Bridge:`/redant/web/homelegance-chatbot/erp-bridge/`
+- PM2 配置:`ecosystem.config.cjs`
+- 环境变量:`.env.production`
+- Apache 配置:加入 www.homelegance.com 的 VirtualHost
+
+---
+
+## 3. Phase 1 — 基础部署
+
+### 主要变更
+
+**`vite.config.ts`** — 移除 Manus 插件,加入 `base: "/chat/"`
+```ts
+base: "/chat/",
+// 删除: vitePluginManusRuntime, jsxLocPlugin, vitePluginManusDebugCollector
+```
+
+**`client/src/App.tsx`** — 加入 wouter Router base
+```tsx
+import { Router } from "wouter";
+<Router base="/chat"><AppRoutes /></Router>
+```
+
+**`client/src/main.tsx`** — API 路径使用 BASE_URL
+```ts
+url: `${import.meta.env.BASE_URL}api/trpc`
+```
+
+**`drizzle.config.ts`** — dialect 改为 postgresql
+
+**`server/db.ts`** — MySQL → PostgreSQL 适配:
+- `onDuplicateKeyUpdate` → `onConflictDoUpdate`
+- `insertId` → `.returning({ id: table.id })`
+- `affectedRows` → `return 0`
+
+**`package.json`**
+```json
+"db:push": "drizzle-kit push"
+```
+
+### Apache 配置片段(加入现有 VirtualHost *:443)
+```apache
+ProxyPreserveHost On
+ProxyPass        /chat/api http://127.0.0.1:3000/api
+ProxyPassReverse /chat/api http://127.0.0.1:3000/api
+ProxyPass /chat/ !
+Alias /chat /redant/web/homelegance-chatbot/dist/public
+
+<Directory "/redant/web/homelegance-chatbot/dist/public">
+    Options -Indexes +FollowSymlinks
+    AllowOverride None
+    Require all granted
+</Directory>
+
+RewriteEngine On
+RewriteCond %{REQUEST_URI}  ^/chat/
+RewriteCond %{REQUEST_URI} !/chat/api
+RewriteCond %{REQUEST_URI} !\.(js|css|png|jpg|jpeg|gif|ico|svg|woff2|woff|ttf|eot|map|json|webp|txt)$
+RewriteRule ^ /redant/web/homelegance-chatbot/dist/public/index.html [L]
+```
+
+---
+
+## 4. Phase 2 — 修复部署问题
+
+### 问题与解决
+
+| 问题 | 根因 | 解决方案 |
+|------|------|---------|
+| 页面空白 | `vitePluginManusRuntime` 注入阻塞代码 | 删除所有 Manus 插件 |
+| umami 400 错误 | `%VITE_ANALYTICS_ENDPOINT%` 未替换 | 从 `index.html` 删除该脚本 |
+| API 404 | tRPC URL `/api/trpc` 绝对路径,走到 Tomcat | 改用 `import.meta.env.BASE_URL` |
+| SPA fallback 返回 HTML 给 JS 文件 | `%{REQUEST_FILENAME} !-f` 检查 DocumentRoot 而非 Alias 路径 | 改用扩展名排除法 |
+| `startSession` 500 错误 | MySQL `insertId` 在 PostgreSQL 不存在 | 改用 Drizzle `.returning()` |
+| PM2 不加载 `ANTHROPIC_API_KEY` | `env_file` 在该版本 PM2 不可靠 | 改用 Node.js `fs` 直接解析 `.env.production` |
+
+### ecosystem.config.cjs 修复
+```js
+const env = loadEnv(path.resolve(__dirname, ".env.production"));
+module.exports = { apps: [{ name: "homelegance-chat", env, ... }] };
+```
+
+### .env.production 必填项
+```env
+NODE_ENV=production
+PORT=3000
+DATABASE_URL=postgresql://chatbot_user:<pw>@<pg_host>:5432/homelegance_chat
+JWT_SECRET=<64字符随机>
+ANTHROPIC_API_KEY=sk-ant-api03-...
+ERP_API_URL=http://127.0.0.1:8080
+ERP_API_KEY=<随机>
+```
+
+---
+
+## 5. Phase 3 — ERP 集成
+
+### 架构
+
+```
+Node.js sendMessage
+   → 意图检测(规则正则)
+   → FastAPI Bridge(127.0.0.1:8080)
+   → erp_api.* 存储函数(PostgreSQL 只读账号)
+   → 结果注入 System Prompt [ERP CONTEXT]
+   → Claude 生成回复
+```
+
+### 新建文件
+
+#### `erp-bridge/main.py` — FastAPI 桥接服务
+
+| 端点 | 调用的 ERP 函数 |
+|------|--------------|
+| `GET /health` | — |
+| `POST /catalog` | `erp_api.catalog_lists(conditions jsonb, limit int)` |
+| `POST /contacts` | `erp_api.contact_lists(conditions jsonb, limit int)` |
+| `POST /orders` | `erp_api.sales_orders_lists(conditions jsonb, limit int)` |
+| `GET /orders/{so_id}` | `erp_api.sales_order_get(so_id text)` |
+| `POST /stock` | `erp_api.stock_lists(conditions jsonb, limit int)` |
+
+**返回列通过 Python `set[str]` 常量控制**,无需改查询逻辑。
+
+#### `server/erpClient.ts` — HTTP 客户端(8 秒超时)
+#### `server/erpTools.ts` — 业务查询函数,返回 LLM 友好字符串
+
+**6 个业务函数**:
+- `lookupOrder(soId)` — 单个订单详情
+- `lookupOrdersByCustomer(cid, n)` — 客户最近 N 个订单
+- `lookupOrdersByPO(poId)` — PO 号查询
+- `lookupCatalog(params)` — 产品目录搜索
+- `lookupStock(params)` — 库存查询
+- `lookupContact(params)` — 客户资料查询
+
+### sendMessage 意图检测顺序
+```
+1. SO-XXXXX 正则 → lookupOrder()
+2. "my orders/recent orders" → lookupOrdersByCustomer()
+3. PO-XXXXX 正则 → lookupOrdersByPO()
+4. "in stock/inventory" + 型号正则 → lookupStock()
+5. 家具关键词 → lookupCatalog()
+6. "customer/dealer/company" + 名称 → lookupContact()
+```
+
+### ERP Bridge 部署
+```bash
+cd /redant/web/homelegance-chatbot/erp-bridge
+pip3.12 install -r requirements.txt
+cp .env.example .env && vi .env
+cp erp-bridge.service /etc/systemd/system/
+systemctl daemon-reload && systemctl enable --now erp-bridge
+curl http://127.0.0.1:8080/health
+```
+
+**erp-bridge/.env**
+```env
+ERP_DATABASE_URL=postgresql://chatbot_readonly:<pw>@<erp-host>:5432/<db>
+ERP_API_KEY=<与 chatbot 相同的 key>
+PORT=8080
+```
+
+**DBA 需执行**
+```sql
+CREATE USER chatbot_readonly WITH PASSWORD '<password>';
+GRANT SELECT ON TABLE catalog, customers, stock_po,
+  sales_orders, sales_order_items, sales_order_note,
+  order_status, warehouse TO chatbot_readonly;
+```
+
+---
+
+## 6. Phase A — 知识库(Knowledge-First)
+
+### 设计原则
+> **Knowledge Base First** — Ellie 必须先搜索 Q&A 知识库,命中则直接返回,不调用 LLM;未命中才进入 ERP 检测 → LLM 流程。
+
+### 新增 DB 表(drizzle/schema.ts)
+
+| 表名 | 用途 |
+|------|------|
+| `chatbot.knowledge_entries` | Q&A 知识条目(管理员维护) |
+| `chatbot.knowledge_suggestions` | 自动捕获的未解答问题 |
+| `chatbot.knowledge_products` | 从 Excel/CSV 导入的产品目录 |
+
+### sendMessage 完整处理链
+```
+用户消息
+   ↓
+① 知识库搜索(keyword 匹配)
+   命中 → 直接返回(source: "knowledge"),useCount++
+   ↓ 未命中
+② Workflow Flow 引擎(意图检测)
+   命中 → 执行 Flow 节点,返回(source: "flow")
+   ↓ 未命中
+③ ERP 意图检测(6种正则)
+   命中 → 调用 FastAPI Bridge,结果注入 System Prompt
+   ↓
+④ Claude LLM 生成回复
+   ↓
+⑤ 自动记录 Suggestion(供管理员审核,持续扩充知识库)
+```
+
+### Knowledge 路由(新增 tRPC 端点)
+```
+knowledge.listEntries / getEntry / createEntry / updateEntry / deleteEntry
+knowledge.importEntries      — CSV 批量导入
+knowledge.listSuggestions / promoteSuggestion / dismissSuggestion
+knowledge.listProducts / importProducts
+```
+
+---
+
+## 7. Phase B — 产品体验
+
+### 快速回复按钮
+
+`startSession` 返回欢迎消息时携带 `metadata.quickReplies`:
+```json
+{
+  "content": "Welcome to Homelegance! I'm **Ellie**...",
+  "metadata": {
+    "quickReplies": ["🔥 Hot Deals", "📦 Order Status", "🛋️ Product Catalog"]
+  }
+}
+```
+
+前端 `ChatbotWidget.tsx` 读取并渲染为可点击按钮,点击后作为用户消息发送。
+
+### Knowledge Management 页面(3 Tab)
+
+`client/src/pages/DataSources.tsx` 重写为:
+
+**Tab 1: Data Source** — Q&A 条目
+- 统计:Total / Active / Total Uses
+- 表格:Question | Answer | Category | Source | Uses | Status | Actions
+- 手动添加 / CSV 导入(自动识别 header)
+
+**Tab 2: Product** — 产品目录
+- 支持 CSV 导入(model, description, categories, collection, price, availability, features, dimensions, imageUrl)
+- Replace All 选项
+
+**Tab 3: Suggestions** — 自动捕获的未解答问题
+- 统计:Pending / Promoted / Dismissed
+- 按状态过滤
+- Promote(填写答案后升级为知识条目)/ Dismiss
+
+---
+
+## 8. Phase C — Workflow 流程引擎
+
+### 新文件:`server/flowEngine.ts`
+
+**5 个 Support Flow 意图模式**:
+
+| Flow ID | 触发关键词/正则 |
+|---------|--------------|
+| `check-order-status` | "order status", "check.*order", "where.*my order" |
+| `track-shipment` | "tracking", "track.*shipment", "where.*package" |
+| `submit-return` | "return", "RMA", "refund" |
+| `cancel-order` | "cancel.*order" |
+| `faq-deflection` | "payment", "warranty", "shipping.*time", "minimum.*order" |
+
+**执行逻辑**:
+1. 优先读取 DB 中保存的 Flow 节点(`workflow_nodes`)
+2. 若无 DB 节点,使用内置静态回复
+3. 支持 `shouldEscalate` 标志(Escalation 节点触发时自动转人工)
+
+### 与 sendMessage 的集成顺序
+```
+KB 搜索 → Flow 引擎 → ERP 意图 → LLM → 记录 Suggestion
+```
+
+---
+
+## 9. 服务器操作速查
+
+### 日常部署流程
+```bash
+# 本地
+git add . && git commit -m "..." && git push origin master
+
+# 服务器
+cd /redant/web/homelegance-chatbot
+git pull origin master
+pnpm build
+pm2 restart homelegance-chat
+```
+
+### DB Schema 更新(新增表后)
+```bash
+cd /redant/web/homelegance-chatbot
+export $(grep -v '^#' .env.production | xargs)
+pnpm db:push
+```
+
+### PM2 完整重启(env 变更后)
+```bash
+pm2 delete homelegance-chat
+pm2 start ecosystem.config.cjs
+pm2 save
+pm2 env 0 | grep ANTHROPIC   # 验证
+```
+
+### 查看日志
+```bash
+pm2 logs homelegance-chat --lines 50
+grep -i "LLM\|error\|Chat\|KB\|Flow\|ERP" /var/log/pm2/homelegance-chat-out-0.log | tail -30
+```
+
+### ERP Bridge
+```bash
+systemctl status erp-bridge
+journalctl -u erp-bridge -f
+curl -H "X-API-Key: <key>" http://127.0.0.1:8080/health
+```
+
+---
+
+## 10. 待完成事项
+
+### 优先级高
+
+- [ ] **ERP Bridge 部署**:在服务器上配置 `.env`,安装 systemd 服务,测试 5 个端点
+- [ ] **DB Schema 推送**:`pnpm db:push` 创建 3 个新知识库表
+- [ ] **导入 Q&A 知识库**:通过 Knowledge → Data Source → Import CSV 导入初始问答对
+
+### 优先级中
+
+- [ ] **SSO 集成**(Phase 4):Dealer Portal 带 JWT token 跳转 → Ellie 自动识别 dealer 身份
+  - Portal 生成短效 JWT(5分钟),`?sso_token=<jwt>` 传入
+  - `chat.startSession` 验证 token,提取 `customer_id`、`company_name`、`sales_rep`
+  - Dashboard 自动显示 Customer ID / Sales Rep 列
+
+- [ ] **产品目录导入**:将 Homelegance Excel 产品表转为 CSV,通过 Knowledge → Product 导入
+
+- [ ] **Leads Flows / Sales Flows**:文档标注为 Pending,下一开发阶段实现
+
+### 优先级低
+
+- [ ] **语义搜索升级**:当前知识库用关键词匹配,未来可接入 Embeddings(OpenAI/Anthropic)实现真正语义匹配
+- [ ] **多城市部署**:6 个城市共用同一个 Anthropic API Key,各自独立 PM2 + PostgreSQL
+
+---
+
+## Git 提交历史(本项目)
+
+| Commit | 说明 |
+|--------|------|
+| `c64aee5` | Remove Manus dependencies from frontend build |
+| `e2b6e8b` | Fix API paths for /chat/ base URL deployment |
+| `bc0d946` | Fix Apache SPA fallback for Alias + RewriteRule conflict |
+| `96a20c0` | Fix SPA fallback: use extension exclusion instead of %1 capture |
+| `a896b48` | Fix all MySQL-specific APIs for PostgreSQL compatibility |
+| `d41f355` | Add ERP integration: FastAPI bridge + intent-aware context injection |
+| `4bfd994` | Allow erp-bridge .env.example to be tracked by git |
+| `25bc2c6` | Fix PM2 env loading: parse .env.production via fs instead of env_file |
+| _(latest)_ | Phase A/B/C: Knowledge Base, Quick Replies, Flow Engine |

+ 53 - 0
drizzle/schema.ts

@@ -263,3 +263,56 @@ export const apiConnections = chatbotSchema.table("api_connections", {
 
 export type ApiConnection       = typeof apiConnections.$inferSelect;
 export type InsertApiConnection = typeof apiConnections.$inferInsert;
+
+/**
+ * Knowledge base Q&A entries — first-line answer engine before the LLM.
+ */
+export const knowledgeEntries = chatbotSchema.table("knowledge_entries", {
+  id:          serial("id").primaryKey(),
+  question:    text("question").notNull(),
+  answer:      text("answer").notNull(),
+  category:    varchar("category", { length: 100 }),
+  source:      varchar("source", { length: 50 }).default("manual"), // "manual"|"csv"|"url"
+  useCount:    integer("use_count").default(0).notNull(),
+  status:      varchar("status", { length: 20 }).default("active").notNull(), // "active"|"inactive"
+  createdAt:   timestamp("created_at").defaultNow().notNull(),
+  updatedAt:   timestamp("updated_at").defaultNow().notNull(),
+});
+
+/**
+ * Auto-captured unanswered questions from Ellie conversations.
+ */
+export const knowledgeSuggestions = chatbotSchema.table("knowledge_suggestions", {
+  id:              serial("id").primaryKey(),
+  question:        text("question").notNull(),
+  occurrenceCount: integer("occurrence_count").default(1).notNull(),
+  lastSeen:        timestamp("last_seen").defaultNow().notNull(),
+  status:          varchar("status", { length: 20 }).default("pending").notNull(), // "pending"|"promoted"|"dismissed"
+  promotedToId:    integer("promoted_to_id"),
+  createdAt:       timestamp("created_at").defaultNow().notNull(),
+});
+
+/**
+ * Product catalog imported from Excel/CSV.
+ */
+export const knowledgeProducts = chatbotSchema.table("knowledge_products", {
+  id:           serial("id").primaryKey(),
+  model:        varchar("model", { length: 100 }).notNull(),
+  description:  text("description"),
+  categories:   text("categories"),
+  collection:   varchar("collection", { length: 100 }),
+  price:        varchar("price", { length: 50 }),
+  availability: varchar("availability", { length: 50 }),
+  features:     text("features"),
+  dimensions:   text("dimensions"),
+  imageUrl:     text("image_url"),
+  status:       varchar("status", { length: 20 }).default("active").notNull(),
+  createdAt:    timestamp("created_at").defaultNow().notNull(),
+});
+
+export type KnowledgeEntry            = typeof knowledgeEntries.$inferSelect;
+export type InsertKnowledgeEntry      = typeof knowledgeEntries.$inferInsert;
+export type KnowledgeSuggestion       = typeof knowledgeSuggestions.$inferSelect;
+export type InsertKnowledgeSuggestion = typeof knowledgeSuggestions.$inferInsert;
+export type KnowledgeProduct          = typeof knowledgeProducts.$inferSelect;
+export type InsertKnowledgeProduct    = typeof knowledgeProducts.$inferInsert;

+ 169 - 0
server/db.ts

@@ -13,6 +13,9 @@ import {
   analyticsEvents, InsertAnalyticsEvent,
   dataSources, InsertDataSource,
   apiConnections, InsertApiConnection,
+  knowledgeEntries, InsertKnowledgeEntry,
+  knowledgeSuggestions, InsertKnowledgeSuggestion,
+  knowledgeProducts, InsertKnowledgeProduct,
 } from "../drizzle/schema";
 import { ENV } from './_core/env';
 
@@ -699,3 +702,169 @@ export async function incrementApiConnectionExecution(id: number) {
     } as any).where(eq(apiConnections.id, id));
   }
 }
+
+/* ─── Knowledge Base helpers ─────────────────────────────── */
+
+/**
+ * Simple keyword-based knowledge search.
+ * Splits the user question into words, filters stop-words, searches
+ * question text case-insensitively. Returns the best match or null.
+ */
+export async function searchKnowledge(userQuestion: string): Promise<{ id: number; question: string; answer: string; category: string | null } | null> {
+  const db = await getDb();
+  if (!db) return null;
+
+  const entries = await db
+    .select({ id: knowledgeEntries.id, question: knowledgeEntries.question, answer: knowledgeEntries.answer, category: knowledgeEntries.category })
+    .from(knowledgeEntries)
+    .where(eq(knowledgeEntries.status, "active"));
+
+  if (!entries.length) return null;
+
+  const stopWords = new Set(["the","a","an","is","are","do","you","have","i","can","tell","me","about","how","what","when","where","why","which","my","your","our"]);
+  const queryWords = userQuestion.toLowerCase().replace(/[^a-z0-9 ]/g, " ").split(/\s+/).filter(w => w.length > 2 && !stopWords.has(w));
+
+  if (!queryWords.length) return null;
+
+  let best: { entry: typeof entries[0]; score: number } | null = null;
+  for (const entry of entries) {
+    const text = entry.question.toLowerCase();
+    let score = 0;
+    for (const word of queryWords) {
+      if (text.includes(word)) score++;
+    }
+    if (score > 0 && (!best || score > best.score)) {
+      best = { entry, score };
+    }
+  }
+
+  if (!best) return null;
+  const threshold = queryWords.some(w => w.length > 5) ? 1 : 2;
+  if (best.score < threshold) return null;
+
+  return best.entry;
+}
+
+export async function incrementKnowledgeUseCount(id: number) {
+  const db = await getDb();
+  if (!db) return;
+  await db.update(knowledgeEntries)
+    .set({ useCount: sql`${knowledgeEntries.useCount} + 1`, updatedAt: new Date() })
+    .where(eq(knowledgeEntries.id, id));
+}
+
+/**
+ * Log an unanswered question as a Suggestion.
+ * If the same question (fuzzy) already exists, increment its count.
+ */
+export async function logKnowledgeSuggestion(question: string) {
+  const db = await getDb();
+  if (!db) return;
+
+  const key = question.slice(0, 80).toLowerCase();
+  const existing = await db.select().from(knowledgeSuggestions)
+    .where(eq(knowledgeSuggestions.status, "pending"))
+    .orderBy(desc(knowledgeSuggestions.createdAt))
+    .limit(50);
+
+  const match = existing.find(s => s.question.slice(0, 80).toLowerCase() === key);
+  if (match) {
+    await db.update(knowledgeSuggestions)
+      .set({ occurrenceCount: sql`${knowledgeSuggestions.occurrenceCount} + 1`, lastSeen: new Date() })
+      .where(eq(knowledgeSuggestions.id, match.id));
+  } else {
+    await db.insert(knowledgeSuggestions).values({ question });
+  }
+}
+
+export async function getKnowledgeEntries(status?: string) {
+  const db = await getDb();
+  if (!db) return [];
+  const conditions = status ? [eq(knowledgeEntries.status, status)] : [];
+  return db.select().from(knowledgeEntries)
+    .where(conditions.length ? and(...conditions) : undefined)
+    .orderBy(desc(knowledgeEntries.createdAt));
+}
+
+export async function getKnowledgeEntryById(id: number) {
+  const db = await getDb();
+  if (!db) return null;
+  const [entry] = await db.select().from(knowledgeEntries).where(eq(knowledgeEntries.id, id));
+  return entry ?? null;
+}
+
+export async function createKnowledgeEntry(data: { question: string; answer: string; category?: string; source?: string }) {
+  const db = await getDb();
+  if (!db) return null;
+  const [inserted] = await db.insert(knowledgeEntries).values({ ...data, useCount: 0, status: "active" }).returning({ id: knowledgeEntries.id });
+  return inserted?.id ?? null;
+}
+
+export async function updateKnowledgeEntry(id: number, data: Partial<{ question: string; answer: string; category: string; status: string }>) {
+  const db = await getDb();
+  if (!db) return;
+  await db.update(knowledgeEntries).set({ ...data, updatedAt: new Date() }).where(eq(knowledgeEntries.id, id));
+}
+
+export async function deleteKnowledgeEntry(id: number) {
+  const db = await getDb();
+  if (!db) return;
+  await db.delete(knowledgeEntries).where(eq(knowledgeEntries.id, id));
+}
+
+export async function bulkCreateKnowledgeEntries(entries: { question: string; answer: string; category?: string; source?: string }[]) {
+  const db = await getDb();
+  if (!db) return { created: 0 };
+  if (!entries.length) return { created: 0 };
+  await db.insert(knowledgeEntries).values(entries.map(e => ({ ...e, useCount: 0, status: "active" })));
+  return { created: entries.length };
+}
+
+export async function getKnowledgeSuggestions(status?: string) {
+  const db = await getDb();
+  if (!db) return [];
+  const conditions = status ? [eq(knowledgeSuggestions.status, status)] : [];
+  return db.select().from(knowledgeSuggestions)
+    .where(conditions.length ? and(...conditions) : undefined)
+    .orderBy(desc(knowledgeSuggestions.occurrenceCount));
+}
+
+export async function promoteKnowledgeSuggestion(id: number, answer: string, category?: string) {
+  const db = await getDb();
+  if (!db) return null;
+  const [suggestion] = await db.select().from(knowledgeSuggestions).where(eq(knowledgeSuggestions.id, id));
+  if (!suggestion) return null;
+
+  const entryId = await createKnowledgeEntry({ question: suggestion.question, answer, category, source: "suggestion" });
+  await db.update(knowledgeSuggestions)
+    .set({ status: "promoted", promotedToId: entryId ?? undefined })
+    .where(eq(knowledgeSuggestions.id, id));
+  return entryId;
+}
+
+export async function dismissKnowledgeSuggestion(id: number) {
+  const db = await getDb();
+  if (!db) return;
+  await db.update(knowledgeSuggestions).set({ status: "dismissed" }).where(eq(knowledgeSuggestions.id, id));
+}
+
+// Knowledge Products
+export async function getKnowledgeProducts() {
+  const db = await getDb();
+  if (!db) return [];
+  return db.select().from(knowledgeProducts).where(eq(knowledgeProducts.status, "active")).orderBy(knowledgeProducts.model);
+}
+
+export async function bulkCreateKnowledgeProducts(products: Omit<InsertKnowledgeProduct, "id" | "createdAt" | "status">[]) {
+  const db = await getDb();
+  if (!db) return { created: 0 };
+  if (!products.length) return { created: 0 };
+  await db.insert(knowledgeProducts).values(products.map(p => ({ ...p, status: "active" })));
+  return { created: products.length };
+}
+
+export async function deleteAllKnowledgeProducts() {
+  const db = await getDb();
+  if (!db) return;
+  await db.delete(knowledgeProducts);
+}

+ 111 - 0
server/flowEngine.ts

@@ -0,0 +1,111 @@
+/**
+ * Flow Engine — executes saved Workflow flows for incoming chat messages.
+ *
+ * Execution model:
+ * 1. Load all active flows from the DB
+ * 2. For each flow, find its intent node and check if user message matches
+ * 3. If matched, traverse the flow graph from intent → response nodes
+ * 4. Return the response content (or null if no flow matched)
+ */
+
+import { getDb } from "./db";
+import { workflowNodes } from "../drizzle/schema";
+import { eq } from "drizzle-orm";
+
+export interface FlowResult {
+  content: string;
+  shouldEscalate?: boolean;
+  flowId: string;
+  flowName?: string;
+}
+
+// Intent patterns for the 5 live Support Flows
+const FLOW_INTENT_PATTERNS: Record<string, RegExp[]> = {
+  "check-order-status": [
+    /\border\s*status\b/i,
+    /\bcheck.*order\b/i,
+    /\bwhere.*my order\b/i,
+    /\border.*update\b/i,
+    /\bmy order\b/i,
+  ],
+  "track-shipment": [
+    /\btrack.*shipment\b/i,
+    /\btracking\b/i,
+    /\bshipment.*status\b/i,
+    /\bwhere.*package\b/i,
+    /\bdelivery.*status\b/i,
+    /\btrack.*package\b/i,
+  ],
+  "submit-return": [
+    /\breturn\b/i,
+    /\bRMA\b/i,
+    /\bsend.*back\b/i,
+    /\breturn.*request\b/i,
+    /\brefund\b/i,
+  ],
+  "cancel-order": [
+    /\bcancel.*order\b/i,
+    /\bcancel.*my order\b/i,
+    /\bstop.*order\b/i,
+  ],
+  "faq-deflection": [
+    /\bpayment\b/i,
+    /\bwarranty\b/i,
+    /\bhow.*long.*delivery\b/i,
+    /\bshipping.*time\b/i,
+    /\bminimum.*order\b/i,
+  ],
+};
+
+// Fallback static responses for flows that don't have DB nodes yet
+const FLOW_STATIC_RESPONSES: Record<string, string | null> = {
+  "check-order-status": "To check your order status, please provide your **Sales Order number** (e.g., SO-12345) and I'll look it up for you right away.",
+  "track-shipment": "I can help you track your shipment! Please share your **order number** or **tracking number** and I'll pull up the latest status.",
+  "submit-return": "I can guide you through the return process. Please provide:\n1. Your **order number**\n2. The **item(s)** you want to return\n3. The **reason** for the return\n\nOur return policy allows returns within 30 days of delivery.",
+  "cancel-order": "I can help with order cancellation. Please provide your **order number** and I'll check if it's still within the cancellation window. Note: orders that have already shipped cannot be cancelled.",
+  "faq-deflection": null, // handled by knowledge base
+};
+
+/**
+ * Detect if the user message matches a flow intent.
+ * Returns the flowId or null.
+ */
+export function detectFlowIntent(message: string): string | null {
+  for (const [flowId, patterns] of Object.entries(FLOW_INTENT_PATTERNS)) {
+    if (patterns.some(p => p.test(message))) {
+      return flowId;
+    }
+  }
+  return null;
+}
+
+/**
+ * Execute a flow by ID. Returns the response content or null.
+ * Tries DB-saved nodes first, falls back to static responses.
+ */
+export async function executeFlow(flowId: string, _userMessage: string): Promise<FlowResult | null> {
+  // Try to load flow nodes from DB
+  try {
+    const db = await getDb();
+    if (db) {
+      const nodes = await db.select().from(workflowNodes).where(eq(workflowNodes.workflowId, flowId));
+      if (nodes.length > 0) {
+        const responseNodes = nodes.filter(n => n.type === "response" && (n.config as any)?.message);
+        if (responseNodes.length > 0) {
+          const escalationNode = nodes.find(n => n.type === "escalation");
+          const content = responseNodes.map(n => (n.config as any).message).join("\n\n");
+          return { content, shouldEscalate: !!escalationNode, flowId };
+        }
+      }
+    }
+  } catch (err) {
+    console.error("[FlowEngine] DB error:", err);
+  }
+
+  const staticResponse = FLOW_STATIC_RESPONSES[flowId];
+  if (staticResponse) {
+    return { content: staticResponse, flowId };
+  }
+
+  return null;
+}

+ 166 - 2
server/routers.ts

@@ -25,7 +25,13 @@ import {
   trackAnalyticsEvent, getAnalyticsEvents, getAnalyticsSummary,
   createDataSource, getDataSources, getDataSourceById, updateDataSource, deleteDataSource,
   createApiConnection, getApiConnections, getApiConnectionById, updateApiConnection, deleteApiConnection, incrementApiConnectionExecution,
+  searchKnowledge, incrementKnowledgeUseCount, logKnowledgeSuggestion,
+  getKnowledgeEntries, getKnowledgeEntryById, createKnowledgeEntry,
+  updateKnowledgeEntry, deleteKnowledgeEntry, bulkCreateKnowledgeEntries,
+  getKnowledgeSuggestions, promoteKnowledgeSuggestion, dismissKnowledgeSuggestion,
+  getKnowledgeProducts, bulkCreateKnowledgeProducts, deleteAllKnowledgeProducts,
 } from "./db";
+import { detectFlowIntent, executeFlow } from "./flowEngine";
 import { messages } from "../drizzle/schema";
 import { eq, desc } from "drizzle-orm";
 import { sdk } from "./_core/sdk";
@@ -238,7 +244,10 @@ export const appRouter = router({
         await addMessage({
           conversationId: conversation.id,
           sender: "bot",
-          content: "Welcome to Homelegance! I'm your AI furniture assistant. I can help you with:\n\n- **Product Discovery** — Find furniture by style, room, or collection\n- **Order Status** — Check your order details\n- **Dealer Locator** — Find authorized retailers near you\n- **Warranty & Returns** — Get policy information\n\nHow can I help you today?",
+          content: "Welcome to Homelegance! I'm **Ellie**, your AI furniture assistant.",
+          metadata: {
+            quickReplies: ["🔥 Hot Deals", "📦 Order Status", "🛋️ Product Catalog"],
+          },
         });
         return { sessionId, conversationId: conversation.id };
       }),
@@ -271,6 +280,55 @@ export const appRouter = router({
           };
         }
 
+        // ── Knowledge Base First ──────────────────────────────────────────────
+        // Search Q&A knowledge base before calling LLM.
+        let knowledgeAnswer: string | null = null;
+        try {
+          const kbMatch = await searchKnowledge(input.content);
+          if (kbMatch) {
+            knowledgeAnswer = kbMatch.answer;
+            // Fire-and-forget: increment use count
+            incrementKnowledgeUseCount(kbMatch.id).catch(() => {});
+          }
+        } catch (kbErr) {
+          console.error("[KB] search error:", kbErr);
+        }
+
+        if (knowledgeAnswer) {
+          await addMessage({ conversationId: conversation.id, sender: "bot", content: knowledgeAnswer });
+          return { reply: knowledgeAnswer, status: conversation.status, source: "knowledge" as const };
+        }
+
+        // ── Workflow Flow Engine ──────────────────────────────────────────────
+        // Check if user message triggers a live Support Flow
+        let flowResult: { content: string; shouldEscalate?: boolean; flowId: string } | null = null;
+        try {
+          const matchedFlowId = detectFlowIntent(input.content);
+          if (matchedFlowId) {
+            flowResult = await executeFlow(matchedFlowId, input.content);
+          }
+        } catch (flowErr) {
+          console.error("[Flow] engine error:", flowErr);
+        }
+
+        if (flowResult) {
+          trackAnalyticsEvent({
+            conversationId: conversation.id,
+            sessionId: input.sessionId,
+            eventType: "flow_triggered",
+            category: flowResult.flowId,
+          }).catch(() => {});
+
+          if (flowResult.shouldEscalate) {
+            await updateConversationStatus(conversation.id, "escalated");
+            await addMessage({ conversationId: conversation.id, sender: "bot", content: flowResult.content });
+            return { reply: flowResult.content, status: "escalated" as const, source: "flow" as const };
+          }
+
+          await addMessage({ conversationId: conversation.id, sender: "bot", content: flowResult.content });
+          return { reply: flowResult.content, status: conversation.status, source: "flow" as const };
+        }
+
         const history = await getMessagesByConversation(conversation.id);
 
         // ── ERP intent detection & context injection ──────────────────────────
@@ -383,7 +441,10 @@ export const appRouter = router({
             content: botReply,
           });
 
-          return { reply: botReply, status: conversation.status };
+          // Auto-log as suggestion for continuous improvement
+          logKnowledgeSuggestion(input.content).catch(() => {});
+
+          return { reply: botReply, status: conversation.status, source: "llm" as const };
         } catch (error) {
           console.error("[Chat] LLM error:", error);
           const fallback = "I apologize for the inconvenience. I'm experiencing a temporary issue. Would you like me to connect you with a human agent?";
@@ -1210,6 +1271,109 @@ Return ONLY the JSON array, no markdown or explanation.`,
         }
       }),
   }),
+
+  /* ─── Knowledge Management Router ─── */
+  knowledge: router({
+    // Q&A Entries
+    listEntries: adminProcedure
+      .input(z.object({ status: z.string().optional() }).optional())
+      .query(async ({ input }) => getKnowledgeEntries(input?.status)),
+
+    getEntry: adminProcedure
+      .input(z.object({ id: z.number() }))
+      .query(async ({ input }) => getKnowledgeEntryById(input.id)),
+
+    createEntry: adminProcedure
+      .input(z.object({
+        question: z.string().min(1),
+        answer: z.string().min(1),
+        category: z.string().optional(),
+      }))
+      .mutation(async ({ input }) => {
+        const id = await createKnowledgeEntry({ ...input, source: "manual" });
+        return { id };
+      }),
+
+    updateEntry: adminProcedure
+      .input(z.object({
+        id: z.number(),
+        question: z.string().optional(),
+        answer: z.string().optional(),
+        category: z.string().optional(),
+        status: z.enum(["active", "inactive"]).optional(),
+      }))
+      .mutation(async ({ input }) => {
+        const { id, ...data } = input;
+        await updateKnowledgeEntry(id, data);
+        return { success: true };
+      }),
+
+    deleteEntry: adminProcedure
+      .input(z.object({ id: z.number() }))
+      .mutation(async ({ input }) => {
+        await deleteKnowledgeEntry(input.id);
+        return { success: true };
+      }),
+
+    importEntries: adminProcedure
+      .input(z.object({
+        entries: z.array(z.object({
+          question: z.string().min(1),
+          answer: z.string().min(1),
+          category: z.string().optional(),
+        })),
+        source: z.string().default("csv"),
+      }))
+      .mutation(async ({ input }) => {
+        return bulkCreateKnowledgeEntries(input.entries.map(e => ({ ...e, source: input.source })));
+      }),
+
+    // Suggestions
+    listSuggestions: adminProcedure
+      .input(z.object({ status: z.string().optional() }).optional())
+      .query(async ({ input }) => getKnowledgeSuggestions(input?.status)),
+
+    promoteSuggestion: adminProcedure
+      .input(z.object({
+        id: z.number(),
+        answer: z.string().min(1),
+        category: z.string().optional(),
+      }))
+      .mutation(async ({ input }) => {
+        const entryId = await promoteKnowledgeSuggestion(input.id, input.answer, input.category);
+        return { entryId };
+      }),
+
+    dismissSuggestion: adminProcedure
+      .input(z.object({ id: z.number() }))
+      .mutation(async ({ input }) => {
+        await dismissKnowledgeSuggestion(input.id);
+        return { success: true };
+      }),
+
+    // Products
+    listProducts: adminProcedure.query(getKnowledgeProducts),
+
+    importProducts: adminProcedure
+      .input(z.object({
+        products: z.array(z.object({
+          model: z.string(),
+          description: z.string().optional(),
+          categories: z.string().optional(),
+          collection: z.string().optional(),
+          price: z.string().optional(),
+          availability: z.string().optional(),
+          features: z.string().optional(),
+          dimensions: z.string().optional(),
+          imageUrl: z.string().optional(),
+        })),
+        replaceAll: z.boolean().default(false),
+      }))
+      .mutation(async ({ input }) => {
+        if (input.replaceAll) await deleteAllKnowledgeProducts();
+        return bulkCreateKnowledgeProducts(input.products);
+      }),
+  }),
 });
 
 export type AppRouter = typeof appRouter;