diff --git a/backend/routes/export.py b/backend/routes/export.py index 2766069..8eb9405 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 = ["# 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 = ["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/backend/services/db_service.py b/backend/services/db_service.py index 7816c09..dd33814 100644 --- a/backend/services/db_service.py +++ b/backend/services/db_service.py @@ -261,6 +261,21 @@ def log_plugin(session_id: str, plugin: str, inp: str, out: str, success: bool = (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] + + # ─── Prompt Template Registry ─────────────────────────────────────────────── def create_prompt_template(prompt_title: str, prompt: str) -> dict: """Create a new prompt template.""" @@ -311,4 +326,4 @@ 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,)) + conn.execute("DELETE FROM prompt_templates WHERE id = ?", (template_id,)) \ No newline at end of file diff --git a/frontend/src/components/ChatWindow.jsx b/frontend/src/components/ChatWindow.jsx index 829bef8..2faadb0 100644 --- a/frontend/src/components/ChatWindow.jsx +++ b/frontend/src/components/ChatWindow.jsx @@ -1,19 +1,71 @@ import { useState, useRef, useEffect } from "react"; import { exportSession } from "../utils/api"; -import { AppLogoIcon, FileIcon, LockIcon } from "./Icons"; +import { AppLogoIcon, CloseIcon, FileIcon, LockIcon, PlusCircleIcon, TemplateIcon } from "./Icons"; +import CodeBlockWithCopy from "./CodeBlockWithCopy"; +import PromptTemplateDialog from "./PromptTemplateDialog"; export default function ChatWindow({ messages, loading, onSend, sessionId }) { const [input, setInput] = useState(""); const bottomRef = useRef(null); const textareaRef = 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"); + + useEffect(() => { bottomRef.current?.scrollIntoView({ behavior: "smooth" }); }, [messages]); + + // Close plus menu on outside click + useEffect(() => { + function handleClickOutside(e) { + if (plusMenuRef.current && !plusMenuRef.current.contains(e.target)) { + setShowPlusMenu(false); + } + } + if (showPlusMenu) document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [showPlusMenu]); + + function handleSelectTemplate(template) { + setSelectedTemplate(template); + setShowTemplateDialog(false); + setShowPlusMenu(false); + setTimeout(() => textareaRef.current?.focus(), 0); + } + + // Parse code blocks for copy button + function parseMessageWithCodeBlocks(content) { + if (!content) return [{ type: "text", content: "" }]; + const parts = []; + const regex = /```(\w*)\n([\s\S]*?)```/g; + let lastIndex = 0; + let match; + while ((match = regex.exec(content)) !== null) { + if (match.index > lastIndex) { + parts.push({ type: "text", content: content.slice(lastIndex, match.index) }); + } + parts.push({ + type: "code", + language: match[1] || "text", + code: match[2].trim() + }); + lastIndex = match.index + match[0].length; + } + if (lastIndex < content.length) { + parts.push({ type: "text", content: content.slice(lastIndex) }); + } + if (parts.length === 0) { + parts.push({ type: "text", content }); + } + return parts; + } function send() { - if (!input.trim() || loading) return; - onSend(input.trim()); + if ((!input.trim() && !selectedTemplate) || loading) return; + const message = selectedTemplate + ? `${selectedTemplate.prompt}\n\n${input.trim()}`.trim() + : input.trim(); + onSend(message); setInput(""); if (textareaRef.current) { textareaRef.current.style.height = "auto"; @@ -32,6 +84,56 @@ export default function ChatWindow({ messages, loading, onSend, sessionId }) { e.target.style.height = Math.min(e.target.scrollHeight, 160) + "px"; } + // Message selection and export + const toggleSelectMessage = (msgId) => { + setSelectedMessages(prev => + prev.includes(msgId) ? prev.filter(id => id !== msgId) : [...prev, msgId] + ); + }; + + const handleExportSelected = async () => { + if (selectedMessages.length === 0) return; + try { + const response = await fetch("/api/export/messages", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message_ids: selectedMessages, format: exportFormat }), + }); + if (!response.ok) throw new Error("Export failed"); + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `localmind_export.${exportFormat === "markdown" ? "md" : exportFormat}`; + a.click(); + URL.revokeObjectURL(url); + } catch (err) { + console.error(err); + alert("Failed to export messages"); + } + }; + + const exportSingleMessage = async (msgId) => { + try { + const response = await fetch("/api/export/messages", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message_ids: [msgId], format: exportFormat }), + }); + if (!response.ok) throw new Error("Export failed"); + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `localmind_message_${msgId}.${exportFormat === "markdown" ? "md" : exportFormat}`; + a.click(); + URL.revokeObjectURL(url); + } catch (err) { + console.error(err); + alert("Failed to export message"); + } + }; + const SUGGESTIONS = [ "Summarize the uploaded document", "What are the key points?", @@ -41,7 +143,7 @@ export default function ChatWindow({ messages, loading, onSend, sessionId }) { return (
+ {code}
+
+
+