diff --git a/frontend/chat-ui/src/components/ModeSelectorDemo.jsx b/frontend/chat-ui/src/components/ModeSelectorDemo.jsx new file mode 100644 index 0000000..cafd93b --- /dev/null +++ b/frontend/chat-ui/src/components/ModeSelectorDemo.jsx @@ -0,0 +1,683 @@ +import React, { useEffect, useId, useMemo, useReducer, useRef, useState } from "react"; +import api, { API_BASE_URL } from "../api"; + +/** + * Chat Mode Selector Component Set (drop-in demo) + * - TailwindCSS for styling + * - No external state libs; uses Context + useReducer + * - Accessible radio-group for mode selection + * - File/photo attachments with chips + * - Mock Connectors modal (Drive, GitHub, SharePoint, Box) + * - Progress toasts + banner for Deep Research + * - Image generation mock gallery + * - Clean, modern aesthetic (cards, rounded-2xl, soft shadows) + * + * Usage: export default (below) + * You can lift into your app and wire the onSubmit handler. + */ + +// ------------------------------ +// Types +// ------------------------------ + +// Note: TypeScript-style definitions left as comments for reference +// type Mode = "chat" | "deep" | "image" | "connectors"; +// type ConnectorId = "google-drive" | "github" | "sharepoint" | "box"; + +// ------------------------------ +// Tiny global store (Context + useReducer) +// ------------------------------ + +// type State = { +// mode: Mode; +// files: File[]; +// connectorsOpen: boolean; +// selectedConnectors: Set; +// toasts: Array<{ id: string; title: string; desc?: string; kind?: "info" | "success" | "error" }>; +// deepProgress: { +// running: boolean; +// stage: "idle" | "planning" | "searching" | "synthesizing" | "finalizing"; +// startedAt?: number; +// log: string[]; +// }; +// results: { +// text?: string; +// images?: string[]; // data URLs for mock image generation +// citations?: Array<{ title: string; url: string }>; +// }; +// }; + +// type Action = +// | { type: "SET_MODE"; mode: Mode } +// | { type: "ADD_FILES"; files: File[] } +// | { type: "REMOVE_FILE"; index: number } +// | { type: "OPEN_CONNECTORS"; open: boolean } +// | { type: "TOGGLE_CONNECTOR"; id: ConnectorId } +// | { type: "ADD_TOAST"; toast: State["toasts"][number] } +// | { type: "REMOVE_TOAST"; id: string } +// | { type: "DEEP_START" } +// | { type: "DEEP_SET_STAGE"; stage: State["deepProgress"]["stage"]; log?: string } +// | { type: "DEEP_STOP" } +// | { type: "SET_RESULTS"; results: Partial } +// | { type: "RESET_RESULTS" }; + +const initialState = { + mode: "chat", + files: [], + connectorsOpen: false, + selectedConnectors: new Set(), + toasts: [], + deepProgress: { running: false, stage: "idle", log: [] }, + results: {}, +}; + +const StoreContext = React.createContext(null); + +function reducer(state, action) { + switch (action.type) { + case "SET_MODE": + return { ...state, mode: action.mode }; + case "ADD_FILES": { + const next = [...state.files]; + action.files.forEach((f) => { + if (!next.some((x) => x.name === f.name && x.size === f.size && x.lastModified === f.lastModified)) { + next.push(f); + } + }); + return { ...state, files: next }; + } + case "REMOVE_FILE": { + const next = [...state.files]; + next.splice(action.index, 1); + return { ...state, files: next }; + } + case "OPEN_CONNECTORS": + return { ...state, connectorsOpen: action.open }; + case "TOGGLE_CONNECTOR": { + const s = new Set(state.selectedConnectors); + if (s.has(action.id)) s.delete(action.id); else s.add(action.id); + return { ...state, selectedConnectors: s }; + } + case "ADD_TOAST": + return { ...state, toasts: [...state.toasts, action.toast] }; + case "REMOVE_TOAST": + return { ...state, toasts: state.toasts.filter((t) => t.id !== action.id) }; + case "DEEP_START": + return { ...state, deepProgress: { running: true, stage: "planning", startedAt: Date.now(), log: [] } }; + case "DEEP_SET_STAGE": { + const log = [...state.deepProgress.log]; + if (action.log) log.push(action.log); + return { ...state, deepProgress: { ...state.deepProgress, stage: action.stage, log } }; + } + case "DEEP_STOP": + return { ...state, deepProgress: { running: false, stage: "idle", log: [] } }; + case "SET_RESULTS": + return { ...state, results: { ...state.results, ...action.results } }; + case "RESET_RESULTS": + return { ...state, results: {} }; + default: + return state; + } +} + +function StoreProvider({ children }) { + const [state, dispatch] = useReducer(reducer, initialState); + return {children}; +} + +function useStore() { + const ctx = React.useContext(StoreContext); + if (!ctx) throw new Error("useStore must be used within StoreProvider"); + return ctx; +} + +// ------------------------------ +// Utility helpers +// ------------------------------ + +function classNames(...xs) { + return xs.filter(Boolean).join(" "); +} + +function uid(prefix = "id") { + return `${prefix}-${Math.random().toString(36).slice(2, 9)}`; +} + +const API_KEY = import.meta.env.VITE_API_KEY || ""; + +// API helpers +async function deepResearch(query, onStage) { + onStage("planning", "Submitting query to agent…"); + const res = await api.post("/deep-research", { query }); + onStage("finalizing", "Deep research complete"); + return res; +} + +async function imageGen(prompt, files) { + const form = new FormData(); + form.append("prompt", prompt); + files.forEach((f) => form.append("files", f)); + const headers = {}; + if (API_KEY) headers["x-api-key"] = API_KEY; + const url = `${API_BASE_URL.replace(/\/$/, "")}/image-gen`; + const res = await fetch(url, { method: "POST", headers, body: form }); + const data = await res.json(); + if (!res.ok) { + throw new Error(data?.message || "Image generation failed"); + } + return data.images || []; +} + +// ------------------------------ +// UI Components +// ------------------------------ + +function Card({ children, className }) { + return ( +
+ {children} +
+ ); +} + +function SectionTitle({ title, subtitle }) { + return ( +
+

{title}

+ {subtitle &&

{subtitle}

} +
+ ); +} + +function Toasts() { + const { state, dispatch } = useStore(); + useEffect(() => { + if (!state.toasts.length) return; + const timers = state.toasts.map((t) => setTimeout(() => dispatch({ type: "REMOVE_TOAST", id: t.id }), 4000)); + return () => timers.forEach(clearTimeout); + }, [state.toasts, dispatch]); + return ( +
+ {state.toasts.map((t) => ( +
+
{t.title}
+ {t.desc &&
{t.desc}
} +
+ ))} +
+ ); +} + +function ModeSelector({ mode, onChange }) { + const groupId = useId(); + const options = [ + { id: "chat", label: "Chat" }, + { id: "deep", label: "Deep research", desc: "Web + multi-step agent" }, + { id: "image", label: "Create image" }, + { id: "connectors", label: "Connectors" }, + ]; + + function handleKeyDown(e) { + const idx = options.findIndex((o) => o.id === mode); + const next = (delta) => options[(idx + delta + options.length) % options.length].id; + if (e.key === "ArrowRight" || e.key === "ArrowDown") { + onChange(next(+1)); + e.preventDefault(); + } + if (e.key === "ArrowLeft" || e.key === "ArrowUp") { + onChange(next(-1)); + e.preventDefault(); + } + } + + return ( +
+ + Response mode + + {options.map((opt) => ( + + ))} +
+ ); +} + +const PLACEHOLDER = { + chat: "Ask anything…", + deep: "Ask a complex question for deep research (will consult the web)…", + image: "Describe the image you want to create…", + connectors: "Ask using selected sources (e.g., Drive, GitHub)…", +}; + +function FileChips() { + const { state, dispatch } = useStore(); + if (!state.files.length) return null; + return ( +
+ {state.files.map((f, i) => ( +
+ + {f.name} + + +
+ ))} +
+ ); +} + +function ActionTray() { + const { state, dispatch } = useStore(); + const inputRef = useRef(null); + const accept = state.mode === "image" ? "image/*" : ".pdf,.csv,.xlsx,image/*,.txt"; + + function onFilesSelected(fs) { + if (!fs.length) return; + const maxMB = 25; + const bad = fs.find((f) => f.size > maxMB * 1024 * 1024); + if (bad) { + dispatch({ type: "ADD_TOAST", toast: { id: uid("toast"), title: "File too large", desc: `${bad.name} exceeds ${maxMB}MB`, kind: "error" } }); + return; + } + dispatch({ type: "ADD_FILES", files: fs }); + dispatch({ type: "ADD_TOAST", toast: { id: uid("toast"), title: `${fs.length} file(s) added`, kind: "success" } }); + } + + return ( +
+ + + + +
+ +
+
+ ); +} + +function MoreMenu() { + const [open, setOpen] = useState(false); + const menuRef = useRef(null); + useEffect(() => { + function onDoc(e) { + if (!menuRef.current) return; + if (!menuRef.current.contains(e.target)) setOpen(false); + } + document.addEventListener("click", onDoc); + return () => document.removeEventListener("click", onDoc); + }, []); + return ( +
+ + {open && ( +
+ {[ + { id: "voice", label: "Start voice / record" }, + { id: "templates", label: "Prompt templates" }, + { id: "tools", label: "Tools gallery" }, + ].map((it) => ( + + ))} +
+ )} +
+ ); +} + +function ConnectorsModal() { + const { state, dispatch } = useStore(); + const items = [ + { id: "google-drive", label: "Google Drive", desc: "Pick files/folders to search" }, + { id: "github", label: "GitHub", desc: "Repos, issues, PRs" }, + { id: "sharepoint", label: "SharePoint", desc: "Sites & document libraries" }, + { id: "box", label: "Box", desc: "Cloud files & folders" }, + ]; + if (!state.connectorsOpen) return null; + return ( +
+ +
+ + +
+
+ {items.map((it) => { + const checked = state.selectedConnectors.has(it.id); + return ( + + ); + })} +
+
+
+ Selected: {state.selectedConnectors.size || 0} +
+ +
+
+
+ ); +} + +function DeepProgressBanner() { + const { state, dispatch } = useStore(); + if (!state.deepProgress.running) return null; + const stages = [ + { id: "planning", label: "Planning" }, + { id: "searching", label: "Searching" }, + { id: "synthesizing", label: "Synthesizing" }, + { id: "finalizing", label: "Finalizing" }, + ]; + return ( + +
+
+
+ +
+
+ {stages.map((s) => ( +
+
+ {s.label} +
+ ))} +
+ {state.deepProgress.log.length > 0 && ( +
+ {state.deepProgress.log.slice(-3).map((l, i) => ( +
• {l}
+ ))} +
+ )} + + ); +} + +function ResultsPanel() { + const { state } = useStore(); + if (!state.results.text && !state.results.images?.length) return null; + return ( + + + {state.results.text && ( +
+          {state.results.text}
+        
+ )} + {state.results.citations && state.results.citations.length > 0 && ( +
+
Citations
+ +
+ )} + {state.results.images && state.results.images.length > 0 && ( +
+ {state.results.images.map((src, i) => ( + {`Generated + ))} +
+ )} +
+ ); +} + +function ChatComposer() { + const { state, dispatch } = useStore(); + const [value, setValue] = useState(""); + const submittingRef = useRef(false); + + async function handleSubmit() { + if (submittingRef.current) return; + submittingRef.current = true; + dispatch({ type: "RESET_RESULTS" }); + + const q = value.trim(); + if (!q) { + dispatch({ type: "ADD_TOAST", toast: { id: uid("toast"), title: "Type something to send", kind: "info" } }); + submittingRef.current = false; + return; + } + + try { + if (state.mode === "chat") { + dispatch({ type: "SET_RESULTS", results: { text: `You said: ${q}\n\n(Connect your backend to replace this mock.)` } }); + } else if (state.mode === "deep") { + dispatch({ type: "DEEP_START" }); + const res = await deepResearch(q, (stage, log) => { + dispatch({ type: "DEEP_SET_STAGE", stage, log }); + }); + dispatch({ type: "SET_RESULTS", results: { text: res.text, citations: res.citations } }); + dispatch({ type: "DEEP_STOP" }); + } else if (state.mode === "image") { + const images = await imageGen(q, state.files); + dispatch({ type: "SET_RESULTS", results: { images } }); + dispatch({ type: "ADD_TOAST", toast: { id: uid("toast"), title: "Images ready", kind: "success" } }); + } else if (state.mode === "connectors") { + if (state.selectedConnectors.size === 0) { + dispatch({ type: "ADD_TOAST", toast: { id: uid("toast"), title: "Select sources first", desc: "Open Connectors to choose data.", kind: "error" } }); + } else { + const res = await api.post("/connectors/query", { + query: q, + connectors: Array.from(state.selectedConnectors), + }); + dispatch({ type: "SET_RESULTS", results: { text: res.text, citations: res.citations } }); + } + } + } finally { + submittingRef.current = false; + } + } + + return ( + +
+
+ + dispatch({ type: "SET_MODE", mode: m })} /> +
+ + + + +