From a8db5fcf5a6f39d262e4d68169fe32520f49d7b0 Mon Sep 17 00:00:00 2001 From: adikulkarni006 Date: Tue, 9 Jun 2026 15:55:39 +0530 Subject: [PATCH 1/7] feat: export single or selected messages (#357) --- backend/routes/export.py | 73 ++++++++++++--- frontend/src/components/ChatWindow.jsx | 122 ++++++++++++++++++++++++- 2 files changed, 180 insertions(+), 15 deletions(-) diff --git a/backend/routes/export.py b/backend/routes/export.py index 2766069..8397f51 100644 --- a/backend/routes/export.py +++ b/backend/routes/export.py @@ -2,28 +2,35 @@ import json from datetime import datetime +from typing import List from fastapi import APIRouter, HTTPException from fastapi.responses import Response +from pydantic import BaseModel from models.schemas import ExportFormat from services import db_service router = APIRouter() +class ExportMessagesRequest(BaseModel): + message_ids: List[str] + format: ExportFormat + + @router.get("/{session_id}/{fmt}") async def export_session(session_id: str, fmt: ExportFormat): - session = db_service.get_session(session_id) + session = db_service.get_session(session_id) if not session: raise HTTPException(404, "Session not found") messages = db_service.get_messages_full(session_id) - title = session.get("title", "LocalMind Chat") - ts = datetime.now().strftime("%Y-%m-%d %H:%M") + title = session.get("title", "LocalMind Chat") + ts = datetime.now().strftime("%Y-%m-%d %H:%M") if fmt == ExportFormat.json: - content = json.dumps({"session": session, "messages": messages, "exported_at": ts}, indent=2, ensure_ascii=False) - media = "application/json" - filename = f"localmind_{session_id[:8]}.json" + content = json.dumps({"session": session, "messages": messages, "exported_at": ts}, indent=2, ensure_ascii=False) + media = "application/json" + filename = f"localmind_{session_id[:8]}.json" elif fmt == ExportFormat.markdown: lines = [f"# {title}\n", f"*Exported: {ts} | Model: {session.get('model','?')}*\n\n---\n"] @@ -33,21 +40,63 @@ async def export_session(session_id: str, fmt: ExportFormat): if m.get("sources"): lines.append(f"*Sources: {', '.join(m['sources'])}*\n") lines.append("\n---\n") - content = "\n".join(lines) - media = "text/markdown" - filename = f"localmind_{session_id[:8]}.md" + content = "\n".join(lines) + media = "text/markdown" + filename = f"localmind_{session_id[:8]}.md" else: # txt lines = [f"LocalMind Export — {title}", f"Exported: {ts}", "=" * 50, ""] for m in messages: role = "YOU" if m["role"] == "user" else "LOCALMIND" lines += [f"[{role}]", m["content"], ""] - content = "\n".join(lines) - media = "text/plain" - filename = f"localmind_{session_id[:8]}.txt" + content = "\n".join(lines) + media = "text/plain" + filename = f"localmind_{session_id[:8]}.txt" return Response( content=content.encode("utf-8"), media_type=media, headers={"Content-Disposition": f'attachment; filename="{filename}"'}, ) + + +@router.post("/messages") +async def export_messages(req: ExportMessagesRequest): + messages = db_service.get_messages_by_ids(req.message_ids) + if not messages: + raise HTTPException(404, "No messages found for the given IDs") + + messages.sort(key=lambda m: m.get("timestamp", "")) + ts = datetime.now().strftime("%Y-%m-%d %H:%M") + + if req.format == ExportFormat.json: + content = json.dumps({"messages": messages, "exported_at": ts}, indent=2, ensure_ascii=False) + media = "application/json" + filename = f"localmind_messages_{ts.replace(' ', '_')}.json" + + elif req.format == ExportFormat.markdown: + lines = [f"# LocalMind – Exported Messages\n", f"*Exported: {ts}*\n\n---\n"] + for m in messages: + role_label = "**You**" if m["role"] == "user" else "**LocalMind**" + lines.append(f"{role_label}\n\n{m['content']}\n") + if m.get("sources"): + lines.append(f"*Sources: {', '.join(m['sources'])}*\n") + lines.append("\n---\n") + content = "\n".join(lines) + media = "text/markdown" + filename = f"localmind_messages_{ts.replace(' ', '_')}.md" + + else: + lines = [f"LocalMind Export — Selected Messages", f"Exported: {ts}", "=" * 50, ""] + for m in messages: + role = "YOU" if m["role"] == "user" else "LOCALMIND" + lines += [f"[{role}]", m["content"], ""] + content = "\n".join(lines) + media = "text/plain" + filename = f"localmind_messages_{ts.replace(' ', '_')}.txt" + + return Response( + content=content.encode("utf-8"), + media_type=media, + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) \ No newline at end of file diff --git a/frontend/src/components/ChatWindow.jsx b/frontend/src/components/ChatWindow.jsx index 90d32ab..7634f6a 100644 --- a/frontend/src/components/ChatWindow.jsx +++ b/frontend/src/components/ChatWindow.jsx @@ -6,6 +6,10 @@ export default function ChatWindow({ messages, loading, onSend, sessionId }) { const [input, setInput] = useState(""); const bottomRef = useRef(null); const textareaRef = useRef(null); + + // NEW: state for selected messages and export format + const [selectedMessages, setSelectedMessages] = useState([]); + const [exportFormat, setExportFormat] = useState("markdown"); useEffect(() => { bottomRef.current?.scrollIntoView({ behavior: "smooth" }); }, [messages]); @@ -25,6 +29,60 @@ export default function ChatWindow({ messages, loading, onSend, sessionId }) { e.target.style.height = Math.min(e.target.scrollHeight, 160) + "px"; } + // NEW: toggle message selection + const toggleSelectMessage = (msgId) => { + setSelectedMessages(prev => + prev.includes(msgId) ? prev.filter(id => id !== msgId) : [...prev, msgId] + ); + }; + + // NEW: export selected messages via backend POST /api/export/messages + 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"); + } + }; + + // NEW: export a single message (just select it and call export) + const exportSingleMessage = async (msgId) => { + setSelectedMessages([msgId]); + // wait a tick for state update (or directly call export with array) + 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?", @@ -34,7 +92,7 @@ export default function ChatWindow({ messages, loading, onSend, sessionId }) { return (
- {/* Export bar */} + {/* Export bar – existing for whole session + new selection bar */} {messages.length > 0 && (
{["markdown","json","txt"].map(f => ( @@ -46,6 +104,36 @@ export default function ChatWindow({ messages, loading, onSend, sessionId }) {
)} + {/* NEW: Export selection bar */} + {selectedMessages.length > 0 && ( +
+ {selectedMessages.length} message(s) selected +
+ + + +
+
+ )} + {/* Messages */}
{messages.length === 0 && ( @@ -68,6 +156,15 @@ export default function ChatWindow({ messages, loading, onSend, sessionId }) { {messages.map((msg, i) => (
+ {/* NEW: checkbox for selection */} +
+ toggleSelectMessage(msg.id)} + className="w-4 h-4 rounded border-gray-600 bg-gray-800 text-purple-600 focus:ring-purple-500 focus:ring-1" + /> +
{msg.role === "assistant" && (
@@ -96,8 +193,27 @@ export default function ChatWindow({ messages, loading, onSend, sessionId }) {
)} {msg.role === "user" && ( -
+
You + {/* NEW: per-message export button */} + +
+ )} + {msg.role === "assistant" && ( +
+
)}
@@ -150,4 +266,4 @@ export default function ChatWindow({ messages, loading, onSend, sessionId }) {
); -} +} \ No newline at end of file From 1ae4096cd5c449c34cba897b3dab935f2a4f296a Mon Sep 17 00:00:00 2001 From: adikulkarni006 Date: Tue, 9 Jun 2026 22:56:02 +0530 Subject: [PATCH 2/7] fix: move get_messages_by_ids outside log_plugin --- backend/routes/export.py | 10 +++++----- backend/services/db_service.py | 14 ++++++++++++++ 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/backend/routes/export.py b/backend/routes/export.py index 8397f51..35b7846 100644 --- a/backend/routes/export.py +++ b/backend/routes/export.py @@ -72,10 +72,10 @@ async def export_messages(req: ExportMessagesRequest): if req.format == ExportFormat.json: content = json.dumps({"messages": messages, "exported_at": ts}, indent=2, ensure_ascii=False) media = "application/json" - filename = f"localmind_messages_{ts.replace(' ', '_')}.json" + filename = f"localmind_messages_{ts.replace(' ', '_').replace(':', '-')}.json" elif req.format == ExportFormat.markdown: - lines = [f"# LocalMind – Exported Messages\n", f"*Exported: {ts}*\n\n---\n"] + lines = ["# LocalMind – Exported Messages\n", f"*Exported: {ts}*\n\n---\n"] for m in messages: role_label = "**You**" if m["role"] == "user" else "**LocalMind**" lines.append(f"{role_label}\n\n{m['content']}\n") @@ -84,16 +84,16 @@ async def export_messages(req: ExportMessagesRequest): lines.append("\n---\n") content = "\n".join(lines) media = "text/markdown" - filename = f"localmind_messages_{ts.replace(' ', '_')}.md" + filename = f"localmind_messages_{ts.replace(' ', '_').replace(':', '-')}.md" else: - lines = [f"LocalMind Export — Selected Messages", f"Exported: {ts}", "=" * 50, ""] + lines = ["LocalMind Export — Selected Messages", f"Exported: {ts}", "=" * 50, ""] for m in messages: role = "YOU" if m["role"] == "user" else "LOCALMIND" lines += [f"[{role}]", m["content"], ""] content = "\n".join(lines) media = "text/plain" - filename = f"localmind_messages_{ts.replace(' ', '_')}.txt" + filename = f"localmind_messages_{ts.replace(' ', '_').replace(':', '-')}.txt" return Response( content=content.encode("utf-8"), diff --git a/backend/services/db_service.py b/backend/services/db_service.py index 66d8ed9..0a1ad74 100644 --- a/backend/services/db_service.py +++ b/backend/services/db_service.py @@ -252,3 +252,17 @@ def log_plugin(session_id: str, plugin: str, inp: str, out: str, success: bool = "INSERT INTO plugin_logs (session_id, plugin, input, output, success) VALUES (?,?,?,?,?)", (session_id, plugin, inp, out, int(success)), ) + + +def get_messages_by_ids(message_ids: list): + """Fetch messages by list of message IDs (used for batch export).""" + if not message_ids: + return [] + placeholders = ','.join('?' for _ in message_ids) + with get_db() as conn: + rows = conn.execute(f""" + SELECT id, role, content, sources, created_at as timestamp + FROM messages + WHERE id IN ({placeholders}) + """, message_ids).fetchall() + return [dict(row) for row in rows] \ No newline at end of file From 36841f106aa72372563b7990cd3539ebcaf1c468 Mon Sep 17 00:00:00 2001 From: adikulkarni006 Date: Thu, 11 Jun 2026 13:38:20 +0530 Subject: [PATCH 3/7] feat: add drag-and-drop reordering for chat sessions (#361) --- backend/routes/sessions.py | 33 +++-- backend/services/db_service.py | 28 +++- frontend/package-lock.json | 69 +++++++++- frontend/package.json | 3 + frontend/src/App.jsx | 42 +++--- frontend/src/components/Sidebar.jsx | 207 ++++++++++++++++++++++------ frontend/src/utils/api.js | 10 +- 7 files changed, 313 insertions(+), 79 deletions(-) diff --git a/backend/routes/sessions.py b/backend/routes/sessions.py index 8b6091e..0f35752 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,9 +131,7 @@ 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} \ No newline at end of file diff --git a/backend/services/db_service.py b/backend/services/db_service.py index dd33814..4e321f3 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: @@ -120,8 +130,8 @@ def init_db(): prompt TEXT NOT NULL, created_at TEXT DEFAULT (datetime('now')) ); - """) + ensure_order_index_column() # <-- add order_index column # ─── Sessions ──────────────────────────────────────────────── @@ -154,9 +164,10 @@ def delete_session(session_id: str): 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] @@ -326,4 +337,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 73529f0..450b145 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,17 @@ export default function App() { } const refreshSessions = useCallback(async () => { - try { const s = await api.getSessions(); setSessions(s || []); } 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 {} + try { + const d = await api.getDocuments(sid); + setDocuments(d.documents || []); + } catch {} }, []); async function sendMessage(text) { @@ -98,16 +103,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); @@ -121,7 +126,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(); } @@ -138,6 +147,7 @@ export default function App() { onNewChat={newChat} onLoadSession={loadSession} onDeleteSession={handleDeleteSession} + refreshSessions={refreshSessions} // ✅ ADD THIS LINE model={model} models={models} onModelChange={setModel} @@ -191,4 +201,4 @@ export default function App() {
); -} +} \ No newline at end of file diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx index 4927201..bbfdf2d 100644 --- a/frontend/src/components/Sidebar.jsx +++ b/frontend/src/components/Sidebar.jsx @@ -1,17 +1,116 @@ -import { useState } from "react"; +import { useState, useEffect } from "react"; +import { DndContext, closestCenter } from "@dnd-kit/core"; +import { + SortableContext, + verticalListSortingStrategy, + arrayMove, + useSortable, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; import { AppLogoIcon, ChatIcon, LockIcon, StarIcon } from "./Icons"; import { highlightText } from "../utils/search"; +import { api } from "../utils/api"; const LANGUAGES = [ - {code:"en",label:"English"},{code:"hi",label:"हिन्दी"},{code:"ta",label:"தமிழ்"}, - {code:"te",label:"తెలుగు"},{code:"kn",label:"ಕನ್ನಡ"},{code:"fr",label:"Français"}, - {code:"de",label:"Deutsch"},{code:"es",label:"Español"}, + { code: "en", label: "English" }, { code: "hi", label: "हिन्दी" }, { code: "ta", label: "தமிழ்" }, + { code: "te", label: "తెలుగు" }, { code: "kn", label: "ಕನ್ನಡ" }, { code: "fr", label: "Français" }, + { code: "de", label: "Deutsch" }, { code: "es", label: "Español" }, ]; -export default function Sidebar({ sessions, currentSession, onNewChat, onLoadSession, onDeleteSession, model, models, onModelChange, language, onLanguageChange }) { +// Individual sortable session item +function SortableSessionItem({ session, isActive, search, onLoad, onDelete, messageCount }) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: session.id, + }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.6 : 1, + }; + + return ( +
+
+ + +
+
+ ); +} + +export default function Sidebar({ + sessions, + currentSession, + onNewChat, + onLoadSession, + onDeleteSession, + model, + models, + onModelChange, + language, + onLanguageChange, + refreshSessions, // optional: function to refresh sessions after reorder +}) { const [search, setSearch] = useState(""); - const modelList = models.length > 0 ? models.map(m=>m.name) : ["llama3","mistral","phi3","gemma2"]; - const filtered = sessions.filter(s => s.title?.toLowerCase().includes(search.toLowerCase())); + const [items, setItems] = useState([]); + const [loadingReorder, setLoadingReorder] = useState(false); + const modelList = models.length > 0 ? models.map((m) => m.name) : ["llama3", "mistral", "phi3", "gemma2"]; + const filtered = sessions.filter((s) => s.title?.toLowerCase().includes(search.toLowerCase())); + + // Sync items with session IDs when sessions change + useEffect(() => { + setItems(sessions.map((s) => s.id)); + }, [sessions]); + + const handleDragEnd = async (event) => { + const { active, over } = event; + if (active.id !== over.id) { + const oldIndex = items.indexOf(active.id); + const newIndex = items.indexOf(over.id); + const newItems = arrayMove(items, oldIndex, newIndex); + setItems(newItems); + setLoadingReorder(true); + try { + await api.reorderSessions(newItems); + // Optionally refresh sessions from backend + if (refreshSessions) refreshSessions(); + } catch (err) { + console.error("Reorder failed:", err); + setItems(sessions.map((s) => s.id)); // revert + } finally { + setLoadingReorder(false); + } + } + }; + + // Map items to session objects in the new order + const orderedSessions = items + .map((id) => sessions.find((s) => s.id === id)) + .filter(Boolean); return (
@@ -24,63 +123,79 @@ export default function Sidebar({ sessions, currentSession, onNewChat, onLoadSes

v2.0 · Offline AI

- - {/* Model */} + {/* Model & Language */}
- onModelChange(e.target.value)} + className="w-full text-xs bg-gray-800 text-gray-200 border border-gray-700 rounded-lg px-2 py-1.5 outline-none focus:border-purple-500" + > + {modelList.map((m) => ( + + ))} - onLanguageChange(e.target.value)} + className="w-full text-xs bg-gray-800 text-gray-200 border border-gray-700 rounded-lg px-2 py-1.5 outline-none focus:border-purple-500" + > + {LANGUAGES.map((l) => ( + + ))}
{/* Search */}
- setSearch(e.target.value)} + setSearch(e.target.value)} placeholder="Search chats..." - className="w-full text-xs bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-gray-300 placeholder-gray-600 outline-none focus:border-purple-500" /> + className="w-full text-xs bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-gray-300 placeholder-gray-600 outline-none focus:border-purple-500" + />
- {/* Sessions */} + {/* Sessions (with drag-and-drop) */}
- {filtered.length === 0 && ( + {orderedSessions.length === 0 && (

{sessions.length === 0 ? "No chats yet. Start one!" : "No results."}

)} - {filtered.map(s => ( -
- - -
- ))} + + + {orderedSessions.map((session) => ( + + ))} + + + {loadingReorder && ( +
Saving order...
+ )}
{/* Footer */} @@ -89,12 +204,16 @@ export default function Sidebar({ sessions, currentSession, onNewChat, onLoadSes 100% local · no cloud · MIT

- + Star on GitHub ); -} +} \ No newline at end of file diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 53ba1b9..02d2c74 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -35,6 +35,14 @@ export const createPromptTemplate = (b) => req("/prompt-templates/", { met export const updatePromptTemplate = (id,b) => req(`/prompt-templates/${id}`, { method: "PUT", body: JSON.stringify(b) }); export const deletePromptTemplate = (id) => req(`/prompt-templates/${id}`, { method: "DELETE" }); +// ✨ Drag‑and‑drop reordering (issue #361) +export const reorderSessions = async (sessionIds) => { + return req("/sessions/reorder", { + method: "PATCH", + body: JSON.stringify({ session_ids: sessionIds }), + }); +}; + export async function uploadDocument(file, session_id) { const fd = new FormData(); fd.append("file", file); fd.append("session_id", session_id); @@ -62,4 +70,4 @@ export function streamMessage(body, onToken, onDone) { } return pump(); }); -} +} \ No newline at end of file From 1d0c8063a5766faebdcbaa9acb430e1e8e6b9e6b Mon Sep 17 00:00:00 2001 From: adikulkarni006 Date: Thu, 11 Jun 2026 13:45:46 +0530 Subject: [PATCH 4/7] fix: use namespace import for api in Sidebar --- frontend/src/components/Sidebar.jsx | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx index bbfdf2d..99f16f6 100644 --- a/frontend/src/components/Sidebar.jsx +++ b/frontend/src/components/Sidebar.jsx @@ -9,7 +9,7 @@ import { import { CSS } from "@dnd-kit/utilities"; import { AppLogoIcon, ChatIcon, LockIcon, StarIcon } from "./Icons"; import { highlightText } from "../utils/search"; -import { api } from "../utils/api"; +import * as api from "../utils/api"; // ✅ FIXED – use namespace import const LANGUAGES = [ { code: "en", label: "English" }, { code: "hi", label: "हिन्दी" }, { code: "ta", label: "தமிழ்" }, @@ -73,7 +73,7 @@ export default function Sidebar({ onModelChange, language, onLanguageChange, - refreshSessions, // optional: function to refresh sessions after reorder + refreshSessions, }) { const [search, setSearch] = useState(""); const [items, setItems] = useState([]); @@ -81,7 +81,6 @@ export default function Sidebar({ const modelList = models.length > 0 ? models.map((m) => m.name) : ["llama3", "mistral", "phi3", "gemma2"]; const filtered = sessions.filter((s) => s.title?.toLowerCase().includes(search.toLowerCase())); - // Sync items with session IDs when sessions change useEffect(() => { setItems(sessions.map((s) => s.id)); }, [sessions]); @@ -96,18 +95,16 @@ export default function Sidebar({ setLoadingReorder(true); try { await api.reorderSessions(newItems); - // Optionally refresh sessions from backend if (refreshSessions) refreshSessions(); } catch (err) { console.error("Reorder failed:", err); - setItems(sessions.map((s) => s.id)); // revert + setItems(sessions.map((s) => s.id)); } finally { setLoadingReorder(false); } } }; - // Map items to session objects in the new order const orderedSessions = items .map((id) => sessions.find((s) => s.id === id)) .filter(Boolean); From b2ce2580610745bbc4a60b1def7846da85ea7d19 Mon Sep 17 00:00:00 2001 From: adikulkarni006 Date: Thu, 11 Jun 2026 14:13:48 +0530 Subject: [PATCH 5/7] feat: add stop generation button to cancel ongoing AI response (#364) --- frontend/src/App.jsx | 37 ++++++++++++++++++++++---- frontend/src/components/ChatWindow.jsx | 19 ++++++++----- frontend/src/utils/api.js | 19 +++++++++---- 3 files changed, 59 insertions(+), 16 deletions(-) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 450b145..c4c1a34 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -25,9 +25,13 @@ export default function App() { 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) => { if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === "N") { @@ -69,6 +73,15 @@ export default function App() { } 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; const userMsg = { role: "user", content: text, id: Date.now() }; @@ -76,8 +89,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: sessionId, model, use_documents: documents.length > 0, language }, @@ -85,11 +101,20 @@ export default function App() { (sources) => { setMessages(prev => prev.map(m => m.id === aiMsg.id ? { ...m, sources, 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 { @@ -147,7 +172,7 @@ export default function App() { onNewChat={newChat} onLoadSession={loadSession} onDeleteSession={handleDeleteSession} - refreshSessions={refreshSessions} // ✅ ADD THIS LINE + refreshSessions={refreshSessions} model={model} models={models} onModelChange={setModel} @@ -196,6 +221,8 @@ export default function App() { loading={loading || streaming} onSend={sendMessage} sessionId={sessionId} + isStreaming={isStreaming} + onStop={stopGeneration} /> )} diff --git a/frontend/src/components/ChatWindow.jsx b/frontend/src/components/ChatWindow.jsx index 90d32ab..6a2e52e 100644 --- a/frontend/src/components/ChatWindow.jsx +++ b/frontend/src/components/ChatWindow.jsx @@ -2,7 +2,7 @@ import { useState, useRef, useEffect } from "react"; import { exportSession } from "../utils/api"; import { AppLogoIcon, FileIcon, LockIcon } from "./Icons"; -export default function ChatWindow({ messages, loading, onSend, sessionId }) { +export default function ChatWindow({ messages, loading, onSend, sessionId, isStreaming, onStop }) { const [input, setInput] = useState(""); const bottomRef = useRef(null); const textareaRef = useRef(null); @@ -136,10 +136,17 @@ export default function ChatWindow({ messages, loading, onSend, sessionId }) { className="flex-1 bg-transparent text-sm text-gray-100 placeholder-gray-500 resize-none outline-none" style={{ minHeight: "24px", maxHeight: "160px" }} /> - + {isStreaming ? ( + + ) : ( + + )}

@@ -150,4 +157,4 @@ export default function ChatWindow({ messages, loading, onSend, sessionId }) { ); -} +} \ No newline at end of file diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 02d2c74..61b37d5 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -35,7 +35,7 @@ export const createPromptTemplate = (b) => req("/prompt-templates/", { met export const updatePromptTemplate = (id,b) => req(`/prompt-templates/${id}`, { method: "PUT", body: JSON.stringify(b) }); export const deletePromptTemplate = (id) => req(`/prompt-templates/${id}`, { method: "DELETE" }); -// ✨ Drag‑and‑drop reordering (issue #361) +// Reorder sessions export const reorderSessions = async (sessionIds) => { return req("/sessions/reorder", { method: "PATCH", @@ -43,6 +43,7 @@ export const reorderSessions = async (sessionIds) => { }); }; +// Upload document export async function uploadDocument(file, session_id) { const fd = new FormData(); fd.append("file", file); fd.append("session_id", session_id); @@ -51,18 +52,26 @@ export async function uploadDocument(file, session_id) { return res.json(); } -export function streamMessage(body, onToken, onDone) { +// Streaming with AbortSignal support (for Stop button) +export function streamMessage(body, onToken, onDone, signal) { return fetch(`${BASE}/chat/stream`, { - method: "POST", headers: { "Content-Type": "application/json" }, + method: "POST", + headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), + signal, // ✅ allows aborting }).then(res => { - const reader = res.body.getReader(); const decoder = new TextDecoder(); + const reader = res.body.getReader(); + const decoder = new TextDecoder(); function pump() { return reader.read().then(({ done, value }) => { if (done) return; decoder.decode(value).split("\n").forEach(line => { if (line.startsWith("data: ")) { - try { const d = JSON.parse(line.slice(6)); if (d.token) onToken(d.token); if (d.done) onDone(d.sources||[]); } catch {} + try { + const d = JSON.parse(line.slice(6)); + if (d.token) onToken(d.token); + if (d.done) onDone(d.sources||[]); + } catch {} } }); return pump(); From 92c01c2a9105308a890df7523dce835f23d732a3 Mon Sep 17 00:00:00 2001 From: adikulkarni006 Date: Fri, 12 Jun 2026 22:25:35 +0530 Subject: [PATCH 6/7] fix: add DELETE /sessions/ endpoint to clear all sessions (fix test_clear_all_sessions) --- backend/routes/sessions.py | 16 +++++++++------- backend/services/db_service.py | 10 ++++++++-- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/backend/routes/sessions.py b/backend/routes/sessions.py index 0f35752..def3102 100644 --- a/backend/routes/sessions.py +++ b/backend/routes/sessions.py @@ -1,4 +1,4 @@ -"""Sessions routes — /api/sessions — full CRUD + reorder""" +"""Sessions routes — /api/sessions — full CRUD + reorder + clear all""" import uuid from typing import List @@ -48,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", @@ -73,10 +71,6 @@ async def bulk_rename_sessions(body: BulkSessionRenameRequest): @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"} @@ -109,6 +103,14 @@ async def delete_session(session_id: str): 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 {"status": "cleared", "message": "All sessions deleted"} + + @router.get("/{session_id}/messages") async def get_messages(session_id: str): messages = db_service.get_messages_full(session_id) diff --git a/backend/services/db_service.py b/backend/services/db_service.py index 4e321f3..dff1b50 100644 --- a/backend/services/db_service.py +++ b/backend/services/db_service.py @@ -131,7 +131,7 @@ def init_db(): created_at TEXT DEFAULT (datetime('now')) ); """) - ensure_order_index_column() # <-- add order_index column + ensure_order_index_column() # ─── Sessions ──────────────────────────────────────────────── @@ -163,6 +163,12 @@ def delete_session(session_id: str): conn.execute("DELETE FROM sessions WHERE id=?", (session_id,)) +def clear_all_sessions(): + """Delete ALL sessions and their associated messages/documents (cascade).""" + with get_db() as conn: + 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: @@ -340,7 +346,7 @@ def delete_prompt_template(template_id: int): conn.execute("DELETE FROM prompt_templates WHERE id = ?", (template_id,)) -# ─── Drag‑and‑drop reordering─────────── +# ─── 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: From 52d358f0e64cdd6ea8d45b8565262b5e8ee59bc3 Mon Sep 17 00:00:00 2001 From: adikulkarni006 Date: Fri, 12 Jun 2026 22:44:36 +0530 Subject: [PATCH 7/7] fix: align DELETE /sessions/ response with test expectation --- backend/routes/sessions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/routes/sessions.py b/backend/routes/sessions.py index def3102..6fa078f 100644 --- a/backend/routes/sessions.py +++ b/backend/routes/sessions.py @@ -108,7 +108,7 @@ async def delete_session(session_id: str): async def clear_all_sessions(): """Delete ALL sessions and their associated messages/documents.""" db_service.clear_all_sessions() - return {"status": "cleared", "message": "All sessions deleted"} + return {"message": "All sessions cleared"} @router.get("/{session_id}/messages")