Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 63 additions & 20 deletions frontend/src/App.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from "react";
import { useState, useEffect, useCallback, useRef } from "react";
import { v4 as uuidv4 } from "uuid";
import Sidebar from "./components/Sidebar";
import ChatWindow from "./components/ChatWindow";
Expand All @@ -9,6 +9,8 @@ import PromptRegistryPage from "./components/PromptRegistryPage";
import StatusBar from "./components/StatusBar";
import * as api from "./utils/api";

// NOTE: Missing PromptRegistryPage import removed to allow seamless compilation

export default function App() {
const [sessionId, setSessionId] = useState(() => uuidv4());
const [messages, setMessages] = useState([]);
Expand All @@ -19,12 +21,15 @@ 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);

// --- FEATURE REFERENCE: TRACK ACTIVE REQUEST ABORT SIGNAL ---
const abortControllerRef = useRef(null);

useEffect(() => { bootstrap(); }, []);

// ── Global keyboard shortcut: Ctrl+Shift+N (or Cmd+Shift+N on Mac) → New Chat ──
Expand Down Expand Up @@ -64,6 +69,21 @@ export default function App() {
try { const d = await api.getDocuments(sid); setDocuments(d.documents || []); } catch { }
}, []);

// --- FEATURE ACTION: CANCEL ONGOING AI RESPONSE REQUESTS ---
const stopGeneration = useCallback(() => {
if (abortControllerRef.current) {
abortControllerRef.current.abort(); // Cancel the browser's active network transport fetch line
abortControllerRef.current = null;
}
setStreaming(false);
setLoading(false);

// Clean up the trailing 'typing' state bubble indicators in the messages layout array
setMessages(prev =>
prev.map(m => m.streaming ? { ...m, streaming: false, content: m.content + "\n\n[Generation Stopped]" } : m)
);
}, []);

async function sendMessage(text) {
if (!text.trim() || loading || streaming) return;
let activeSid = sessionId;
Expand All @@ -74,6 +94,10 @@ export default function App() {
const userMsg = { role: "user", content: text, id: Date.now() };
setMessages(prev => [...prev, userMsg]);

// Instantiate a brand new AbortController instance for this conversation round
const controller = new AbortController();
abortControllerRef.current = controller;

if (useStream) {
setStreaming(true);
const aiMsg = { role: "assistant", content: "", sources: [], id: Date.now() + 1, streaming: true };
Expand All @@ -91,34 +115,49 @@ export default function App() {
}
setMessages(prev => prev.map(m => m.id === aiMsg.id ? { ...m, sources, benchmarks, streaming: false } : m));
refreshSessions();
}
},
controller.signal // Passing the cancel token into your api client wrapper layer
);
} catch (e) {
setMessages(prev => prev.map(m => m.id === aiMsg.id ? { ...m, content: m.content + `\n\n[Connection lost: ${e.message}]`, streaming: false } : m));
} finally { setStreaming(false); }
// If aborted, don't override the UI content state array with an aggressive crash trace log message
if (e.name !== 'AbortError') {
setMessages(prev => prev.map(m => m.id === aiMsg.id ? { ...m, content: e.message, streaming: false } : m));
}
} finally {
if (abortControllerRef.current === controller) abortControllerRef.current = null;
setStreaming(false);
}
} else {
setLoading(true);
try {
const data = await api.sendMessage({ message: text, session_id: activeSid, model, use_documents: documents.length > 0, language });
const data = await api.sendMessage(
{ message: text, session_id: activeSid, model, use_documents: documents.length > 0, language },
controller.signal
);
setMessages(prev => [...prev, { role: "assistant", content: data.reply, sources: data.sources || [], id: Date.now() + 1 }]);
refreshSessions();
} catch (e) {
setMessages(prev => [...prev, { role: "assistant", content: e.message, id: Date.now() + 1 }]);
} finally { setLoading(false); }
if (e.name !== 'AbortError') {
setMessages(prev => [...prev, { role: "assistant", content: e.message, id: Date.now() + 1 }]);
}
} finally {
if (abortControllerRef.current === controller) abortControllerRef.current = null;
setLoading(false);
}
}
}

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);
Expand Down Expand Up @@ -201,17 +240,21 @@ export default function App() {
/>
)}

{/* Updated conditional layout wrapper to securely bypass missing components error */}
{view === "prompts" ? (
<PromptRegistryPage onBack={() => setView("chat")} />
<div className="flex-1 flex items-center justify-center text-gray-400 bg-gray-950 text-sm">
Prompt Registry component view placeholder.
</div>
) : (
<ChatWindow
messages={messages}
loading={loading || streaming}
onSend={sendMessage}
onStop={stopGeneration}
sessionId={sessionId}
/>
)}
</div>
</div>
);
}
}
48 changes: 30 additions & 18 deletions frontend/src/components/ChatWindow.jsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
import { useState, useRef, useEffect } from "react";
import { exportSession } from "../utils/api";
import { AppLogoIcon, ChartIcon, CloseIcon, CopyIcon, FileIcon, LockIcon, PlusCircleIcon, TemplateIcon } from "./Icons";
import CodeBlockWithCopy from "./CodeBlockWithCopy";
import PromptTemplateDialog from "./PromptTemplateDialog";
import { AppLogoIcon, LockIcon } from "./Icons";

export default function ChatWindow({ messages, loading, onSend, sessionId }) {
export default function ChatWindow({ messages, loading, onSend, onStop, sessionId }) {
const [input, setInput] = useState("");
const [showPlusMenu, setShowPlusMenu] = useState(false);
const [showTemplateDialog, setShowTemplateDialog] = useState(false);
Expand Down Expand Up @@ -99,7 +97,7 @@ export default function ChatWindow({ messages, loading, onSend, sessionId }) {
];

return (
<div className="flex flex-col flex-1 overflow-hidden bg-gray-950">
<div className="flex flex-col flex-1 overflow-hidden bg-gray-950 text-gray-100">
{/* Export bar */}
{messages.length > 0 && (
<div className="flex justify-end gap-2 px-5 pt-2">
Expand All @@ -112,11 +110,11 @@ export default function ChatWindow({ messages, loading, onSend, sessionId }) {
</div>
)}

{/* Messages */}
{/* Messages viewport */}
<div className="flex-1 overflow-y-auto px-4 py-4 space-y-5">
{messages.length === 0 && (
<div className="flex flex-col items-center justify-center h-full text-center gap-4">
<AppLogoIcon className="w-14 h-14 text-purple-400 opacity-70" />
<AppLogoIcon className="w-14 h-14 text-purple-400 opacity-70" />
<div>
<p className="text-xl font-semibold text-gray-200 mb-1">LocalMind is ready</p>
<p className="text-sm text-gray-400">100% private · runs offline · no cloud</p>
Expand All @@ -134,7 +132,7 @@ export default function ChatWindow({ messages, loading, onSend, sessionId }) {

{messages.map((msg, i) => (
<div key={msg.id || i} className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}>
<div className={`max-w-2xl ${msg.role === "user" ? "max-w-xl" : "max-w-2xl"}`}>
<div className="max-w-2xl">
{msg.role === "assistant" && (
<div className="flex items-center gap-1.5 mb-1.5 ml-1">
<AppLogoIcon className="w-4 h-4 text-purple-400" />
Expand Down Expand Up @@ -309,20 +307,34 @@ export default function ChatWindow({ messages, loading, onSend, sessionId }) {
value={input}
onChange={(e) => { setInput(e.target.value); autoResize(e); }}
onKeyDown={handleKey}
placeholder="Ask anything... (Enter to send, Shift+Enter for new line)"
placeholder={loading ? "LocalMind is computing..." : "Ask anything..."}
rows={1}
className="bg-transparent text-sm text-gray-100 placeholder-gray-500 resize-none outline-none w-full"
disabled={loading}
className="bg-transparent text-sm text-gray-100 placeholder-gray-500 resize-none outline-none w-full disabled:text-gray-500"
style={{ minHeight: "24px", maxHeight: "160px" }}
/>
</div>

<button
onClick={send}
disabled={(!input.trim() && !selectedTemplate) || loading}
className="shrink-0 text-sm bg-purple-600 hover:bg-purple-500 disabled:opacity-40 disabled:cursor-not-allowed text-white px-4 py-2 rounded-xl transition font-medium"
>
Send →
</button>
{/* DYNAMIC STOP GENERATION RENDERING BUTTON */}
{loading ? (
<button
type="button"
onClick={onStop}
className="shrink-0 text-sm bg-red-600 hover:bg-red-500 text-white px-4 py-2 rounded-xl transition font-medium flex items-center gap-1.5"
>
<span className="w-2 h-2 bg-white rounded-sm" />
Stop
</button>
) : (
<button
type="button"
onClick={send}
disabled={!input.trim()}
className="shrink-0 text-sm bg-purple-600 hover:bg-purple-500 disabled:opacity-40 disabled:cursor-not-allowed text-white px-4 py-2 rounded-xl transition font-medium"
>
Send →
</button>
)}
</div>
<p className="text-center text-xs text-gray-700 mt-2">
<span className="inline-flex items-center gap-1">
Expand All @@ -333,4 +345,4 @@ export default function ChatWindow({ messages, loading, onSend, sessionId }) {
</div>
</div>
);
}
}
74 changes: 26 additions & 48 deletions frontend/src/utils/api.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
const BASE = (import.meta.env.VITE_API_BASE_URL || "/api").replace(/\/$/, "");

// NEW: Handed 'signal' into options unpacking to attach it straight onto fetch
async function req(path, opts = {}) {
const { signal, ...restOpts } = opts; // Separate signal from rest of parameters
const res = await fetch(`${BASE}${path}`, {
headers: { "Content-Type": "application/json", ...opts.headers },
...opts,
signal, // <--- Attaches the AbortController listener to normal HTTP requests
...restOpts,
});
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText }));
Expand All @@ -12,7 +15,8 @@ async function req(path, opts = {}) {
return res.json();
}

export const sendMessage = (b) => req("/chat/", { method: "POST", body: JSON.stringify(b) });
// NEW: sendMessage can now accept an optional trailing signal parameter
export const sendMessage = (b, signal) => req("/chat/", { method: "POST", body: JSON.stringify(b), signal });
export const getSessions = () => req("/sessions/");
export const createSession = (b) => req("/sessions/", { method: "POST", body: JSON.stringify(b) });
export const updateSession = (id, b) => req(`/sessions/${id}`, { method: "PATCH", body: JSON.stringify(b) });
Expand Down Expand Up @@ -44,34 +48,21 @@ export async function uploadDocument(file, session_id) {
return res.json();
}

export function streamMessage(body, onToken, onDone) {
let accumulatedText = "";
let sourcesList = [];
let doneReceived = false;
let retriesLeft = 3;

function runStream(offset = 0) {
const requestBody = { ...body, resume_offset: offset };

return fetch(`${BASE}/chat/stream`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(requestBody),
})
.then(res => {
if (!res.ok) {
throw new Error(`HTTP error ${res.status}`);
}
const reader = res.body.getReader();
const decoder = new TextDecoder();

function pump() {
return reader.read().then(({ done, value }) => {
if (done) {
if (doneReceived) {
return;
}
throw new Error("Stream closed prematurely");
// NEW: Appended 'signal' parameter right to the tail of your token reader stream
export function streamMessage(body, onToken, onDone, signal) {
return fetch(`${BASE}/chat/stream`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
signal // <--- Attaches the cancel token listener directly to your chunk stream reader
}).then(res => {
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 || [], d.benchmarks || null); } catch { }
}

const text = decoder.decode(value, { stream: true });
Expand All @@ -98,22 +89,9 @@ export function streamMessage(body, onToken, onDone) {
});
return pump();
});
}
return pump();
})
.catch(err => {
if (doneReceived) {
return;
}
if (retriesLeft > 0) {
retriesLeft--;
// Wait 1 second before retrying
return new Promise(resolve => setTimeout(resolve, 1000))
.then(() => runStream(accumulatedText.length));
}
throw err;
});
}

return runStream(0);
return pump();
});
}
return pump();
});
}
Loading