diff --git a/backend/routes/sessions.py b/backend/routes/sessions.py index 002551e..4f8bbab 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""" 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: @@ -62,8 +69,21 @@ 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): + """ + Update the order of sessions for drag‑and‑drop. + Expects a list of session IDs in the desired order (top to bottom). + """ + 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,7 +103,6 @@ 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 @@ -112,11 +131,11 @@ 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("/") diff --git a/backend/services/db_service.py b/backend/services/db_service.py index b88ac00..2bd5f63 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() # <-- add order_index column try: conn.execute("ALTER TABLE documents ADD COLUMN status TEXT DEFAULT 'completed'") except sqlite3.OperationalError: @@ -168,9 +178,10 @@ def clear_all_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 +360,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..b145294 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -19,7 +19,7 @@ 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({}); @@ -30,7 +30,6 @@ export default function App() { // ── 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,11 +56,25 @@ export default function App() { } const refreshSessions = useCallback(async () => { +<<<<<<< HEAD + 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 { } }, []); const refreshDocuments = useCallback(async (sid) => { try { const d = await api.getDocuments(sid); setDocuments(d.documents || []); } catch { } +>>>>>>> upstream/main }, []); async function sendMessage(text) { @@ -103,16 +116,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 +139,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,7 +171,11 @@ export default function App() { onNewChat={newChat} onLoadSession={loadSession} onDeleteSession={handleDeleteSession} +<<<<<<< HEAD + refreshSessions={refreshSessions} // ✅ ADD THIS LINE +======= onClearAllSessions={handleClearAllSessions} +>>>>>>> upstream/main model={model} models={models} onModelChange={setModel} @@ -208,4 +229,4 @@ export default function App() { ); -} +} \ No newline at end of file diff --git a/frontend/src/components/ChatWindow.jsx b/frontend/src/components/ChatWindow.jsx index 5cab23f..c5aa610 100644 --- a/frontend/src/components/ChatWindow.jsx +++ b/frontend/src/components/ChatWindow.jsx @@ -1,5 +1,6 @@ import { useState, useRef, useEffect } from "react"; import { exportSession } from "../utils/api"; +import { AppLogoIcon, FileIcon, LockIcon } from "./Icons"; import { AppLogoIcon, ChartIcon, CloseIcon, CopyIcon, FileIcon, LockIcon, PlusCircleIcon, TemplateIcon } from "./Icons"; import CodeBlockWithCopy from "./CodeBlockWithCopy"; import PromptTemplateDialog from "./PromptTemplateDialog"; @@ -13,6 +14,8 @@ export default function ChatWindow({ messages, loading, onSend, sessionId }) { const textareaRef = useRef(null); const plusMenuRef = useRef(null); + useEffect(() => { bottomRef.current?.scrollIntoView({ behavior: "smooth" }); }, [messages]); + // NEW: state for selected messages and export format const [selectedMessages, setSelectedMessages] = useState([]); const [exportFormat, setExportFormat] = useState("markdown"); @@ -72,11 +75,8 @@ export default function ChatWindow({ messages, loading, onSend, sessionId }) { return parts; } function send() { - if ((!input.trim() && !selectedTemplate) || loading) return; - const message = selectedTemplate - ? `${selectedTemplate.prompt}\n\n${input.trim()}`.trim() - : input.trim(); - onSend(message); + if (!input.trim() || loading) return; + onSend(input.trim()); setInput(""); setSelectedTemplate(null); if (textareaRef.current) { textareaRef.current.style.height = "auto"; } @@ -257,6 +257,21 @@ export default function ChatWindow({ messages, loading, onSend, sessionId }) {
+ {/* Input */} +v2.0 · Offline AI
{sessions.length === 0 ? "No chats yet. Start one!" : "No results."}
)} +<<<<<<< HEAD +