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