diff --git a/backend/app/models.py b/backend/app/models.py index 6e5379b..249fce3 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -200,11 +200,11 @@ class ApiKey(Base): class WorkspaceInvitation(Base): __tablename__ = "workspace_invitations" - id = Column(String, primary_key=True, default=generate_uuid) + id = Column(GUID, primary_key=True, default=uuid.uuid4) email = Column(String(120), nullable=False, index=True) token_hash = Column(String(255), nullable=False, unique=True, index=True) inviter_id = Column( - String, + GUID, ForeignKey("users.id"), nullable=False, index=True, diff --git a/backend/app/observability.py b/backend/app/observability.py index 662cf75..e2cba9b 100644 --- a/backend/app/observability.py +++ b/backend/app/observability.py @@ -9,7 +9,33 @@ from fastapi import FastAPI from prometheus_client import Gauge -from prometheus_fastapi_instrumentator import Instrumentator +from prometheus_fastapi_instrumentator import Instrumentator, routing +from starlette.routing import Match + +# ── Workaround for FastAPI 0.135+ and prometheus-fastapi-instrumentator 8.0.0 ── +# Newer FastAPI versions include _IncludedRouter objects in app.routes which +# lack a '.path' attribute, causing AttributeErrors during instrumentation. +def _patched_get_route_name(scope, routes, route_name=None): + """Safe version of _get_route_name that handles routes without a .path attribute.""" + for route in routes: + try: + match, child_scope = route.matches(scope) + except Exception: + continue + + if match == Match.FULL: + # If we have a full match and the route has a path, use it and return early. + # This matches Starlette's behavior where the first matching route wins. + if hasattr(route, "path"): + return route.path + elif match == Match.PARTIAL and hasattr(route, "routes"): + # Recursive call for nested routes (e.g. Mounts) + route_name = _patched_get_route_name(child_scope, route.routes, route_name) + if route_name: + return route_name + return route_name + +routing._get_route_name = _patched_get_route_name APP_PROCESS_RSS_BYTES = Gauge( "app_process_resident_memory_bytes", diff --git a/backend/app/services/layout_parser.py b/backend/app/services/layout_parser.py index 6053b32..c496d3a 100644 --- a/backend/app/services/layout_parser.py +++ b/backend/app/services/layout_parser.py @@ -48,6 +48,13 @@ def process_embedded_images(self, page_num: int, page_obj: fitz.Page) -> List[st image_descriptions = [] image_list = page_obj.get_images(full=True) + try: + from google import genai + client = genai.Client() + except Exception as e: + print(f"Gemini client init failed, skipping vision: {e}") + return image_descriptions + for img_index, img in enumerate(image_list): xref = img[0] base_image = self.doc.extract_image(xref) diff --git a/backend/app/tasks.py b/backend/app/tasks.py index 811cad0..ef0c7ff 100644 --- a/backend/app/tasks.py +++ b/backend/app/tasks.py @@ -84,11 +84,11 @@ def process_document( pass # 4. Mark document pipeline processing as completely successful - doc.status = "completed" + doc.status = "ready" doc.processing_progress = 100 db.commit() - return {"document_id": document_id, "status": "completed"} + return {"document_id": document_id, "status": "ready"} except Exception as exc: logger.error("Document %s processing failed (attempt %s): %s", document_id, self.request.retries + 1, exc) diff --git a/backend/requirements.txt b/backend/requirements.txt index aeee1e9..6960b89 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -29,6 +29,9 @@ httpx # Document Processing PyMuPDF +pymupdf4llm +google-generativeai +google-genai pdfplumber python-docx unstructured[pdf] diff --git a/backend/tests/test_celery_ingestion.py b/backend/tests/test_celery_ingestion.py index e5727e7..9bc38d4 100644 --- a/backend/tests/test_celery_ingestion.py +++ b/backend/tests/test_celery_ingestion.py @@ -48,4 +48,4 @@ def test_process_document_ingestion_pipeline(db_session): # Query the database to verify the state update updated_doc = db_session.query(Document).filter_by(id="test-doc-123").first() assert updated_doc is not None - assert updated_doc.status == "completed" \ No newline at end of file + assert updated_doc.status == "ready" \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index b587c4d..4638aa8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -57,7 +57,7 @@ services: environment: - SECRET_KEY=${SECRET_KEY:-dev-secret-key-change-me} - HF_TOKEN=${HF_TOKEN} - - DATABASE_URL=postgresql://${POSTGRES_USER:-pdf_rag_user}:${POSTGRES_PASSWORD:-pdf_rag_pass}@postgres:5432/${POSTGRES_DB:-pdf_rag} + - DATABASE_URL=postgresql+psycopg://${POSTGRES_USER:-pdf_rag_user}:${POSTGRES_PASSWORD:-pdf_rag_pass}@postgres:5432/${POSTGRES_DB:-pdf_rag} - UPLOAD_DIR=/app/data/uploads - CHROMA_PERSIST_DIR=/app/data/chroma_db - GRAPH_PERSIST_DIR=/app/data/graphs @@ -90,7 +90,7 @@ services: environment: - SECRET_KEY=${SECRET_KEY:-dev-secret-key-change-me} - HF_TOKEN=${HF_TOKEN} - - DATABASE_URL=postgresql://${POSTGRES_USER:-pdf_rag_user}:${POSTGRES_PASSWORD:-pdf_rag_pass}@postgres:5432/${POSTGRES_DB:-pdf_rag} + - DATABASE_URL=postgresql+psycopg://${POSTGRES_USER:-pdf_rag_user}:${POSTGRES_PASSWORD:-pdf_rag_pass}@postgres:5432/${POSTGRES_DB:-pdf_rag} - UPLOAD_DIR=/app/data/uploads - CHROMA_PERSIST_DIR=/app/data/chroma_db - GRAPH_PERSIST_DIR=/app/data/graphs @@ -123,15 +123,13 @@ services: container_name: pdf_rag_worker profiles: ["cpu"] logging: *default-logging - command: > - sh -c "cd /app/backend && - celery -A app.celery_app.celery_app worker --loglevel=info" + command: celery -A app.celery_app.celery_app worker --loglevel=info volumes: - app_data:/app/data environment: - SECRET_KEY=${SECRET_KEY:-dev-secret-key-change-me} - HF_TOKEN=${HF_TOKEN} - - DATABASE_URL=postgresql://${POSTGRES_USER:-pdf_rag_user}:${POSTGRES_PASSWORD:-pdf_rag_pass}@postgres:5432/${POSTGRES_DB:-pdf_rag} + - DATABASE_URL=postgresql+psycopg://${POSTGRES_USER:-pdf_rag_user}:${POSTGRES_PASSWORD:-pdf_rag_pass}@postgres:5432/${POSTGRES_DB:-pdf_rag} - UPLOAD_DIR=/app/data/uploads - CHROMA_PERSIST_DIR=/app/data/chroma_db - GRAPH_PERSIST_DIR=/app/data/graphs @@ -151,15 +149,13 @@ services: container_name: pdf_rag_worker profiles: ["gpu"] logging: *default-logging - command: > - sh -c "cd /app/backend && - celery -A app.celery_app.celery_app worker --loglevel=info" + command: celery -A app.celery_app.celery_app worker --loglevel=info volumes: - app_data:/app/data environment: - SECRET_KEY=${SECRET_KEY:-dev-secret-key-change-me} - HF_TOKEN=${HF_TOKEN} - - DATABASE_URL=postgresql://${POSTGRES_USER:-pdf_rag_user}:${POSTGRES_PASSWORD:-pdf_rag_pass}@postgres:5432/${POSTGRES_DB:-pdf_rag} + - DATABASE_URL=postgresql+psycopg://${POSTGRES_USER:-pdf_rag_user}:${POSTGRES_PASSWORD:-pdf_rag_pass}@postgres:5432/${POSTGRES_DB:-pdf_rag} - UPLOAD_DIR=/app/data/uploads - CHROMA_PERSIST_DIR=/app/data/chroma_db - GRAPH_PERSIST_DIR=/app/data/graphs diff --git a/frontend/src/components/chat/ChatPanel.tsx b/frontend/src/components/chat/ChatPanel.tsx index b1e028a..927bcb4 100644 --- a/frontend/src/components/chat/ChatPanel.tsx +++ b/frontend/src/components/chat/ChatPanel.tsx @@ -14,21 +14,25 @@ import { import { buttonVariants } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; import { Textarea } from "@/components/ui/textarea"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; import MessageBubble from "./MessageBubble"; import SourceCard from "./SourceCard"; import { - Send, - Loader2, - Trash2, - MessageSquare, - Download, - Mic, - MicOff, - HelpCircle, - ChevronDown, - } from "lucide-react"; - import { cn } from "@/lib/utils"; + Send, + Loader2, + Trash2, + MessageSquare, + Download, + Mic, + MicOff, + HelpCircle, + ChevronDown, +} from "lucide-react"; +import { cn } from "@/lib/utils"; interface ISpeechRecognitionEvent { resultIndex: number; results: { @@ -95,8 +99,6 @@ export default function ChatPanel({ activeDoc, onCitationClick }: Props) { const [isRecording, setIsRecording] = useState(false); const [speechError, setSpeechError] = useState(null); - // New State for Keyboard Shortcuts Help Modal - const [showHelpModal, setShowHelpModal] = useState(false); const [showScrollButton, setShowScrollButton] = useState(false); const recognitionRef = useRef(null); @@ -146,7 +148,10 @@ export default function ChatPanel({ activeDoc, onCitationClick }: Props) { const scrollToBottom = () => { if (containerRef.current) { - containerRef.current.scrollTo({ top: containerRef.current.scrollHeight, behavior: "smooth" }); + containerRef.current.scrollTo({ + top: containerRef.current.scrollHeight, + behavior: "smooth", + }); } }; @@ -206,7 +211,7 @@ export default function ChatPanel({ activeDoc, onCitationClick }: Props) { cancelled = true; }; }, [activeSessionId, activeDoc, fetchSessionHistory, setMessages]); - + const handleStop = () => { abortControllerRef.current?.abort(); setStreaming(false); @@ -216,7 +221,6 @@ export default function ChatPanel({ activeDoc, onCitationClick }: Props) { const handleSend = async () => { if (!input.trim() || streaming) return; - const question = input.trim(); setInput(""); @@ -239,9 +243,14 @@ export default function ChatPanel({ activeDoc, onCitationClick }: Props) { try { // Try WebSocket first for real-time agentic thought streaming - const token = typeof window !== "undefined" ? localStorage.getItem("token") : null; + const token = + typeof window !== "undefined" ? localStorage.getItem("token") : null; const base = API_BASE || window.location.origin; - const wsScheme = base.startsWith("https") ? "wss" : base.startsWith("http") ? "ws" : "wss"; + const wsScheme = base.startsWith("https") + ? "wss" + : base.startsWith("http") + ? "ws" + : "wss"; const host = base.replace(/^https?:/, ""); const wsUrl = `${wsScheme}:${host}/api/v1/chat/ws${token ? `?token=${encodeURIComponent(token)}` : ""}`; @@ -262,7 +271,13 @@ export default function ChatPanel({ activeDoc, onCitationClick }: Props) { ws.onopen = () => { // Send initial payload - ws.send(JSON.stringify({ question, document_id: activeDoc?.id || null, session_id: activeSessionId })); + ws.send( + JSON.stringify({ + question, + document_id: activeDoc?.id || null, + session_id: activeSessionId, + }), + ); }; // If WS doesn't open within 800ms, treat as failure and fallback @@ -295,24 +310,52 @@ export default function ChatPanel({ activeDoc, onCitationClick }: Props) { setMessages((prev) => [...prev, assistantMsg]); } else { setMessages((prev) => - prev.map((m) => (m.id === assistantId ? { ...m, content: m.content + (event.data as string) } : m)) + prev.map((m) => + m.id === assistantId + ? { ...m, content: m.content + (event.data as string) } + : m, + ), ); } } else if (event.type === "sources") { - setMessages((prev) => prev.map((m) => (m.id === assistantId ? { ...m, sources: event.data as SourceChunk[] } : m))); + setMessages((prev) => + prev.map((m) => + m.id === assistantId + ? { ...m, sources: event.data as SourceChunk[] } + : m, + ), + ); } else if (event.type === "thought") { // Append thoughts as a temporary assistant note (optional UI handling) // For simplicity, add to assistant message content in brackets setMessages((prev) => - prev.map((m) => (m.id === assistantId ? { ...m, content: m.content + `\n[thought] ${event.data}` } : m)) + prev.map((m) => + m.id === assistantId + ? { ...m, content: m.content + `\n[thought] ${event.data}` } + : m, + ), ); } else if (event.type === "error") { setIsTyping(false); - setMessages((prev) => prev.map((m) => (m.id === assistantId ? { ...m, content: `Error: ${event.data}`, isStreaming: false } : m))); + setMessages((prev) => + prev.map((m) => + m.id === assistantId + ? { + ...m, + content: `Error: ${event.data}`, + isStreaming: false, + } + : m, + ), + ); ws.close(); reject(new Error(String(event.data))); } else if (event.type === "done") { - setMessages((prev) => prev.map((m) => (m.id === assistantId ? { ...m, isStreaming: false } : m))); + setMessages((prev) => + prev.map((m) => + m.id === assistantId ? { ...m, isStreaming: false } : m, + ), + ); ws.close(); resolve(); } @@ -341,18 +384,22 @@ export default function ChatPanel({ activeDoc, onCitationClick }: Props) { } catch (err) { if ( err instanceof Error && - (err.name === "AbortError" || err.message === "The user aborted a request.") + (err.name === "AbortError" || + err.message === "The user aborted a request.") ) { return; } - // Fallback to existing SSE stream if WebSocket fails try { - const stream = api.streamPost("/api/v1/chat/ask/stream", { - question, - document_id: activeDoc?.id || null, - session_id: activeSessionId, - }, abortController.signal); + const stream = api.streamPost( + "/api/v1/chat/ask/stream", + { + question, + document_id: activeDoc?.id || null, + session_id: activeSessionId, + }, + abortController.signal, + ); for await (const event of stream) { if (event.type === "token") { @@ -371,28 +418,53 @@ export default function ChatPanel({ activeDoc, onCitationClick }: Props) { setMessages((prev) => [...prev, assistantMsg]); } else { setMessages((prev) => - prev.map((m) => (m.id === assistantId ? { ...m, content: m.content + (event.data as string) } : m)) + prev.map((m) => + m.id === assistantId + ? { ...m, content: m.content + (event.data as string) } + : m, + ), ); } } else if (event.type === "sources") { - setMessages((prev) => prev.map((m) => (m.id === assistantId ? { ...m, sources: event.data as SourceChunk[] } : m))); + setMessages((prev) => + prev.map((m) => + m.id === assistantId + ? { ...m, sources: event.data as SourceChunk[] } + : m, + ), + ); } else if (event.type === "error") { setIsTyping(false); - setMessages((prev) => prev.map((m) => (m.id === assistantId ? { ...m, content: `Error: ${event.data}`, isStreaming: false } : m))); + setMessages((prev) => + prev.map((m) => + m.id === assistantId + ? { + ...m, + content: `Error: ${event.data}`, + isStreaming: false, + } + : m, + ), + ); } else if (event.type === "done") { - setMessages((prev) => prev.map((m) => (m.id === assistantId ? { ...m, isStreaming: false } : m))); + setMessages((prev) => + prev.map((m) => + m.id === assistantId ? { ...m, isStreaming: false } : m, + ), + ); } } } catch (err2) { setIsTyping(false); if ( err2 instanceof Error && - (err2.name === "AbortError" || err2.message === "The user aborted a request.") + (err2.name === "AbortError" || + err2.message === "The user aborted a request.") ) { setMessages((prev) => prev.map((m) => - m.id === assistantId ? { ...m, isStreaming: false } : m - ) + m.id === assistantId ? { ...m, isStreaming: false } : m, + ), ); return; } @@ -402,12 +474,13 @@ export default function ChatPanel({ activeDoc, onCitationClick }: Props) { ? { ...m, content: t("chat.fallbackError", { - message: err2 instanceof Error ? err2.message : "Unknown error", + message: + err2 instanceof Error ? err2.message : "Unknown error", }), isStreaming: false, } - : m - ) + : m, + ), ); } } finally { @@ -610,8 +683,6 @@ export default function ChatPanel({ activeDoc, onCitationClick }: Props) { } else if (document.activeElement === textareaRef.current) { e.preventDefault(); setInput(""); - } else if (showHelpModal) { - setShowHelpModal(false); } else if (showExportMenu) { setShowExportMenu(false); } @@ -623,12 +694,6 @@ export default function ChatPanel({ activeDoc, onCitationClick }: Props) { textareaRef.current?.focus(); } - // Shortcut 4: Ctrl/Cmd + / → Toggle shortcuts help modal - if (isCmdOrCtrl && e.key === "/") { - e.preventDefault(); - setShowHelpModal((prev) => !prev); - } - // Shortcut 5: Ctrl/Cmd + Shift + C → Clear chat history if (isCmdOrCtrl && e.shiftKey && (e.key === "c" || e.key === "C")) { e.preventDefault(); @@ -657,15 +722,15 @@ export default function ChatPanel({ activeDoc, onCitationClick }: Props) { window.removeEventListener("keydown", handleGlobalKeyDown); }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [input, streaming, showHelpModal, showExportMenu, messages]); // Dependencies updated to capture fresh state data + }, [input, streaming, showExportMenu, messages]); // Dependencies updated to capture fresh state data return (
{/* ── Chat Messages ──────────────────────────── */} -
{historyLoading ? ( @@ -747,7 +812,9 @@ export default function ChatPanel({ activeDoc, onCitationClick }: Props) { aria-label="Scroll to bottom" className={cn( "absolute right-4 bottom-20 z-50 rounded-full p-2 bg-primary text-primary-foreground shadow-lg transition-all duration-200", - showScrollButton ? "opacity-100 translate-y-0" : "opacity-0 translate-y-2 pointer-events-none" + showScrollButton + ? "opacity-100 translate-y-0" + : "opacity-0 translate-y-2 pointer-events-none", )} > @@ -861,23 +928,6 @@ export default function ChatPanel({ activeDoc, onCitationClick }: Props) { })} - - {/* NEW Keyboard Shortcuts Info Button */} - - setShowHelpModal(true)} - className={cn( - buttonVariants({ variant: "ghost", size: "icon" }), - "absolute right-2 bottom-1.5 h-7 w-7 rounded-md text-muted-foreground hover:text-primary hover:bg-accent transition-all duration-200", - )} - aria-label="View Keyboard Shortcuts" - > - - - Keyboard shortcuts -
@@ -991,144 +1041,6 @@ export default function ChatPanel({ activeDoc, onCitationClick }: Props) { Ctrl+Enter to send from anywhere. Press Escape to cancel streaming.

- - {/* ── NEW KEYBOARD SHORTCUTS HELP MODAL OVERLAY ───────────────── */} - {showHelpModal && ( -
setShowHelpModal(false)} - > -
e.stopPropagation()} // Stop overlay closing when clicking inside - > - - -

- ⌨️ Keyboard Shortcuts -

-

- Enhance your typing productivity -

-
- -
    -
  • - - Send Message - -
    - - Ctrl - - + - - Enter - -
    -
  • -
  • - - Cancel Streaming / Clear Input - -
    - - Esc - -
    -
  • -
  • - - Focus Chat Input - -
    - - Ctrl - - + - - K - -
    -
  • -
  • - - Toggle Shortcuts Help - -
    - - Ctrl - - + - - / - -
    -
  • -
  • - - Clear Chat History - -
    - - Ctrl - - + - - Shift - - + - - C - -
    -
  • -
  • - - Toggle Export Menu - -
    - - Ctrl - - + - - Shift - - + - - E - -
    -
  • -
  • - - Toggle Mic Recording - -
    - - Ctrl - - + - - Shift - - + - - M - -
    -
  • -
-
-
- )}
); } diff --git a/frontend/src/components/layout/Header.tsx b/frontend/src/components/layout/Header.tsx index 0c383b7..e591e73 100644 --- a/frontend/src/components/layout/Header.tsx +++ b/frontend/src/components/layout/Header.tsx @@ -36,9 +36,15 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { useWorkspaceStore, WORKSPACES, type WorkspaceId } from "@/store/workspace-store"; +import { + useWorkspaceStore, + WORKSPACES, + type WorkspaceId, +} from "@/store/workspace-store"; import { api } from "@/lib/api"; +import { KeyboardShortcutsModal } from "./KeyboardShortcutsModal"; + interface HeaderProps { sidebarOpen: boolean; onToggleSidebar: () => void; @@ -62,7 +68,11 @@ export default function Header({ const { user, logout } = useAuth(); const router = useRouter(); const { theme, setTheme } = useTheme(); - const mounted = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); // ← replaces useState + useEffect + const mounted = useSyncExternalStore( + subscribe, + getSnapshot, + getServerSnapshot, + ); // ← replaces useState + useEffect const [settingsOpen, setSettingsOpen] = useState(false); const [temperature, setTemperature] = useState(() => { if (typeof window !== "undefined") { @@ -80,7 +90,8 @@ export default function Header({ const workspace = useWorkspaceStore((s) => s.workspace); const setWorkspace = useWorkspaceStore((s) => s.setWorkspace); - const currentWorkspaceLabel = WORKSPACES.find((w) => w.id === workspace)?.label ?? workspace; + const currentWorkspaceLabel = + WORKSPACES.find((w) => w.id === workspace)?.label ?? workspace; const isDark = theme === "dark"; const toggleTheme = () => setTheme(isDark ? "light" : "dark"); @@ -92,7 +103,9 @@ export default function Header({ const fetchDocumentsForWorkspace = async (id: string) => { setWorkspaceLoading(true); try { - const res = await api.get(`/api/v1/documents?workspace=${encodeURIComponent(id)}`).catch(() => null); + const res = await api + .get(`/api/v1/documents?workspace=${encodeURIComponent(id)}`) + .catch(() => null); console.log("workspace change, fetched documents:", res); } catch (err) { console.warn("Failed to fetch documents for workspace", id, err); @@ -127,7 +140,9 @@ export default function Header({ className="h-8 w-8 hidden md:inline-flex" onClick={onToggleSidebar} title={sidebarOpen ? "Close sidebar" : "Open sidebar"} - aria-label={sidebarOpen ? "Close document sidebar" : "Open document sidebar"} + aria-label={ + sidebarOpen ? "Close document sidebar" : "Open document sidebar" + } aria-pressed={sidebarOpen} > {sidebarOpen ? ( @@ -146,7 +161,9 @@ export default function Header({
- Document AI Analyst + + Document AI Analyst + @@ -185,12 +202,20 @@ export default function Header({ className="h-8 w-8" onClick={toggleTheme} title={isDark ? "Light mode" : "Dark mode"} - aria-label={isDark ? "Switch to light mode" : "Switch to dark mode"} + aria-label={ + isDark ? "Switch to light mode" : "Switch to dark mode" + } > - {isDark ? : } + {isDark ? ( + + ) : ( + + )} )} + + {/* Workspace switcher */} - {currentWorkspaceLabel} + + {currentWorkspaceLabel} + )} @@ -242,10 +269,15 @@ export default function Header({

{user?.username}

-

{user?.email}

+

+ {user?.email} +

- + Sign out @@ -299,24 +331,19 @@ export default function Header({ -
{sheetOpen ? mobileSheetContent : null}
+
+ {sheetOpen ? mobileSheetContent : null} +
- + - - LLM Settings - + LLM Settings
- + - setTemperature(Number(e.target.value)) - } + onChange={(e) => setTemperature(Number(e.target.value))} className="w-full" />
diff --git a/frontend/src/components/layout/KeyboardShortcutsModal.tsx b/frontend/src/components/layout/KeyboardShortcutsModal.tsx new file mode 100644 index 0000000..b9c7ff5 --- /dev/null +++ b/frontend/src/components/layout/KeyboardShortcutsModal.tsx @@ -0,0 +1,90 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Keyboard } from "lucide-react"; + +const SHORTCUTS = [ + { keys: ["?"], description: "Open keyboard shortcuts" }, + { keys: ["⌘/Ctrl", "Enter"], description: "Send message" }, + { keys: ["Esc"], description: "Cancel streaming / Clear input" }, + { keys: ["⌘/Ctrl", "K"], description: "Focus chat input" }, + { keys: ["⌘/Ctrl", "Shift", "C"], description: "Clear chat history" }, + { keys: ["⌘/Ctrl", "Shift", "E"], description: "Toggle export menu" }, + { keys: ["⌘/Ctrl", "Shift", "M"], description: "Toggle mic recording" }, +]; + +export function KeyboardShortcutsModal() { + const [open, setOpen] = useState(false); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + // Don't trigger if user is typing in an input or textarea + if ( + e.target instanceof HTMLInputElement || + e.target instanceof HTMLTextAreaElement || + e.target instanceof HTMLSelectElement || + (e.target as HTMLElement).isContentEditable + ) { + return; + } + + if (e.key === "?") { + e.preventDefault(); + setOpen((prev) => !prev); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, []); + + return ( + + + + + } + /> + + + Keyboard Shortcuts + +
+ {SHORTCUTS.map((shortcut, idx) => ( +
+ + {shortcut.description} + +
+ {shortcut.keys.map((key, i) => ( + + {key} + + ))} +
+
+ ))} +
+
+
+ ); +}