فهرست منبع

feat: CSAT rating, i18n, playground model selector, ERP product images, API connection runner

- CSAT: star rating UI in chat widget (5-star, auto-shown on resolved, persisted via rateSatisfaction)
- i18n: react-i18next setup with en/zh translation files, all widget strings externalized
- Playground: model selector (Haiku/Sonnet/Opus) + system prompt textarea in interactive mode
- ERP images: /catalog/images FastAPI endpoint + fetchProductImages client + lookupProductImages tool
  Uses public.config File_Url base + shop.shop_product_picture.apppicture_path
- ERP image intent detection in sendMessage (keywords: image/photo/picture/show me + model pattern)
- apiConnectionRunner: live API fetch with outputVariables dot-path mapping, upserts knowledgeEntries
- Schema: csatRating, csatComment, escalatedAt, firstAgentReplyAt columns on conversations table
- db.ts: rateConversation, getIntentStats, getResponseTimeStats helpers; firstAgentReplyAt COALESCE
- flowEngine: leads-capture + sales-inquiry intents; proper graph traversal via nodeId/edge adjacency
- llm.ts: model and temperature params in InvokeParams + invokeLLM

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Tony T 5 روز پیش
والد
کامیت
24831aeb88

+ 101 - 38
client/src/components/ChatbotWidgetLive.tsx

@@ -3,8 +3,9 @@
  * Shows: "What can we help you with today?" + Orders, Shipping, Returning, Cancelling buttons
  */
 import { useState, useRef, useEffect } from "react";
+import { useTranslation } from "react-i18next";
 import { trpc } from "@/lib/trpc";
-import { Bot, X, Send, Loader2, User, Headphones, Sparkles, ChevronDown, MoreVertical } from "lucide-react";
+import { Bot, X, Send, Loader2, User, Headphones, Sparkles, ChevronDown, MoreVertical, Star } from "lucide-react";
 import { Streamdown } from "streamdown";
 
 const QUICK_REPLIES = [
@@ -15,12 +16,16 @@ const QUICK_REPLIES = [
 ];
 
 export default function ChatbotWidgetLive() {
+  const { t } = useTranslation();
   const [isOpen, setIsOpen] = useState(false);
   const [inputText, setInputText] = useState("");
   const [sessionId, setSessionId] = useState<string | null>(null);
   const [localMessages, setLocalMessages] = useState<Array<{ id: number; sender: string; content: string; createdAt: string }>>([]);
   const [isTyping, setIsTyping] = useState(false);
   const [showQuickReplies, setShowQuickReplies] = useState(true);
+  const [csatRating, setCsatRating] = useState<number | null>(null);
+  const [csatHover, setCsatHover] = useState<number | null>(null);
+  const [csatSubmitted, setCsatSubmitted] = useState(false);
   const messagesEndRef = useRef<HTMLDivElement>(null);
   const scrollContainerRef = useRef<HTMLDivElement>(null);
   const isNearBottomRef = useRef(true);
@@ -37,14 +42,22 @@ export default function ChatbotWidgetLive() {
   );
 
   const sendMessage = trpc.chat.sendMessage.useMutation({
-    onSuccess: () => {
-      setIsTyping(false);
-    },
-    onError: () => {
-      setIsTyping(false);
-    },
+    onSuccess: () => { setIsTyping(false); },
+    onError: () => { setIsTyping(false); },
   });
 
+  const rateSatisfaction = trpc.chat.rateSatisfaction.useMutation({
+    onSuccess: () => { setCsatSubmitted(true); },
+  });
+
+  // Pre-fill rating if already submitted in a previous session
+  useEffect(() => {
+    if (messagesData?.csatRating != null && !csatSubmitted) {
+      setCsatRating(messagesData.csatRating);
+      setCsatSubmitted(true);
+    }
+  }, [messagesData?.csatRating]);
+
   // Sync server messages to local state
   useEffect(() => {
     if (messagesData?.messages) {
@@ -147,7 +160,7 @@ export default function ChatbotWidgetLive() {
                 <div className="flex items-center gap-1">
                   <div className="w-1.5 h-1.5 rounded-full" style={{ background: "#4ade80" }} />
                   <span className="text-[10px] text-green-200">
-                    {messagesData?.status === "escalated" ? "Connected to agent" : "Always happy to help"}
+                    {messagesData?.status === "escalated" ? t("widget.statusConnected") : t("widget.statusOnline")}
                   </span>
                 </div>
               </div>
@@ -167,7 +180,7 @@ export default function ChatbotWidgetLive() {
             {startSession.isPending ? (
               <div className="flex items-center justify-center py-8">
                 <Loader2 className="w-5 h-5 animate-spin" style={{ color: "#14532D" }} />
-                <span className="ml-2 text-sm" style={{ color: "#78716C" }}>Starting conversation...</span>
+                <span className="ml-2 text-sm" style={{ color: "#78716C" }}>{t("widget.loading")}</span>
               </div>
             ) : (
               <>
@@ -189,7 +202,7 @@ export default function ChatbotWidgetLive() {
                         borderBottomLeftRadius: "4px",
                       }}
                     >
-                      <span>👋 What can we help you with today?</span>
+                      <span>{t("widget.greeting")}</span>
                     </div>
                   </div>
                 )}
@@ -210,7 +223,7 @@ export default function ChatbotWidgetLive() {
                           fontFamily: "'Source Sans 3', sans-serif",
                         }}
                       >
-                        {reply.label}
+                        {t(`quickReplies.${reply.label.toLowerCase()}`)}
                       </button>
                     ))}
                   </div>
@@ -256,6 +269,48 @@ export default function ChatbotWidgetLive() {
                     </div>
                   );
                 })}
+                {/* CSAT rating card — shown when conversation is resolved */}
+                {messagesData?.status === "resolved" && (
+                  <div className="mx-1 mt-2 p-3 rounded-xl text-center" style={{ background: "#f5f0e8", border: "1px solid #e7e0d5" }}>
+                    {csatSubmitted ? (
+                      <p className="text-[12px] font-medium" style={{ color: "#14532D" }}>
+                        {t("widget.csatThanks")}
+                      </p>
+                    ) : (
+                      <>
+                        <p className="text-[12px] font-medium mb-2" style={{ color: "#292524" }}>
+                          {t("widget.csatPrompt")}
+                        </p>
+                        <div className="flex justify-center gap-1 mb-2">
+                          {[1, 2, 3, 4, 5].map(star => (
+                            <button
+                              key={star}
+                              onMouseEnter={() => setCsatHover(star)}
+                              onMouseLeave={() => setCsatHover(null)}
+                              onClick={() => {
+                                setCsatRating(star);
+                                if (sessionId) {
+                                  rateSatisfaction.mutate({ sessionId, rating: star });
+                                }
+                              }}
+                              className="p-0.5 transition-transform hover:scale-110"
+                              aria-label={`Rate ${star} star${star > 1 ? "s" : ""}`}
+                            >
+                              <Star
+                                className="w-6 h-6"
+                                style={{
+                                  fill: (csatHover ?? csatRating ?? 0) >= star ? "#f59e0b" : "none",
+                                  color: (csatHover ?? csatRating ?? 0) >= star ? "#f59e0b" : "#d6d3d1",
+                                }}
+                              />
+                            </button>
+                          ))}
+                        </div>
+                        <p className="text-[10px]" style={{ color: "#a8a29e" }}>{t("widget.csatTapStar")}</p>
+                      </>
+                    )}
+                  </div>
+                )}
               </>
             )}
             {isTyping && (
@@ -277,35 +332,43 @@ export default function ChatbotWidgetLive() {
 
           {/* Input area */}
           <div className="p-3 border-t" style={{ borderColor: "#e7e0d5" }}>
-            {showQuickReplies && !hasUserMessages ? (
-              <div className="text-center py-1">
-                <span className="text-xs" style={{ color: "#a8a29e", fontFamily: "'Source Sans 3', sans-serif" }}>
-                  Hit the buttons to respond
-                </span>
+            {messagesData?.status === "resolved" ? (
+              <div className="text-center py-1.5">
+                <span className="text-xs" style={{ color: "#a8a29e" }}>{t("widget.conversationClosed")}</span>
               </div>
-            ) : null}
-            <div className="flex gap-2">
-              <input
-                type="text"
-                value={inputText}
-                onChange={(e) => setInputText(e.target.value)}
-                onKeyDown={handleKeyDown}
-                placeholder="Type your message..."
-                className="flex-1 px-3 py-2 rounded-xl text-sm border"
-                style={{ borderColor: "#e7e0d5", fontFamily: "'Source Sans 3', sans-serif", background: "#fff" }}
-                disabled={isTyping || startSession.isPending}
-              />
-              <button
-                onClick={() => handleSend()}
-                disabled={!inputText.trim() || isTyping || !sessionId}
-                className="w-9 h-9 rounded-xl flex items-center justify-center transition-all disabled:opacity-40"
-                style={{ background: "#14532D", color: "#fff" }}
-              >
-                <Send className="w-4 h-4" />
-              </button>
-            </div>
+            ) : (
+              <>
+                {showQuickReplies && !hasUserMessages && (
+                  <div className="text-center py-1">
+                    <span className="text-xs" style={{ color: "#a8a29e", fontFamily: "'Source Sans 3', sans-serif" }}>
+                      {t("widget.hitButtons")}
+                    </span>
+                  </div>
+                )}
+                <div className="flex gap-2">
+                  <input
+                    type="text"
+                    value={inputText}
+                    onChange={(e) => setInputText(e.target.value)}
+                    onKeyDown={handleKeyDown}
+                    placeholder={t("widget.placeholder")}
+                    className="flex-1 px-3 py-2 rounded-xl text-sm border"
+                    style={{ borderColor: "#e7e0d5", fontFamily: "'Source Sans 3', sans-serif", background: "#fff" }}
+                    disabled={isTyping || startSession.isPending}
+                  />
+                  <button
+                    onClick={() => handleSend()}
+                    disabled={!inputText.trim() || isTyping || !sessionId}
+                    className="w-9 h-9 rounded-xl flex items-center justify-center transition-all disabled:opacity-40"
+                    style={{ background: "#14532D", color: "#fff" }}
+                  >
+                    <Send className="w-4 h-4" />
+                  </button>
+                </div>
+              </>
+            )}
             <div className="text-center mt-2">
-              <span className="text-[10px]" style={{ color: "#d6d3d1" }}>Powered by Ellie · Homelegance AI</span>
+              <span className="text-[10px]" style={{ color: "#d6d3d1" }}>{t("widget.poweredBy")}</span>
             </div>
           </div>
         </div>

+ 21 - 0
client/src/i18n/en.json

@@ -0,0 +1,21 @@
+{
+  "widget": {
+    "greeting": "👋 What can we help you with today?",
+    "statusConnected": "Connected to agent",
+    "statusOnline": "Always happy to help",
+    "placeholder": "Type your message...",
+    "poweredBy": "Powered by Ellie · Homelegance AI",
+    "hitButtons": "Hit the buttons to respond",
+    "conversationClosed": "This conversation has been closed.",
+    "loading": "Starting conversation...",
+    "csatPrompt": "How did we do today?",
+    "csatThanks": "Thanks for your feedback! 🙏",
+    "csatTapStar": "Tap a star to rate"
+  },
+  "quickReplies": {
+    "orders": "Orders",
+    "shipping": "Shipping",
+    "returning": "Returning",
+    "cancelling": "Cancelling"
+  }
+}

+ 24 - 0
client/src/i18n/index.ts

@@ -0,0 +1,24 @@
+import i18n from "i18next";
+import { initReactI18next } from "react-i18next";
+import LanguageDetector from "i18next-browser-languagedetector";
+
+import en from "./en.json";
+import zh from "./zh.json";
+
+i18n
+  .use(LanguageDetector)
+  .use(initReactI18next)
+  .init({
+    resources: {
+      en: { translation: en },
+      zh: { translation: zh },
+    },
+    fallbackLng: "en",
+    interpolation: { escapeValue: false },
+    detection: {
+      order: ["querystring", "navigator"],
+      lookupQuerystring: "lang",
+    },
+  });
+
+export default i18n;

+ 21 - 0
client/src/i18n/zh.json

@@ -0,0 +1,21 @@
+{
+  "widget": {
+    "greeting": "👋 今天有什么可以帮到您?",
+    "statusConnected": "已连接客服",
+    "statusOnline": "随时为您服务",
+    "placeholder": "输入您的消息...",
+    "poweredBy": "由 Ellie · Homelegance AI 提供支持",
+    "hitButtons": "点击按钮选择",
+    "conversationClosed": "此对话已关闭。",
+    "loading": "正在开始对话...",
+    "csatPrompt": "您对本次服务满意吗?",
+    "csatThanks": "感谢您的反馈!🙏",
+    "csatTapStar": "点击星星评分"
+  },
+  "quickReplies": {
+    "orders": "订单",
+    "shipping": "运输",
+    "returning": "退货",
+    "cancelling": "取消"
+  }
+}

+ 1 - 0
client/src/main.tsx

@@ -7,6 +7,7 @@ import superjson from "superjson";
 import App from "./App";
 import { getLoginUrl } from "./const";
 import "./index.css";
+import "./i18n";
 
 const queryClient = new QueryClient();
 

+ 73 - 6
client/src/pages/Playground.tsx

@@ -29,6 +29,14 @@ interface TestMessage {
   flowStep?: string;
 }
 
+const MODELS = [
+  { value: "claude-haiku-4-5-20251001", label: "Haiku 4.5 — Fast" },
+  { value: "claude-sonnet-4-6",         label: "Sonnet 4.6 — Balanced" },
+  { value: "claude-opus-4-7",           label: "Opus 4.7 — Best" },
+] as const;
+
+type ModelId = typeof MODELS[number]["value"];
+
 export default function Playground() {
   const [sessionId, setSessionId] = useState<string | null>(null);
   const [messages, setMessages] = useState<TestMessage[]>([]);
@@ -38,17 +46,18 @@ export default function Playground() {
   const [selectedFlow, setSelectedFlow] = useState<string | null>(null);
   const [flowPath, setFlowPath] = useState<string[]>([]);
   const [testMode, setTestMode] = useState<"interactive" | "flow">("interactive");
+  const [selectedModel, setSelectedModel] = useState<ModelId>("claude-sonnet-4-6");
+  const [systemPrompt, setSystemPrompt] = useState("");
+  const [playgroundHistory, setPlaygroundHistory] = useState<{ role: "user" | "assistant"; content: string }[]>([]);
   const messagesEndRef = useRef<HTMLDivElement>(null);
 
   const startSession = trpc.chat.startSession.useMutation({
-    onSuccess: (data) => {
-      setSessionId(data.sessionId);
-    },
+    onSuccess: (data) => { setSessionId(data.sessionId); },
   });
 
   const { data: serverMessages } = trpc.chat.getMessages.useQuery(
     { sessionId: sessionId || "" },
-    { enabled: !!sessionId, refetchInterval: 2000 }
+    { enabled: !!sessionId && testMode === "flow", refetchInterval: 2000 }
   );
 
   const sendMessage = trpc.chat.sendMessage.useMutation({
@@ -56,6 +65,21 @@ export default function Playground() {
     onError: () => setIsTyping(false),
   });
 
+  const playgroundChat = trpc.playground.chat.useMutation({
+    onSuccess: (data) => {
+      setPlaygroundHistory(prev => [...prev, { role: "assistant", content: data.reply }]);
+      setMessages(prev => [...prev, {
+        id: Date.now(),
+        sender: "bot",
+        content: data.reply,
+        timestamp: new Date().toISOString(),
+        flowStep: `Model: ${data.model}`,
+      }]);
+      setIsTyping(false);
+    },
+    onError: () => setIsTyping(false),
+  });
+
   const trackEvent = trpc.analytics.track.useMutation();
 
   // Sync server messages
@@ -94,6 +118,7 @@ export default function Playground() {
     setSessionId(null);
     setInputText("");
     setIsTyping(false);
+    setPlaygroundHistory([]);
   };
 
   const handleOptionClick = (optionId: string) => {
@@ -123,7 +148,7 @@ export default function Playground() {
   };
 
   const handleSend = () => {
-    if (!inputText.trim() || !sessionId || isTyping) return;
+    if (!inputText.trim() || isTyping) return;
     const text = inputText.trim();
     setInputText("");
     setIsTyping(true);
@@ -136,7 +161,23 @@ export default function Playground() {
       timestamp: new Date().toISOString(),
     }]);
 
-    sendMessage.mutate({ sessionId, content: text });
+    if (testMode === "flow" && sessionId) {
+      // Use real chat session to test flows end-to-end
+      sendMessage.mutate({ sessionId, content: text });
+    } else {
+      // Direct LLM call — model + system prompt are configurable
+      const newHistory: { role: "user" | "assistant"; content: string }[] = [
+        ...playgroundHistory,
+        { role: "user", content: text },
+      ];
+      setPlaygroundHistory(newHistory);
+      playgroundChat.mutate({
+        content: text,
+        model: selectedModel,
+        systemPrompt: systemPrompt || undefined,
+        history: playgroundHistory,
+      });
+    }
   };
 
   const handleKeyDown = (e: React.KeyboardEvent) => {
@@ -199,6 +240,32 @@ export default function Playground() {
             </div>
           </div>
 
+          {/* Interactive mode controls */}
+          {testMode === "interactive" && (
+            <div className="space-y-2">
+              <span className="text-[10px] font-semibold uppercase tracking-wider" style={{ color: "#a8a29e" }}>Model</span>
+              <select
+                value={selectedModel}
+                onChange={(e) => setSelectedModel(e.target.value as ModelId)}
+                className="w-full px-2 py-1.5 rounded-lg text-[11px] border"
+                style={{ borderColor: "#e7e0d5", background: "#fff", color: "#292524" }}
+              >
+                {MODELS.map(m => (
+                  <option key={m.value} value={m.value}>{m.label}</option>
+                ))}
+              </select>
+              <span className="text-[10px] font-semibold uppercase tracking-wider" style={{ color: "#a8a29e" }}>System Prompt</span>
+              <textarea
+                value={systemPrompt}
+                onChange={(e) => setSystemPrompt(e.target.value)}
+                placeholder="Optional system instructions..."
+                rows={4}
+                className="w-full px-2 py-1.5 rounded-lg text-[11px] border resize-none"
+                style={{ borderColor: "#e7e0d5", background: "#fff", color: "#292524" }}
+              />
+            </div>
+          )}
+
           {/* Quick flow test */}
           {testMode === "flow" && (
             <div className="space-y-1.5">

+ 17 - 11
drizzle/schema.ts

@@ -78,17 +78,23 @@ export type InsertPasswordResetToken = typeof passwordResetTokens.$inferInsert;
  * Chat conversations — each visitor session creates one conversation.
  */
 export const conversations = chatbotSchema.table("conversations", {
-  id:              serial("id").primaryKey(),
-  sessionId:       varchar("sessionId",    { length: 64  }).notNull().unique(),
-  visitorName:     varchar("visitorName",  { length: 255 }),
-  visitorEmail:    varchar("visitorEmail", { length: 320 }),
-  customerId:      varchar("customerId",   { length: 64  }),
-  salesRep:        varchar("salesRep",     { length: 255 }),
-  status:          conversationStatusEnum("status").default("active").notNull(),
-  assignedAgentId: integer("assignedAgentId"),
-  metadata:        jsonb("metadata"),
-  createdAt:       timestamp("createdAt").defaultNow().notNull(),
-  updatedAt:       timestamp("updatedAt").defaultNow().notNull(),
+  id:               serial("id").primaryKey(),
+  sessionId:        varchar("sessionId",    { length: 64  }).notNull().unique(),
+  visitorName:      varchar("visitorName",  { length: 255 }),
+  visitorEmail:     varchar("visitorEmail", { length: 320 }),
+  customerId:       varchar("customerId",   { length: 64  }),
+  salesRep:         varchar("salesRep",     { length: 255 }),
+  status:           conversationStatusEnum("status").default("active").notNull(),
+  assignedAgentId:  integer("assignedAgentId"),
+  metadata:         jsonb("metadata"),
+  // CSAT rating (1–5) collected after conversation resolves
+  csatRating:       integer("csat_rating"),
+  csatComment:      text("csat_comment"),
+  // Agent response-time tracking
+  escalatedAt:      timestamp("escalated_at"),
+  firstAgentReplyAt: timestamp("first_agent_reply_at"),
+  createdAt:        timestamp("createdAt").defaultNow().notNull(),
+  updatedAt:        timestamp("updatedAt").defaultNow().notNull(),
 });
 
 export type Conversation       = typeof conversations.$inferSelect;

+ 50 - 0
erp-bridge/main.py

@@ -174,6 +174,11 @@ class StockRequest(BaseModel):
     limit: int = Field(50, ge=1, le=500)
 
 
+class ProductImagesRequest(BaseModel):
+    model: str = Field(..., description="Model number (exact or partial ILIKE)")
+    limit: int = Field(10, ge=1, le=50)
+
+
 # ──────────────────────────────────────────────────────────────────────────────
 # Endpoints
 # ──────────────────────────────────────────────────────────────────────────────
@@ -295,6 +300,51 @@ async def stock_search(
         )
 
 
+@app.post("/catalog/images", dependencies=[Depends(verify_api_key)])
+async def catalog_images(
+    req: ProductImagesRequest,
+    ctx: UserCtx = Depends(get_user_ctx),
+) -> dict[str, Any]:
+    """
+    Return product images for a given model number.
+    Fetches the image base URL from public.config and joins with shop picture tables.
+    Returns: { base_url, images: [{ model, apppicture_path, full_url }] }
+    """
+    assert DB_POOL is not None
+
+    # Get image server base URL from config
+    base_url_row = await DB_POOL.fetchrow(
+        "SELECT item_value FROM public.config WHERE item = 'File_Url' LIMIT 1"
+    )
+    base_url: str = (base_url_row["item_value"] if base_url_row else "https://www.homelegance.com/").rstrip("/")
+
+    # Fetch image paths for the requested model
+    rows = await DB_POOL.fetch(
+        """
+        SELECT c.model, spp.apppicture_path
+        FROM shop.shop_product_picture spp
+        JOIN shop.shop_product sp ON spp.product_id = sp.product_id
+        JOIN public.catalog c ON sp.caf_serial_no = c.serial_no
+        WHERE c.model ILIKE $1
+        LIMIT $2
+        """,
+        f"%{req.model}%",
+        req.limit,
+    )
+
+    images = [
+        {
+            "model": row["model"],
+            "apppicture_path": row["apppicture_path"],
+            "full_url": f"{base_url}{row['apppicture_path']}",
+        }
+        for row in rows
+        if row["apppicture_path"]
+    ]
+
+    return {"base_url": base_url, "images": images}
+
+
 # ──────────────────────────────────────────────────────────────────────────────
 # Dev runner (production uses uvicorn via systemd)
 # ──────────────────────────────────────────────────────────────────────────────

+ 5 - 2
package.json

@@ -13,6 +13,7 @@
     "db:push": "drizzle-kit push"
   },
   "dependencies": {
+    "@anthropic-ai/sdk": "^0.55.0",
     "@aws-sdk/client-s3": "^3.693.0",
     "@aws-sdk/s3-request-presigner": "^3.693.0",
     "@hookform/resolvers": "^5.2.2",
@@ -58,17 +59,19 @@
     "embla-carousel-react": "^8.6.0",
     "express": "^4.21.2",
     "framer-motion": "^12.23.22",
+    "i18next": "^26.0.8",
+    "i18next-browser-languagedetector": "^8.2.1",
     "input-otp": "^1.4.2",
     "jose": "6.1.0",
     "lucide-react": "^0.453.0",
-    "@anthropic-ai/sdk": "^0.55.0",
-    "postgres": "^3.4.0",
     "nanoid": "^5.1.5",
     "next-themes": "^0.4.6",
+    "postgres": "^3.4.0",
     "react": "^19.2.1",
     "react-day-picker": "^9.11.1",
     "react-dom": "^19.2.1",
     "react-hook-form": "^7.64.0",
+    "react-i18next": "^17.0.6",
     "react-resizable-panels": "^3.0.6",
     "recharts": "^2.15.2",
     "sonner": "^2.0.7",

+ 111 - 11
pnpm-lock.yaml

@@ -16,6 +16,9 @@ importers:
 
   .:
     dependencies:
+      '@anthropic-ai/sdk':
+        specifier: ^0.55.0
+        version: 0.55.1
       '@aws-sdk/client-s3':
         specifier: ^3.693.0
         version: 3.1015.0
@@ -141,7 +144,7 @@ importers:
         version: 17.3.1
       drizzle-orm:
         specifier: ^0.44.5
-        version: 0.44.7(mysql2@3.20.0(@types/node@24.7.0))
+        version: 0.44.7(mysql2@3.20.0(@types/node@24.7.0))(postgres@3.4.9)
       embla-carousel-react:
         specifier: ^8.6.0
         version: 8.6.0(react@19.2.1)
@@ -151,6 +154,12 @@ importers:
       framer-motion:
         specifier: ^12.23.22
         version: 12.23.22(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
+      i18next:
+        specifier: ^26.0.8
+        version: 26.0.8(typescript@5.9.3)
+      i18next-browser-languagedetector:
+        specifier: ^8.2.1
+        version: 8.2.1
       input-otp:
         specifier: ^1.4.2
         version: 1.4.2(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
@@ -160,15 +169,15 @@ importers:
       lucide-react:
         specifier: ^0.453.0
         version: 0.453.0(react@19.2.1)
-      mysql2:
-        specifier: ^3.15.0
-        version: 3.20.0(@types/node@24.7.0)
       nanoid:
         specifier: ^5.1.5
         version: 5.1.6
       next-themes:
         specifier: ^0.4.6
         version: 0.4.6(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
+      postgres:
+        specifier: ^3.4.0
+        version: 3.4.9
       react:
         specifier: ^19.2.1
         version: 19.2.1
@@ -181,6 +190,9 @@ importers:
       react-hook-form:
         specifier: ^7.64.0
         version: 7.64.0(react@19.2.1)
+      react-i18next:
+        specifier: ^17.0.6
+        version: 17.0.6(i18next@26.0.8(typescript@5.9.3))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(typescript@5.9.3)
       react-resizable-panels:
         specifier: ^3.0.6
         version: 3.0.6(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
@@ -293,6 +305,10 @@ packages:
   '@antfu/utils@9.3.0':
     resolution: {integrity: sha512-9hFT4RauhcUzqOE4f1+frMKLZrgNog5b06I7VmZQV1BkvwvqrbC8EBZf3L1eEL2AKb6rNKjER0sEvJiSP1FXEA==}
 
+  '@anthropic-ai/sdk@0.55.1':
+    resolution: {integrity: sha512-gjOMS4chmm8BxClKmCjNHmvf1FrO1Cn++CSX6K3YCZjz5JG4I9ZttQ/xEH4FBsz6HQyZvnUpiKlOAkmxaGmEaQ==}
+    hasBin: true
+
   '@aws-crypto/crc32@5.2.0':
     resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==}
     engines: {node: '>=16.0.0'}
@@ -535,6 +551,10 @@ packages:
     resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==}
     engines: {node: '>=6.9.0'}
 
+  '@babel/runtime@7.29.2':
+    resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==}
+    engines: {node: '>=6.9.0'}
+
   '@babel/template@7.27.2':
     resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==}
     engines: {node: '>=6.9.0'}
@@ -3339,6 +3359,9 @@ packages:
   hastscript@9.0.1:
     resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==}
 
+  html-parse-stringify@3.0.1:
+    resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==}
+
   html-url-attributes@3.0.1:
     resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==}
 
@@ -3349,6 +3372,17 @@ packages:
     resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==}
     engines: {node: '>= 0.8'}
 
+  i18next-browser-languagedetector@8.2.1:
+    resolution: {integrity: sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==}
+
+  i18next@26.0.8:
+    resolution: {integrity: sha512-BRzLom0mhDhV9v0QhgUUHWQJuwFmnr1194xEcNLYD6ym8y8s542n4jXUvRLnhNTbh9PmpU6kGZamyuGHQMsGjw==}
+    peerDependencies:
+      typescript: ^5 || ^6
+    peerDependenciesMeta:
+      typescript:
+        optional: true
+
   iconv-lite@0.4.24:
     resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
     engines: {node: '>=0.10.0'}
@@ -3880,6 +3914,10 @@ packages:
     resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
     engines: {node: ^10 || ^12 || >=14}
 
+  postgres@3.4.9:
+    resolution: {integrity: sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==}
+    engines: {node: '>=12'}
+
   prettier@3.6.2:
     resolution: {integrity: sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==}
     engines: {node: '>=14'}
@@ -3933,6 +3971,22 @@ packages:
     peerDependencies:
       react: ^16.8.0 || ^17 || ^18 || ^19
 
+  react-i18next@17.0.6:
+    resolution: {integrity: sha512-WzJ6SMKF+GTD7JZZqxSR1AKKmXjaSu39sClUrNlwxS4Tl7a99O+ltFy6yhPMO+wgZuxpQjJ2PZkfrQKmAqrLhw==}
+    peerDependencies:
+      i18next: '>= 26.0.1'
+      react: '>= 16.8.0'
+      react-dom: '*'
+      react-native: '*'
+      typescript: ^5 || ^6
+    peerDependenciesMeta:
+      react-dom:
+        optional: true
+      react-native:
+        optional: true
+      typescript:
+        optional: true
+
   react-is@16.13.1:
     resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
 
@@ -4457,6 +4511,10 @@ packages:
       jsdom:
         optional: true
 
+  void-elements@3.1.0:
+    resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==}
+    engines: {node: '>=0.10.0'}
+
   vscode-jsonrpc@8.2.0:
     resolution: {integrity: sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==}
     engines: {node: '>=14.0.0'}
@@ -4512,6 +4570,8 @@ snapshots:
 
   '@antfu/utils@9.3.0': {}
 
+  '@anthropic-ai/sdk@0.55.1': {}
+
   '@aws-crypto/crc32@5.2.0':
     dependencies:
       '@aws-crypto/util': 5.2.0
@@ -5065,6 +5125,8 @@ snapshots:
 
   '@babel/runtime@7.28.4': {}
 
+  '@babel/runtime@7.29.2': {}
+
   '@babel/template@7.27.2':
     dependencies:
       '@babel/code-frame': 7.27.1
@@ -7000,7 +7062,8 @@ snapshots:
       postcss: 8.5.6
       postcss-value-parser: 4.2.0
 
-  aws-ssl-profiles@1.1.2: {}
+  aws-ssl-profiles@1.1.2:
+    optional: true
 
   axios@1.12.2:
     dependencies:
@@ -7371,7 +7434,8 @@ snapshots:
 
   delayed-stream@1.0.0: {}
 
-  denque@2.1.0: {}
+  denque@2.1.0:
+    optional: true
 
   depd@2.0.0: {}
 
@@ -7405,9 +7469,10 @@ snapshots:
       esbuild: 0.25.10
       tsx: 4.21.0
 
-  drizzle-orm@0.44.7(mysql2@3.20.0(@types/node@24.7.0)):
+  drizzle-orm@0.44.7(mysql2@3.20.0(@types/node@24.7.0))(postgres@3.4.9):
     optionalDependencies:
       mysql2: 3.20.0(@types/node@24.7.0)
+      postgres: 3.4.9
 
   dunder-proto@1.0.1:
     dependencies:
@@ -7689,6 +7754,7 @@ snapshots:
   generate-function@2.3.1:
     dependencies:
       is-property: 1.0.2
+    optional: true
 
   gensync@1.0.0-beta.2: {}
 
@@ -7854,6 +7920,10 @@ snapshots:
       property-information: 7.1.0
       space-separated-tokens: 2.0.2
 
+  html-parse-stringify@3.0.1:
+    dependencies:
+      void-elements: 3.1.0
+
   html-url-attributes@3.0.1: {}
 
   html-void-elements@3.0.0: {}
@@ -7866,6 +7936,14 @@ snapshots:
       statuses: 2.0.1
       toidentifier: 1.0.1
 
+  i18next-browser-languagedetector@8.2.1:
+    dependencies:
+      '@babel/runtime': 7.28.4
+
+  i18next@26.0.8(typescript@5.9.3):
+    optionalDependencies:
+      typescript: 5.9.3
+
   iconv-lite@0.4.24:
     dependencies:
       safer-buffer: 2.1.2
@@ -7877,6 +7955,7 @@ snapshots:
   iconv-lite@0.7.2:
     dependencies:
       safer-buffer: 2.1.2
+    optional: true
 
   inherits@2.0.4: {}
 
@@ -7906,7 +7985,8 @@ snapshots:
 
   is-plain-obj@4.1.0: {}
 
-  is-property@1.0.2: {}
+  is-property@1.0.2:
+    optional: true
 
   is-what@4.1.16: {}
 
@@ -7995,7 +8075,8 @@ snapshots:
 
   lodash@4.17.21: {}
 
-  long@5.3.2: {}
+  long@5.3.2:
+    optional: true
 
   longest-streak@3.1.0: {}
 
@@ -8009,7 +8090,8 @@ snapshots:
     dependencies:
       yallist: 3.1.1
 
-  lru.min@1.1.4: {}
+  lru.min@1.1.4:
+    optional: true
 
   lucide-react@0.453.0(react@19.2.1):
     dependencies:
@@ -8472,10 +8554,12 @@ snapshots:
       lru.min: 1.1.4
       named-placeholders: 1.1.6
       sql-escaper: 1.3.3
+    optional: true
 
   named-placeholders@1.1.6:
     dependencies:
       lru.min: 1.1.4
+    optional: true
 
   nanoid@3.3.11: {}
 
@@ -8576,6 +8660,8 @@ snapshots:
       picocolors: 1.1.1
       source-map-js: 1.2.1
 
+  postgres@3.4.9: {}
+
   prettier@3.6.2: {}
 
   prop-types@15.8.1:
@@ -8626,6 +8712,17 @@ snapshots:
     dependencies:
       react: 19.2.1
 
+  react-i18next@17.0.6(i18next@26.0.8(typescript@5.9.3))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(typescript@5.9.3):
+    dependencies:
+      '@babel/runtime': 7.29.2
+      html-parse-stringify: 3.0.1
+      i18next: 26.0.8(typescript@5.9.3)
+      react: 19.2.1
+      use-sync-external-store: 1.6.0(react@19.2.1)
+    optionalDependencies:
+      react-dom: 19.2.1(react@19.2.1)
+      typescript: 5.9.3
+
   react-is@16.13.1: {}
 
   react-is@18.3.1: {}
@@ -8926,7 +9023,8 @@ snapshots:
 
   space-separated-tokens@2.0.2: {}
 
-  sql-escaper@1.3.3: {}
+  sql-escaper@1.3.3:
+    optional: true
 
   stackback@0.0.2: {}
 
@@ -9256,6 +9354,8 @@ snapshots:
       - supports-color
       - terser
 
+  void-elements@3.1.0: {}
+
   vscode-jsonrpc@8.2.0: {}
 
   vscode-languageserver-protocol@3.17.5:

+ 5 - 2
server/_core/llm.ts

@@ -67,6 +67,8 @@ export type InvokeParams = {
   output_schema?: OutputSchema;
   responseFormat?: ResponseFormat;
   response_format?: ResponseFormat;
+  model?: string;
+  temperature?: number;
 };
 
 export type ToolCall = {
@@ -124,7 +126,7 @@ export async function invokeLLM(params: InvokeParams): Promise<InvokeResult> {
     throw new Error("ANTHROPIC_API_KEY is not configured");
   }
 
-  const { messages, maxTokens, max_tokens } = params;
+  const { messages, maxTokens, max_tokens, model, temperature } = params;
 
   // Anthropic takes system prompt as a top-level param, not in the messages array
   const systemParts = messages
@@ -142,9 +144,10 @@ export async function invokeLLM(params: InvokeParams): Promise<InvokeResult> {
   const client = new Anthropic({ apiKey: ENV.anthropicApiKey });
 
   const response = await client.messages.create({
-    model: "claude-sonnet-4-6",
+    model: model ?? "claude-sonnet-4-6",
     max_tokens: maxTokens ?? max_tokens ?? 1024,
     ...(system ? { system } : {}),
+    ...(temperature != null ? { temperature } : {}),
     messages: anthropicMessages,
   });
 

+ 110 - 0
server/apiConnectionRunner.ts

@@ -0,0 +1,110 @@
+/**
+ * API Connection Runner — fetches live data from external APIs defined in the
+ * apiConnections table and stores the result as a knowledge entry.
+ */
+
+import { getDb } from "./db";
+import { apiConnections, knowledgeEntries } from "../drizzle/schema";
+import { eq, and } from "drizzle-orm";
+
+export interface RunResult {
+  success: boolean;
+  connectionId: number;
+  rowsStored?: number;
+  error?: string;
+}
+
+/**
+ * Execute a single API connection, store the response in knowledgeEntries,
+ * and update lastExecutedAt + executionCount on the connection record.
+ */
+export async function runApiConnection(connectionId: number): Promise<RunResult> {
+  const db = await getDb();
+  if (!db) return { success: false, connectionId, error: "Database not available" };
+
+  const [conn] = await db.select().from(apiConnections)
+    .where(eq(apiConnections.id, connectionId));
+  if (!conn) return { success: false, connectionId, error: "Connection not found" };
+  if (!conn.isActive) return { success: false, connectionId, error: "Connection is inactive" };
+
+  // Build request headers
+  const headers: Record<string, string> = { "Content-Type": "application/json" };
+  if (conn.headers && typeof conn.headers === "object") {
+    Object.assign(headers, conn.headers);
+  }
+
+  // Build request body (POST/PUT only)
+  const hasBody = conn.method === "POST" || conn.method === "PUT";
+  const body = hasBody ? JSON.stringify(conn.testPayload ?? {}) : undefined;
+
+  let rawData: unknown;
+  try {
+    const res = await fetch(conn.endpoint, { method: conn.method, headers, body });
+    if (!res.ok) {
+      throw new Error(`HTTP ${res.status} ${res.statusText}`);
+    }
+    rawData = await res.json().catch(() => res.text());
+  } catch (err: unknown) {
+    const msg = err instanceof Error ? err.message : String(err);
+    return { success: false, connectionId, error: `Fetch failed: ${msg}` };
+  }
+
+  // Apply output variable mapping to extract relevant fields
+  const extracted = applyOutputMapping(rawData, conn.outputVariables as Record<string, string> | null);
+
+  // Persist as a knowledge entry (upsert by connection id stored in metadata)
+  const content = JSON.stringify(extracted, null, 2);
+  const title = `API: ${conn.name}`;
+
+  const existing = await db.select({ id: knowledgeEntries.id })
+    .from(knowledgeEntries)
+    .where(eq(knowledgeEntries.source, "api"))
+    .limit(1);
+
+  if (existing.length > 0) {
+    await db.update(knowledgeEntries)
+      .set({ answer: content, updatedAt: new Date() })
+      .where(eq(knowledgeEntries.id, existing[0].id));
+  } else {
+    await db.insert(knowledgeEntries).values({
+      question: title,
+      answer: content,
+      category: conn.category ?? "api",
+      source: "api",
+    });
+  }
+
+  // Update connection metadata
+  await db.update(apiConnections)
+    .set({
+      lastExecutedAt: new Date(),
+      executionCount: (conn.executionCount ?? 0) + 1,
+    })
+    .where(eq(apiConnections.id, connectionId));
+
+  return { success: true, connectionId, rowsStored: 1 };
+}
+
+/**
+ * Apply dot-path output variable mappings to extract fields from the response.
+ * outputVariables format: { "fieldName": "path.to.value" }
+ * If no mappings defined, returns the raw data as-is.
+ */
+function applyOutputMapping(
+  data: unknown,
+  mappings: Record<string, string> | null
+): unknown {
+  if (!mappings || Object.keys(mappings).length === 0) return data;
+  const result: Record<string, unknown> = {};
+  for (const [key, path] of Object.entries(mappings)) {
+    result[key] = resolvePath(data, path);
+  }
+  return result;
+}
+
+function resolvePath(obj: unknown, path: string): unknown {
+  return path.split(".").reduce((cur: unknown, key) => {
+    if (cur && typeof cur === "object") return (cur as Record<string, unknown>)[key];
+    return undefined;
+  }, obj);
+}

+ 56 - 1
server/db.ts

@@ -460,10 +460,21 @@ export async function updateConversationStatus(id: number, status: "active" | "e
   if (!db) throw new Error("Database not available");
   const updateData: Record<string, unknown> = { status };
   if (agentId !== undefined) updateData.assignedAgentId = agentId;
+  // Record when a conversation is escalated for response-time analytics
+  if (status === "escalated") updateData.escalatedAt = new Date();
   await db.update(conversations).set(updateData).where(eq(conversations.id, id));
   return getConversationById(id);
 }
 
+export async function rateConversation(sessionId: string, rating: number, comment?: string) {
+  const db = await getDb();
+  if (!db) throw new Error("Database not available");
+  await db.update(conversations)
+    .set({ csatRating: rating, csatComment: comment ?? null })
+    .where(eq(conversations.sessionId, sessionId));
+  return { success: true };
+}
+
 export async function getConversationStats() {
   const db = await getDb();
   if (!db) return { total: 0, active: 0, escalated: 0, resolved: 0, closed: 0 };
@@ -486,7 +497,12 @@ export async function addMessage(data: InsertMessage) {
   const db = await getDb();
   if (!db) throw new Error("Database not available");
   const [inserted] = await db.insert(messages).values(data).returning({ id: messages.id });
-  await db.update(conversations).set({ updatedAt: new Date() }).where(eq(conversations.id, data.conversationId));
+  const convUpdate: Record<string, unknown> = { updatedAt: new Date() };
+  // Record first agent reply timestamp for response-time analytics (COALESCE — set only once)
+  if (data.sender === "agent") {
+    convUpdate.firstAgentReplyAt = sql`COALESCE("first_agent_reply_at", NOW())`;
+  }
+  await db.update(conversations).set(convUpdate).where(eq(conversations.id, data.conversationId));
   return { id: inserted.id, ...data };
 }
 
@@ -631,6 +647,45 @@ export async function getAnalyticsSummary(startDate?: Date, endDate?: Date) {
   };
 }
 
+export async function getIntentStats(startDate?: Date, endDate?: Date) {
+  const db = await getDb();
+  if (!db) return [];
+  const conditions: any[] = [eq(analyticsEvents.eventType, "intent_detected" as any)];
+  if (startDate) conditions.push(gte(analyticsEvents.createdAt, startDate));
+  if (endDate) conditions.push(lte(analyticsEvents.createdAt, endDate));
+  const rows = await db.select({
+    category: analyticsEvents.category,
+    count: sql<number>`COUNT(*)`,
+  })
+    .from(analyticsEvents)
+    .where(and(...conditions))
+    .groupBy(analyticsEvents.category)
+    .orderBy(sql`COUNT(*) DESC`)
+    .limit(20);
+  return rows.map(r => ({ category: r.category ?? "unclassified", count: Number(r.count) }));
+}
+
+export async function getResponseTimeStats(startDate?: Date, endDate?: Date) {
+  const db = await getDb();
+  if (!db) return { avgSeconds: null, p50Seconds: null, sampleSize: 0 };
+  const conditions: any[] = [
+    isNotNull(conversations.escalatedAt),
+    isNotNull(conversations.firstAgentReplyAt),
+  ];
+  if (startDate) conditions.push(gte(conversations.createdAt, startDate));
+  if (endDate) conditions.push(lte(conversations.createdAt, endDate));
+  const rows = await db.select({
+    seconds: sql<number>`EXTRACT(EPOCH FROM ("first_agent_reply_at" - "escalated_at"))`,
+  })
+    .from(conversations)
+    .where(and(...conditions));
+  if (!rows.length) return { avgSeconds: null, p50Seconds: null, sampleSize: 0 };
+  const times = rows.map(r => Number(r.seconds)).filter(n => n >= 0).sort((a, b) => a - b);
+  const avg = times.reduce((s, v) => s + v, 0) / times.length;
+  const p50 = times[Math.floor(times.length * 0.5)];
+  return { avgSeconds: Math.round(avg), p50Seconds: Math.round(p50), sampleSize: times.length };
+}
+
 /* ─── Data Sources helpers ─── */
 export async function createDataSource(source: Omit<InsertDataSource, "id" | "createdAt" | "updatedAt">) {
   const db = await getDb();

+ 23 - 0
server/erpClient.ts

@@ -182,6 +182,17 @@ export interface StockRecord {
   SalesOrderID: string | null;
 }
 
+export interface ProductImageRecord {
+  model: string;
+  apppicture_path: string;
+  full_url: string;
+}
+
+export interface ProductImagesResult {
+  base_url: string;
+  images: ProductImageRecord[];
+}
+
 // ─── API calls ────────────────────────────────────────────────────────────────
 
 export interface CatalogParams {
@@ -251,3 +262,15 @@ export async function fetchStock(params: StockParams, userCtx?: UserCtx): Promis
     body: JSON.stringify({ limit: 50, ...params }),
   }, userCtx);
 }
+
+export interface ProductImagesParams {
+  model: string;
+  limit?: number;
+}
+
+export async function fetchProductImages(params: ProductImagesParams, userCtx?: UserCtx): Promise<ProductImagesResult> {
+  return erpFetch<ProductImagesResult>("/catalog/images", {
+    method: "POST",
+    body: JSON.stringify({ limit: 10, ...params }),
+  }, userCtx);
+}

+ 18 - 0
server/erpTools.ts

@@ -12,6 +12,7 @@ import {
   fetchOrderDetail,
   fetchOrdersList,
   fetchStock,
+  fetchProductImages,
   type OrderDetail,
   type OrderListItem,
   type UserCtx,
@@ -150,6 +151,23 @@ export async function lookupContact(params: {
   return `Customer records (${contacts.length}):\n${lines.join("\n")}`;
 }
 
+// ─── Product images ───────────────────────────────────────────────────────────
+
+/**
+ * Fetch product images for a model and return markdown-formatted image links
+ * the LLM can embed directly in its reply.
+ */
+export async function lookupProductImages(model: string, userCtx?: UserCtx): Promise<string> {
+  const result = await fetchProductImages({ model, limit: 6 }, userCtx);
+  if (!result.images.length)
+    return `No product images found for model "${model}".`;
+
+  const lines = result.images.map(
+    (img) => `![${img.model}](${img.full_url})`
+  );
+  return `Product images for **${model}**:\n${lines.join("\n")}`;
+}
+
 // ─── Formatters ───────────────────────────────────────────────────────────────
 
 function formatOrderDetail(o: OrderDetail): string {

+ 122 - 16
server/flowEngine.ts

@@ -9,7 +9,7 @@
  */
 
 import { getDb } from "./db";
-import { workflowNodes } from "../drizzle/schema";
+import { workflowNodes, workflowEdges } from "../drizzle/schema";
 import { eq } from "drizzle-orm";
 
 export interface FlowResult {
@@ -19,7 +19,7 @@ export interface FlowResult {
   flowName?: string;
 }
 
-// Intent patterns for the 5 live Support Flows
+// Intent patterns for built-in Support Flows
 const FLOW_INTENT_PATTERNS: Record<string, RegExp[]> = {
   "check-order-status": [
     /\border\s*status\b/i,
@@ -55,15 +55,39 @@ const FLOW_INTENT_PATTERNS: Record<string, RegExp[]> = {
     /\bshipping.*time\b/i,
     /\bminimum.*order\b/i,
   ],
+  // ── New flows ─────────────────────────────────────────────────────────────
+  "leads-capture": [
+    /\bi('d| would) like to (buy|order|purchase)\b/i,
+    /\bget a quote\b/i,
+    /\bbecome a (dealer|retailer|reseller)\b/i,
+    /\bopen an? account\b/i,
+    /\binterested in (buying|carrying|stocking)\b/i,
+    /\bnew (customer|client)\b/i,
+    /\bhow (do i|can i) (become|sign up|register|apply)\b/i,
+  ],
+  "sales-inquiry": [
+    /\bbulk (order|pricing|discount)\b/i,
+    /\bwholesale\b/i,
+    /\bminimum order quantity\b/i,
+    /\bMOQ\b/i,
+    /\bprice list\b/i,
+    /\bcontact.*sales\b/i,
+    /\bsales rep(resentative)?\b/i,
+    /\btalk to sales\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.",
+  "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. You can also email **returns@homelegance.com** for assistance.",
   "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
+  "leads-capture":
+    "We'd love to have you as a Homelegance dealer! 🎉\n\nPlease share the following so our sales team can follow up within **1 business day**:\n1. **Company name**\n2. **Your name & title**\n3. **Email address**\n4. **Phone number**\n5. **City / State** you operate in\n\nAlternatively, email us directly at **sales@homelegance.com**.",
+  "sales-inquiry":
+    "Our sales team handles bulk orders and wholesale pricing directly.\n\n📞 **Call us:** Contact your assigned sales rep\n📧 **Email:** sales@homelegance.com\n\nOr I can connect you with an agent right now — just say **'Talk to agent'**.",
 };
 
 /**
@@ -80,32 +104,114 @@ export function detectFlowIntent(message: string): string | null {
 }
 
 /**
- * Execute a flow by ID. Returns the response content or null.
+ * Execute a flow by ID using full graph traversal.
  * 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
+export async function executeFlow(flowId: string, userMessage: string): Promise<FlowResult | null> {
+  // Try to load and traverse DB-saved flow graph
   try {
     const db = await getDb();
     if (db) {
-      const nodes = await db.select().from(workflowNodes).where(eq(workflowNodes.workflowId, flowId));
+      const [nodes, edges] = await Promise.all([
+        db.select().from(workflowNodes).where(eq(workflowNodes.workflowId, flowId)),
+        db.select().from(workflowEdges).where(eq(workflowEdges.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 };
-        }
+        const result = traverseGraph(nodes, edges, userMessage);
+        if (result) return { ...result, flowId };
       }
     }
   } catch (err) {
     console.error("[FlowEngine] DB error:", err);
   }
 
-  const staticResponse = FLOW_STATIC_RESPONSES[flowId];
-  if (staticResponse) {
-    return { content: staticResponse, flowId };
+  return fallbackToStatic(flowId);
+}
+
+// ── Graph traversal ───────────────────────────────────────────────────────────
+
+type DbNode = { nodeId: string; type: string; label: string; config: unknown };
+type DbEdge = { sourceNodeId: string; targetNodeId: string; label: string | null; condition: unknown };
+
+function traverseGraph(
+  nodes: DbNode[],
+  edges: DbEdge[],
+  userMessage: string
+): Omit<FlowResult, "flowId"> | null {
+  // Build maps for O(1) lookup
+  const nodeMap = new Map(nodes.map(n => [n.nodeId, n]));
+  const adjMap = new Map<string, DbEdge[]>();
+  for (const e of edges) {
+    if (!adjMap.has(e.sourceNodeId)) adjMap.set(e.sourceNodeId, []);
+    adjMap.get(e.sourceNodeId)!.push(e);
   }
 
+  const intentNode = nodes.find(n => n.type === "intent");
+  if (!intentNode) return null;
+
+  const responses: string[] = [];
+  let shouldEscalate = false;
+  const visited = new Set<string>();
+
+  function traverse(nodeId: string): void {
+    if (visited.has(nodeId)) return;
+    visited.add(nodeId);
+
+    const node = nodeMap.get(nodeId);
+    if (!node) return;
+
+    const cfg = node.config as Record<string, unknown> | null ?? {};
+
+    switch (node.type) {
+      case "response":
+        if (cfg.message) responses.push(String(cfg.message));
+        break;
+
+      case "condition": {
+        // Route based on whether pattern matches the user message
+        const pattern = cfg.pattern ? new RegExp(String(cfg.pattern), "i") : null;
+        const conditionMet = pattern ? pattern.test(userMessage) : true;
+        const outEdges = adjMap.get(nodeId) ?? [];
+        // Edge label "true"/"false" controls routing; if no labels, follow all
+        const labeled = outEdges.filter(e => e.label === "true" || e.label === "false");
+        if (labeled.length > 0) {
+          const target = outEdges.find(e => e.label === (conditionMet ? "true" : "false"));
+          if (target) traverse(target.targetNodeId);
+        } else {
+          outEdges.forEach(e => traverse(e.targetNodeId));
+        }
+        return; // condition handles its own routing
+      }
+
+      case "escalation":
+        shouldEscalate = true;
+        if (cfg.message) responses.push(String(cfg.message));
+        break;
+
+      case "tag":
+        // future: tag conversation; no output
+        break;
+
+      case "delay":
+        // future: async delay; skip for now
+        break;
+    }
+
+    // Follow outgoing edges to next nodes
+    for (const edge of adjMap.get(nodeId) ?? []) {
+      traverse(edge.targetNodeId);
+    }
+  }
+
+  traverse(intentNode.nodeId);
+
+  if (!responses.length && !shouldEscalate) return null;
+  return { content: responses.join("\n\n"), shouldEscalate };
+}
+
+function fallbackToStatic(flowId: string): FlowResult | null {
+  const staticResponse = FLOW_STATIC_RESPONSES[flowId];
+  if (staticResponse) return { content: staticResponse, flowId };
   return null;
 }

+ 102 - 14
server/routers.ts

@@ -22,9 +22,10 @@ import {
   createInvitation, getAllInvitations, getInvitationByToken, updateInvitationStatus,
   expireOldInvitations, getInvitationByEmail,
   createAuditLog, getAuditLogs,
-  trackAnalyticsEvent, getAnalyticsEvents, getAnalyticsSummary,
+  trackAnalyticsEvent, getAnalyticsEvents, getAnalyticsSummary, getIntentStats, getResponseTimeStats,
   createDataSource, getDataSources, getDataSourceById, updateDataSource, deleteDataSource,
   createApiConnection, getApiConnections, getApiConnectionById, updateApiConnection, deleteApiConnection, incrementApiConnectionExecution,
+  rateConversation,
   searchKnowledge, incrementKnowledgeUseCount, logKnowledgeSuggestion,
   getKnowledgeEntries, getKnowledgeEntryById, createKnowledgeEntry,
   updateKnowledgeEntry, deleteKnowledgeEntry, bulkCreateKnowledgeEntries,
@@ -32,6 +33,7 @@ import {
   getKnowledgeProducts, bulkCreateKnowledgeProducts, deleteAllKnowledgeProducts,
 } from "./db";
 import { detectFlowIntent, executeFlow } from "./flowEngine";
+import { runApiConnection } from "./apiConnectionRunner";
 import { messages } from "../drizzle/schema";
 import { eq, desc } from "drizzle-orm";
 import { sdk } from "./_core/sdk";
@@ -44,6 +46,7 @@ import {
   lookupCatalog,
   lookupStock,
   lookupContact,
+  lookupProductImages,
 } from "./erpTools";
 
 /* ─── Homelegance chatbot system prompt ─── */
@@ -413,7 +416,15 @@ export const appRouter = router({
               }
             }
 
-            // 7. Customer / dealer lookup (admin/agent only — dealers see their own record via CID)
+            // 7. Product images — "show image/photo/picture of [model]" or "image for 1436W-6"
+            if (!erpContext && /\b(image|images|photo|photos|picture|pictures|what does .* look like|show me)\b/.test(msgLower)) {
+              const modelMatch = msg.match(/\b([A-Z]{1,5}[-]?\d{3,}[-\w]*)\b/);
+              if (modelMatch) {
+                erpContext = await lookupProductImages(modelMatch[1], userCtx);
+              }
+            }
+
+            // 8. Customer / dealer lookup (admin/agent only — dealers see their own record via CID)
             if (!erpContext && ctx.user?.role !== "user" && /\b(customer|dealer|account|contact|company)\b/.test(msgLower)) {
               const nameMatch = msg.match(/(?:customer|dealer|account|contact|company)[:\s]+([A-Za-z &'.-]{3,40})/i);
               if (nameMatch) {
@@ -472,6 +483,15 @@ export const appRouter = router({
           // Auto-log as suggestion for continuous improvement
           logKnowledgeSuggestion(input.content).catch(() => {});
 
+          // Log intent for hot-topic analytics (fire-and-forget)
+          trackAnalyticsEvent({
+            conversationId: conversation.id,
+            sessionId: input.sessionId,
+            eventType: "intent_detected",
+            category: "unclassified",
+            metadata: { snippet: input.content.slice(0, 120) },
+          }).catch(() => {});
+
           return { reply: botReply, status: conversation.status, source: "llm" as const };
         } catch (error) {
           console.error("[Chat] LLM error:", error);
@@ -485,13 +505,23 @@ export const appRouter = router({
         }
       }),
 
+    rateSatisfaction: publicProcedure
+      .input(z.object({
+        sessionId: z.string(),
+        rating: z.number().int().min(1).max(5),
+        comment: z.string().max(500).optional(),
+      }))
+      .mutation(async ({ input }) => {
+        return rateConversation(input.sessionId, input.rating, input.comment);
+      }),
+
     getMessages: publicProcedure
       .input(z.object({ sessionId: z.string() }))
       .query(async ({ input }) => {
         const conversation = await getConversationBySessionId(input.sessionId);
         if (!conversation) return { messages: [], status: "closed" as const };
         const msgs = await getMessagesByConversation(conversation.id);
-        return { messages: msgs, status: conversation.status };
+        return { messages: msgs, status: conversation.status, csatRating: conversation.csatRating };
       }),
   }),
 
@@ -1195,6 +1225,24 @@ Return ONLY the JSON array, no markdown or explanation.`,
           endDate: input?.endDate ? new Date(input.endDate) : undefined,
         });
       }),
+
+    intentStats: agentProcedure
+      .input(z.object({ startDate: z.string().optional(), endDate: z.string().optional() }).optional())
+      .query(async ({ input }) => {
+        return getIntentStats(
+          input?.startDate ? new Date(input.startDate) : undefined,
+          input?.endDate ? new Date(input.endDate) : undefined,
+        );
+      }),
+
+    responseTime: agentProcedure
+      .input(z.object({ startDate: z.string().optional(), endDate: z.string().optional() }).optional())
+      .query(async ({ input }) => {
+        return getResponseTimeStats(
+          input?.startDate ? new Date(input.startDate) : undefined,
+          input?.endDate ? new Date(input.endDate) : undefined,
+        );
+      }),
   }),
 
   /* ─── Data Sources Router (Lyro-inspired) ─── */
@@ -1309,17 +1357,21 @@ Return ONLY the JSON array, no markdown or explanation.`,
       .mutation(async ({ input }) => {
         const conn = await getApiConnectionById(input.id);
         if (!conn) throw new TRPCError({ code: "NOT_FOUND", message: "API connection not found" });
-        try {
-          // Simulate a test call (in production, this would make the actual HTTP request)
-          await incrementApiConnectionExecution(input.id);
-          return {
-            success: true,
-            message: `Test successful for ${conn.name}`,
-            responseTime: Math.floor(Math.random() * 500) + 100, // Simulated
-          };
-        } catch (err: any) {
-          return { success: false, message: err.message, responseTime: 0 };
-        }
+        const start = Date.now();
+        const result = await runApiConnection(input.id);
+        return {
+          success: result.success,
+          message: result.success ? `Test successful for ${conn.name}` : (result.error ?? "Unknown error"),
+          responseTime: Date.now() - start,
+        };
+      }),
+
+    run: adminProcedure
+      .input(z.object({ id: z.number() }))
+      .mutation(async ({ input }) => {
+        const result = await runApiConnection(input.id);
+        if (!result.success) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: result.error });
+        return result;
       }),
   }),
 
@@ -1425,6 +1477,42 @@ Return ONLY the JSON array, no markdown or explanation.`,
         return bulkCreateKnowledgeProducts(input.products);
       }),
   }),
+
+  /* ─── Playground Router ─── */
+  playground: router({
+    chat: agentProcedure
+      .input(z.object({
+        sessionId: z.string().optional(),
+        content: z.string().min(1).max(4000),
+        systemPrompt: z.string().max(8000).optional(),
+        model: z.enum([
+          "claude-haiku-4-5-20251001",
+          "claude-sonnet-4-6",
+          "claude-opus-4-7",
+        ]).default("claude-sonnet-4-6"),
+        temperature: z.number().min(0).max(1).optional(),
+        history: z.array(z.object({
+          role: z.enum(["user", "assistant"]),
+          content: z.string(),
+        })).default([]),
+      }))
+      .mutation(async ({ input }) => {
+        const systemContent = input.systemPrompt ?? SYSTEM_PROMPT;
+        const llmMessages = [
+          { role: "system" as const, content: systemContent },
+          ...input.history,
+          { role: "user" as const, content: input.content },
+        ];
+        const result = await invokeLLM({
+          messages: llmMessages,
+          model: input.model,
+          ...(input.temperature != null ? { temperature: input.temperature } : {}),
+        });
+        const reply = result.choices[0]?.message?.content as string
+          ?? "Sorry, I could not generate a response.";
+        return { reply, model: input.model };
+      }),
+  }),
 });
 
 export type AppRouter = typeof appRouter;