diff --git a/backend/routes/sessions.py b/backend/routes/sessions.py index 002551e..a6a129b 100644 --- a/backend/routes/sessions.py +++ b/backend/routes/sessions.py @@ -1,14 +1,20 @@ -"""Sessions routes — /api/sessions — full CRUD""" +"""Sessions routes — /api/sessions — full CRUD + reorder + clear all""" import uuid +from typing import List from fastapi import APIRouter, HTTPException +from pydantic import BaseModel from models.schemas import SessionCreate, SessionUpdate, BulkSessionRenameRequest from services import db_service -# from backend.models.schemas import BulkSessionRenameRequest # Adjust if other items are imported from here router = APIRouter() +# ─── Request models for reorder ───────────────────────────── +class ReorderSessionsRequest(BaseModel): + session_ids: List[str] + + @router.get("/") async def list_sessions(): return db_service.get_all_sessions() @@ -20,6 +26,7 @@ async def create_session(body: SessionCreate): session = db_service.create_session(sid, title=body.title, model=body.model) return session + @router.patch("/bulk-rename") async def bulk_rename_sessions(body: BulkSessionRenameRequest): try: @@ -41,10 +48,8 @@ async def bulk_rename_sessions(body: BulkSessionRenameRequest): title=item.new_title, model=current_session.get("model") ) - # 3. Correct Success Count: Only increment if the database update actually fired updated_count += 1 - # If some requested sessions weren't found, alert the client transparently if missing_sessions: return { "status": "partial_success", @@ -62,8 +67,17 @@ async def bulk_rename_sessions(body: BulkSessionRenameRequest): status_code=500, detail=f"Bulk rename failed: {str(e)}" ) - - + + +@router.patch("/reorder") +async def reorder_sessions(req: ReorderSessionsRequest): + try: + db_service.update_sessions_order(req.session_ids) + return {"success": True, "message": "Session order updated"} + except Exception as e: + raise HTTPException(status_code=500, detail=f"Reorder failed: {str(e)}") + + @router.get("/{session_id}") async def get_session(session_id: str): s = db_service.get_session(session_id) @@ -83,13 +97,20 @@ async def delete_session(session_id: str): db_service.delete_session(session_id) try: from services import rag_service - rag_service.delete_session_index(session_id) except Exception: pass return {"status": "deleted", "session_id": session_id} +# ─── Clear all sessions (for testing / admin) ────────────────────────────── +@router.delete("/") +async def clear_all_sessions(): + """Delete ALL sessions and their associated messages/documents.""" + db_service.clear_all_sessions() + return {"message": "All sessions cleared"} + + @router.get("/{session_id}/messages") async def get_messages(session_id: str): messages = db_service.get_messages_full(session_id) @@ -112,15 +133,14 @@ async def get_documents(session_id: str): async def rag_stats(session_id: str): try: from services import rag_service - count = rag_service.get_indexed_count(session_id) except Exception: count = 0 return {"session_id": session_id, "indexed_chunks": count} + return {"session_id": session_id, "indexed_chunks": count} @router.delete("/") async def clear_all_sessions(): db_service.clear_all_sessions() return {"message": "All sessions cleared"} - diff --git a/backend/services/db_service.py b/backend/services/db_service.py index b88ac00..216cec4 100644 --- a/backend/services/db_service.py +++ b/backend/services/db_service.py @@ -56,6 +56,16 @@ def get_db(): if conn: conn.close() + +def ensure_order_index_column(): + """Add order_index column to sessions table if not exists (for drag‑and‑drop reordering).""" + with get_db() as conn: + try: + conn.execute("ALTER TABLE sessions ADD COLUMN order_index INTEGER DEFAULT 0") + except sqlite3.OperationalError: + pass # column already exists + + def init_db(): """Create all tables on startup.""" with get_db() as conn: @@ -122,8 +132,8 @@ def init_db(): prompt TEXT NOT NULL, created_at TEXT DEFAULT (datetime('now')) ); - """) + ensure_order_index_column() try: conn.execute("ALTER TABLE documents ADD COLUMN status TEXT DEFAULT 'completed'") except sqlite3.OperationalError: @@ -162,15 +172,18 @@ def delete_session(session_id: str): def clear_all_sessions(): + """Delete ALL sessions and their associated messages/documents (cascade).""" + with get_db() as conn: with get_db() as conn: conn.execute("DELETE FROM messages") conn.execute("DELETE FROM sessions") def get_all_sessions() -> list[dict]: + """Fetch sessions sorted by manual order (order_index) then by last updated.""" with get_db() as conn: rows = conn.execute( - "SELECT * FROM sessions ORDER BY updated_at DESC" + "SELECT * FROM sessions ORDER BY order_index ASC, updated_at DESC" ).fetchall() return [dict(r) for r in rows] @@ -349,4 +362,15 @@ def update_prompt_template(template_id: int, prompt_title: str = None, prompt: s def delete_prompt_template(template_id: int): """Delete a prompt template by ID.""" with get_db() as conn: - conn.execute("DELETE FROM prompt_templates WHERE id = ?", (template_id,)) \ No newline at end of file + conn.execute("DELETE FROM prompt_templates WHERE id = ?", (template_id,)) + + +# ─── Drag‑and‑drop reordering ────────────────────────────────────────────── +def update_sessions_order(session_ids: list): + """Update order_index for each session based on its position in the list (0,1,2...).""" + with get_db() as conn: + for idx, session_id in enumerate(session_ids): + conn.execute( + "UPDATE sessions SET order_index = ? WHERE id = ?", + (idx, session_id) + ) \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0b1467b..11a735b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,9 @@ "name": "localmind-frontend", "version": "2.0.0", "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "react": "^18.3.0", "react-dom": "^18.3.0", "uuid": "^10.0.0" @@ -65,7 +68,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -317,6 +319,59 @@ "node": ">=6.9.0" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", @@ -1350,7 +1405,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -1758,7 +1812,6 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", - "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -2053,7 +2106,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", @@ -2223,7 +2275,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -2546,7 +2597,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -2574,6 +2624,12 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -2632,7 +2688,6 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", diff --git a/frontend/package.json b/frontend/package.json index 72dab17..0329517 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,6 +10,9 @@ "screenshot": "node scripts/capture-screenshots.js" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "react": "^18.3.0", "react-dom": "^18.3.0", "uuid": "^10.0.0" diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index b6e8233..c97e41d 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -19,18 +19,21 @@ export default function App() { const [loading, setLoading] = useState(false); const [streaming, setStreaming] = useState(false); const [panel, setPanel] = useState(null); // "upload"|"plugins"|"settings"|null - const [view, setView] = useState("chat"); // "chat"|"prompts" + const [view, setView] = useState("chat"); // "chat"|"prompts" const [language, setLanguage] = useState("en"); const [ollamaOk, setOllamaOk] = useState(null); const [settings, setSettings] = useState({}); const [useStream, setUseStream] = useState(true); + // NEW: stop generation state + const [abortController, setAbortController] = useState(null); + const [isStreaming, setIsStreaming] = useState(false); + useEffect(() => { bootstrap(); }, []); - // ── Global keyboard shortcut: Ctrl+Shift+N (or Cmd+Shift+N on Mac) → New Chat ── + // Global keyboard shortcut: Ctrl+Shift+N (or Cmd+Shift+N on Mac) → New Chat useEffect(() => { const handleKeyDown = (e) => { - console.log("Key pressed:", e.key, "Ctrl:", e.ctrlKey, "Shift:", e.shiftKey); // ADD THIS if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === "N") { e.preventDefault(); newChat(); @@ -57,6 +60,18 @@ export default function App() { } const refreshSessions = useCallback(async () => { + try { + const s = await api.getSessions(); + setSessions(s || []); + } catch {} + }, []); + + const refreshDocuments = useCallback(async (sid) => { + try { + const d = await api.getDocuments(sid); + setDocuments(d.documents || []); + } catch {} + try { const s = await api.getSessions(); setSessions(s || []); } catch { } }, []); @@ -64,6 +79,15 @@ export default function App() { try { const d = await api.getDocuments(sid); setDocuments(d.documents || []); } catch { } }, []); + const stopGeneration = useCallback(() => { + if (abortController) { + abortController.abort(); + setAbortController(null); + setIsStreaming(false); + setStreaming(false); + } + }, [abortController]); + async function sendMessage(text) { if (!text.trim() || loading || streaming) return; let activeSid = sessionId; @@ -76,8 +100,11 @@ export default function App() { if (useStream) { setStreaming(true); + setIsStreaming(true); const aiMsg = { role: "assistant", content: "", sources: [], id: Date.now() + 1, streaming: true }; setMessages(prev => [...prev, aiMsg]); + const controller = new AbortController(); + setAbortController(controller); try { await api.streamMessage( { message: text, session_id: activeSid, model, use_documents: documents.length > 0, language }, @@ -85,11 +112,20 @@ export default function App() { (sources, benchmarks) => { setMessages(prev => prev.map(m => m.id === aiMsg.id ? { ...m, sources, benchmarks, streaming: false } : m)); refreshSessions(); - } + }, + controller.signal ); } catch (e) { - setMessages(prev => prev.map(m => m.id === aiMsg.id ? { ...m, content: e.message, streaming: false } : m)); - } finally { setStreaming(false); } + if (e.name === 'AbortError') { + setMessages(prev => prev.map(m => m.id === aiMsg.id ? { ...m, content: m.content + "\n\n[Stopped by user]", streaming: false } : m)); + } else { + setMessages(prev => prev.map(m => m.id === aiMsg.id ? { ...m, content: e.message, streaming: false } : m)); + } + } finally { + setStreaming(false); + setIsStreaming(false); + setAbortController(null); + } } else { setLoading(true); try { @@ -103,16 +139,16 @@ export default function App() { } async function newChat() { - const sid = uuidv4(); - try { - await api.createSession({ title: "New Chat", model }); - } catch {} - setSessionId(sid); - setMessages([]); - setDocuments([]); - setPanel(null); - refreshSessions(); - } + const sid = uuidv4(); + try { + await api.createSession({ title: "New Chat", model }); + } catch {} + setSessionId(sid); + setMessages([]); + setDocuments([]); + setPanel(null); + refreshSessions(); + } async function loadSession(sid) { setSessionId(sid); @@ -126,7 +162,11 @@ export default function App() { async function handleDeleteSession(sid) { await api.deleteSession(sid); - if (sid === sessionId) { setSessionId(uuidv4()); setMessages([]); setDocuments([]); } + if (sid === sessionId) { + setSessionId(uuidv4()); + setMessages([]); + setDocuments([]); + } refreshSessions(); } @@ -154,6 +194,7 @@ export default function App() { onNewChat={newChat} onLoadSession={loadSession} onDeleteSession={handleDeleteSession} + refreshSessions={refreshSessions} onClearAllSessions={handleClearAllSessions} model={model} models={models} @@ -203,9 +244,11 @@ export default function App() { loading={loading || streaming} onSend={sendMessage} sessionId={sessionId} + isStreaming={isStreaming} + onStop={stopGeneration} /> )} ); -} +} \ No newline at end of file diff --git a/frontend/src/components/ChatWindow.jsx b/frontend/src/components/ChatWindow.jsx index 5cab23f..3ed55ea 100644 --- a/frontend/src/components/ChatWindow.jsx +++ b/frontend/src/components/ChatWindow.jsx @@ -4,7 +4,7 @@ import { AppLogoIcon, ChartIcon, CloseIcon, CopyIcon, FileIcon, LockIcon, PlusCi import CodeBlockWithCopy from "./CodeBlockWithCopy"; import PromptTemplateDialog from "./PromptTemplateDialog"; -export default function ChatWindow({ messages, loading, onSend, sessionId }) { +export default function ChatWindow({ messages, loading, onSend, sessionId, isStreaming, onStop }) { const [input, setInput] = useState(""); const [showPlusMenu, setShowPlusMenu] = useState(false); const [showTemplateDialog, setShowTemplateDialog] = useState(false); @@ -13,7 +13,7 @@ export default function ChatWindow({ messages, loading, onSend, sessionId }) { const textareaRef = useRef(null); const plusMenuRef = useRef(null); - // NEW: state for selected messages and export format + // State for export selection and copy const [selectedMessages, setSelectedMessages] = useState([]); const [exportFormat, setExportFormat] = useState("markdown"); const [copiedMsgId, setCopiedMsgId] = useState(null); @@ -71,6 +71,7 @@ export default function ChatWindow({ messages, loading, onSend, sessionId }) { } return parts; } + function send() { if ((!input.trim() && !selectedTemplate) || loading) return; const message = selectedTemplate @@ -79,7 +80,7 @@ export default function ChatWindow({ messages, loading, onSend, sessionId }) { onSend(message); setInput(""); setSelectedTemplate(null); - if (textareaRef.current) { textareaRef.current.style.height = "auto"; } + if (textareaRef.current) textareaRef.current.style.height = "auto"; } function handleKey(e) { @@ -91,6 +92,56 @@ export default function ChatWindow({ messages, loading, onSend, sessionId }) { e.target.style.height = Math.min(e.target.scrollHeight, 160) + "px"; } + // Message selection & export + const toggleSelectMessage = (msgId) => { + setSelectedMessages(prev => + prev.includes(msgId) ? prev.filter(id => id !== msgId) : [...prev, msgId] + ); + }; + + const handleExportSelected = async () => { + if (selectedMessages.length === 0) return; + try { + const response = await fetch("/api/export/messages", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message_ids: selectedMessages, format: exportFormat }), + }); + if (!response.ok) throw new Error("Export failed"); + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `localmind_export.${exportFormat === "markdown" ? "md" : exportFormat}`; + a.click(); + URL.revokeObjectURL(url); + } catch (err) { + console.error(err); + alert("Failed to export messages"); + } + }; + + const exportSingleMessage = async (msgId) => { + try { + const response = await fetch("/api/export/messages", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message_ids: [msgId], format: exportFormat }), + }); + if (!response.ok) throw new Error("Export failed"); + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `localmind_message_${msgId}.${exportFormat === "markdown" ? "md" : exportFormat}`; + a.click(); + URL.revokeObjectURL(url); + } catch (err) { + console.error(err); + alert("Failed to export message"); + } + }; + const SUGGESTIONS = [ "Summarize the uploaded document", "What are the key points?", @@ -100,7 +151,7 @@ export default function ChatWindow({ messages, loading, onSend, sessionId }) { return (
LocalMind is ready
100% private · runs offline · no cloud
@@ -134,6 +215,15 @@ export default function ChatWindow({ messages, loading, onSend, sessionId }) { {messages.map((msg, i) => (Performance
-Performance
+
v2.0 · Offline AI
{sessions.length === 0 ? "No chats yet. Start one!" : "No results."}
)} +