diff --git a/backend/routes/sessions.py b/backend/routes/sessions.py index 002551e..a6a129b 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 + clear all""" 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: @@ -41,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", @@ -62,8 +67,17 @@ 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): + 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,13 +97,20 @@ 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 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 {"message": "All sessions cleared"} + + @router.get("/{session_id}/messages") async def get_messages(session_id: str): messages = db_service.get_messages_full(session_id) @@ -112,15 +133,14 @@ async def get_documents(session_id: str): async def rag_stats(session_id: str): try: from services import rag_service - count = rag_service.get_indexed_count(session_id) except Exception: count = 0 return {"session_id": session_id, "indexed_chunks": count} + return {"session_id": session_id, "indexed_chunks": count} @router.delete("/") async def clear_all_sessions(): db_service.clear_all_sessions() return {"message": "All sessions cleared"} - diff --git a/backend/services/db_service.py b/backend/services/db_service.py index b88ac00..216cec4 100644 --- a/backend/services/db_service.py +++ b/backend/services/db_service.py @@ -56,6 +56,16 @@ def get_db(): if conn: conn.close() + +def ensure_order_index_column(): + """Add order_index column to sessions table if not exists (for drag‑and‑drop reordering).""" + with get_db() as conn: + try: + conn.execute("ALTER TABLE sessions ADD COLUMN order_index INTEGER DEFAULT 0") + except sqlite3.OperationalError: + pass # column already exists + + def init_db(): """Create all tables on startup.""" with get_db() as conn: @@ -122,8 +132,8 @@ def init_db(): prompt TEXT NOT NULL, created_at TEXT DEFAULT (datetime('now')) ); - """) + ensure_order_index_column() try: conn.execute("ALTER TABLE documents ADD COLUMN status TEXT DEFAULT 'completed'") except sqlite3.OperationalError: @@ -162,15 +172,18 @@ def delete_session(session_id: str): def clear_all_sessions(): + """Delete ALL sessions and their associated messages/documents (cascade).""" + with get_db() as conn: with get_db() as conn: conn.execute("DELETE FROM messages") 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: rows = conn.execute( - "SELECT * FROM sessions ORDER BY updated_at DESC" + "SELECT * FROM sessions ORDER BY order_index ASC, updated_at DESC" ).fetchall() return [dict(r) for r in rows] @@ -349,4 +362,15 @@ def update_prompt_template(template_id: int, prompt_title: str = None, prompt: s def delete_prompt_template(template_id: int): """Delete a prompt template by ID.""" with get_db() as conn: - conn.execute("DELETE FROM prompt_templates WHERE id = ?", (template_id,)) \ No newline at end of file + conn.execute("DELETE FROM prompt_templates WHERE id = ?", (template_id,)) + + +# ─── Drag‑and‑drop reordering ────────────────────────────────────────────── +def update_sessions_order(session_ids: list): + """Update order_index for each session based on its position in the list (0,1,2...).""" + with get_db() as conn: + for idx, session_id in enumerate(session_ids): + conn.execute( + "UPDATE sessions SET order_index = ? WHERE id = ?", + (idx, session_id) + ) \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0b1467b..11a735b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,9 @@ "name": "localmind-frontend", "version": "2.0.0", "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "react": "^18.3.0", "react-dom": "^18.3.0", "uuid": "^10.0.0" @@ -65,7 +68,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -317,6 +319,59 @@ "node": ">=6.9.0" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", @@ -1350,7 +1405,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -1758,7 +1812,6 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", - "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -2053,7 +2106,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", @@ -2223,7 +2275,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -2546,7 +2597,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -2574,6 +2624,12 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -2632,7 +2688,6 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", diff --git a/frontend/package.json b/frontend/package.json index 72dab17..0329517 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,6 +10,9 @@ "screenshot": "node scripts/capture-screenshots.js" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "react": "^18.3.0", "react-dom": "^18.3.0", "uuid": "^10.0.0" diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index b6e8233..c97e41d 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -19,18 +19,21 @@ 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({}); 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) => { - 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,6 +60,18 @@ export default function App() { } const refreshSessions = useCallback(async () => { + try { + const s = await api.getSessions(); + setSessions(s || []); + } catch {} + }, []); + + const refreshDocuments = useCallback(async (sid) => { + try { + const d = await api.getDocuments(sid); + setDocuments(d.documents || []); + } catch {} + try { const s = await api.getSessions(); setSessions(s || []); } catch { } }, []); @@ -64,6 +79,15 @@ export default function App() { try { const d = await api.getDocuments(sid); setDocuments(d.documents || []); } 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; let activeSid = sessionId; @@ -76,8 +100,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: activeSid, model, use_documents: documents.length > 0, language }, @@ -85,11 +112,20 @@ export default function App() { (sources, benchmarks) => { setMessages(prev => prev.map(m => m.id === aiMsg.id ? { ...m, sources, benchmarks, 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 { @@ -103,16 +139,16 @@ export default function App() { } async function newChat() { - const sid = uuidv4(); - try { - await api.createSession({ title: "New Chat", model }); - } catch {} - setSessionId(sid); - setMessages([]); - setDocuments([]); - setPanel(null); - refreshSessions(); - } + const sid = uuidv4(); + try { + await api.createSession({ title: "New Chat", model }); + } catch {} + setSessionId(sid); + setMessages([]); + setDocuments([]); + setPanel(null); + refreshSessions(); + } async function loadSession(sid) { setSessionId(sid); @@ -126,7 +162,11 @@ export default function App() { async function handleDeleteSession(sid) { await api.deleteSession(sid); - if (sid === sessionId) { setSessionId(uuidv4()); setMessages([]); setDocuments([]); } + if (sid === sessionId) { + setSessionId(uuidv4()); + setMessages([]); + setDocuments([]); + } refreshSessions(); } @@ -154,6 +194,7 @@ export default function App() { onNewChat={newChat} onLoadSession={loadSession} onDeleteSession={handleDeleteSession} + refreshSessions={refreshSessions} onClearAllSessions={handleClearAllSessions} model={model} models={models} @@ -203,9 +244,11 @@ export default function App() { loading={loading || streaming} onSend={sendMessage} sessionId={sessionId} + isStreaming={isStreaming} + onStop={stopGeneration} /> )} ); -} +} \ No newline at end of file diff --git a/frontend/src/components/ChatWindow.jsx b/frontend/src/components/ChatWindow.jsx index 5cab23f..3ed55ea 100644 --- a/frontend/src/components/ChatWindow.jsx +++ b/frontend/src/components/ChatWindow.jsx @@ -4,7 +4,7 @@ import { AppLogoIcon, ChartIcon, CloseIcon, CopyIcon, FileIcon, LockIcon, PlusCi import CodeBlockWithCopy from "./CodeBlockWithCopy"; import PromptTemplateDialog from "./PromptTemplateDialog"; -export default function ChatWindow({ messages, loading, onSend, sessionId }) { +export default function ChatWindow({ messages, loading, onSend, sessionId, isStreaming, onStop }) { const [input, setInput] = useState(""); const [showPlusMenu, setShowPlusMenu] = useState(false); const [showTemplateDialog, setShowTemplateDialog] = useState(false); @@ -13,7 +13,7 @@ export default function ChatWindow({ messages, loading, onSend, sessionId }) { const textareaRef = useRef(null); const plusMenuRef = useRef(null); - // NEW: state for selected messages and export format + // State for export selection and copy const [selectedMessages, setSelectedMessages] = useState([]); const [exportFormat, setExportFormat] = useState("markdown"); const [copiedMsgId, setCopiedMsgId] = useState(null); @@ -71,6 +71,7 @@ export default function ChatWindow({ messages, loading, onSend, sessionId }) { } return parts; } + function send() { if ((!input.trim() && !selectedTemplate) || loading) return; const message = selectedTemplate @@ -79,7 +80,7 @@ export default function ChatWindow({ messages, loading, onSend, sessionId }) { onSend(message); setInput(""); setSelectedTemplate(null); - if (textareaRef.current) { textareaRef.current.style.height = "auto"; } + if (textareaRef.current) textareaRef.current.style.height = "auto"; } function handleKey(e) { @@ -91,6 +92,56 @@ export default function ChatWindow({ messages, loading, onSend, sessionId }) { e.target.style.height = Math.min(e.target.scrollHeight, 160) + "px"; } + // Message selection & 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?", @@ -100,7 +151,7 @@ export default function ChatWindow({ messages, loading, onSend, sessionId }) { return (
- {/* Export bar */} + {/* Export bar (session export) */} {messages.length > 0 && (
{["markdown","json","txt"].map(f => ( @@ -112,11 +163,41 @@ export default function ChatWindow({ messages, loading, onSend, sessionId }) {
)} + {/* Export selection bar */} + {selectedMessages.length > 0 && ( +
+ {selectedMessages.length} message(s) selected +
+ + + +
+
+ )} + {/* Messages */}
{messages.length === 0 && (
- +

LocalMind is ready

100% private · runs offline · no cloud

@@ -134,6 +215,15 @@ export default function ChatWindow({ messages, loading, onSend, sessionId }) { {messages.map((msg, i) => (
+ {/* 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" && (
@@ -146,8 +236,23 @@ export default function ChatWindow({ messages, loading, onSend, sessionId }) { ${msg.role === "user" ? "bg-purple-700 text-white rounded-br-sm" : "bg-gray-800 text-gray-100 rounded-bl-sm border border-gray-700"}`}> - {msg.content} - {msg.streaming && } + {msg.role === "user" ? ( + <> + {msg.content} + {msg.streaming && } + + ) : ( + <> + {parseMessageWithCodeBlocks(msg.content).map((part, idx) => ( + part.type === "code" ? ( + + ) : ( +
{part.content}
+ ) + ))} + {msg.streaming && } + + )}
{msg.sources?.length > 0 && (
@@ -162,8 +267,16 @@ export default function ChatWindow({ messages, loading, onSend, sessionId }) {
)} {msg.role === "user" && ( -
+
You + {/* Per-message export button */} +
)} {msg.role === "assistant" && !msg.streaming && ( @@ -180,7 +293,6 @@ export default function ChatWindow({ messages, loading, onSend, sessionId }) { )} - {/* Stats hover button */}
- {hoveredStatsId === msg.id && msg.benchmarks && Object.keys(msg.benchmarks).length > 0 && (
-

Performance

-
-
- Time to first token - {(msg.benchmarks.ttft_ms / 1000).toFixed(2)}s -
-
- Total duration - {(msg.benchmarks.total_duration_ms / 1000).toFixed(2)}s -
-
- Tokens generated - {msg.benchmarks.token_count} -
- {msg.benchmarks.memory_used_gb && ( -
-
- RAM usage - - {msg.benchmarks.memory_used_gb} / {msg.benchmarks.memory_total_gb} GB - - i - - Total system memory in use across all processes, not just the LLM. +

Performance

+
+
+ Time to first token + {(msg.benchmarks.ttft_ms / 1000).toFixed(2)}s +
+
+ Total duration + {(msg.benchmarks.total_duration_ms / 1000).toFixed(2)}s +
+
+ Tokens generated + {msg.benchmarks.token_count} +
+ {msg.benchmarks.memory_used_gb && ( +
+
+ RAM usage + + {msg.benchmarks.memory_used_gb} / {msg.benchmarks.memory_total_gb} GB + + i + + Total system memory in use across all processes, not just the LLM. + - +
-
- )} -
+ )} +
)} @@ -257,15 +368,7 @@ export default function ChatWindow({ messages, loading, onSend, sessionId }) {
- {/* Prompt Template Dialog */} - {showTemplateDialog && ( - { setShowTemplateDialog(false); setShowPlusMenu(false); }} - /> - )} - - {/* Input Form Footer */} + {/* Input Form Footer with Stop button */}
{/* Plus button for prompt templates */} @@ -290,7 +393,7 @@ export default function ChatWindow({ messages, loading, onSend, sessionId }) { )}
- {/* Selected template chip */} + {/* Selected template chip + textarea */}
{selectedTemplate && (
@@ -316,14 +419,33 @@ export default function ChatWindow({ messages, loading, onSend, sessionId }) { />
- + {/* Send or Stop button */} + {isStreaming ? ( + + ) : ( + + )}
+ + {/* Template picker dialog */} + {showTemplateDialog && ( + setShowTemplateDialog(false)} + /> + )} +

@@ -333,4 +455,4 @@ export default function ChatWindow({ messages, loading, onSend, sessionId }) {

); -} +} \ No newline at end of file diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx index 060f30c..bfc4143 100644 --- a/frontend/src/components/Sidebar.jsx +++ b/frontend/src/components/Sidebar.jsx @@ -1,17 +1,114 @@ -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 * as api from "../utils/api"; // ✅ FIXED – use namespace import 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" }, ]; +// 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, +}) { export default function Sidebar({ sessions, currentSession, onNewChat, onLoadSession, onDeleteSession, onClearAllSessions, model, models, onModelChange, language, onLanguageChange }) { 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())); + + 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); + if (refreshSessions) refreshSessions(); + } catch (err) { + console.error("Reorder failed:", err); + setItems(sessions.map((s) => s.id)); + } finally { + setLoadingReorder(false); + } + } + }; + + const orderedSessions = items + .map((id) => sessions.find((s) => s.id === id)) + .filter(Boolean); return (
@@ -24,42 +121,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."}

)} + + + {orderedSessions.map((session) => ( + + ))} + + + {loadingReorder && ( +
Saving order...
+ )} {filtered.map(s => { const isActive = currentSession === s.id; return ( @@ -114,12 +248,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 e389132..7d05ab1 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -36,6 +36,15 @@ 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" }); +// Reorder sessions +export const reorderSessions = async (sessionIds) => { + return req("/sessions/reorder", { + method: "PATCH", + body: JSON.stringify({ session_ids: sessionIds }), + }); +}; + +// Upload document export async function uploadDocument(file, session_id) { const fd = new FormData(); fd.append("file", file); fd.append("session_id", session_id); @@ -44,17 +53,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 || [], d.benchmarks || null); } catch { } } }); @@ -63,4 +81,4 @@ export function streamMessage(body, onToken, onDone) { } return pump(); }); -} +} \ No newline at end of file