From a8db5fcf5a6f39d262e4d68169fe32520f49d7b0 Mon Sep 17 00:00:00 2001
From: adikulkarni006
Date: Tue, 9 Jun 2026 15:55:39 +0530
Subject: [PATCH 1/4] 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/4] 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/4] 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 */}
-
{/* 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/4] 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);