Skip to content
Merged
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
1 change: 1 addition & 0 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion frontend/src/app/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ export interface DocInfo {
}

export default function DashboardPage() {
const { user, loading, initialized } = useAuth();
const { user, initialized } = useAuth();
const router = useRouter();

const [documents, setDocuments] = useState<DocInfo[]>([]);
Expand Down
1 change: 0 additions & 1 deletion frontend/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import { AuthProvider } from "@/lib/auth";
import { TooltipProvider } from "@/components/ui/tooltip";
import I18nProvider from "@/components/providers/I18nProvider";
import { ThemeProvider } from "@/components/layout/ThemeProvider";
import { Toaster } from "sonner";

const inter = Inter({
variable: "--font-sans",
Expand Down
3 changes: 0 additions & 3 deletions frontend/src/app/register/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import { Brain } from "lucide-react";
import Link from "next/link";
import GoogleSignInButton from "@/components/auth/GoogleSignInButton";
import { PasswordField } from "@/components/auth/PasswordField";
import { isPasswordValid } from "@/lib/password-validation";
import HuggingFaceSignInButton from "@/components/auth/HuggingFaceSignInButton";

export default function RegisterPage() {
Expand All @@ -27,8 +26,6 @@ export default function RegisterPage() {
const [verificationUrl, setVerificationUrl] = useState("");
const [loading, setLoading] = useState(false);

const passwordValid = isPasswordValid(password);
const canSubmit = username.trim().length >= 3 && email.trim().length > 0 && passwordValid && !loading;
// Redirect if already logged in
useEffect(() => {
if (initialized && user) {
Expand Down
66 changes: 54 additions & 12 deletions frontend/src/components/chat/ChatPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -184,10 +184,17 @@ export default function ChatPanel({ activeDoc, onCitationClick }: Props) {
cancelled = true;
};
}, [activeSessionId, activeDoc, fetchSessionHistory, setMessages]);

const handleStop = () => {
abortControllerRef.current?.abort();
setStreaming(false);
setIsTyping(false);
};

const handleSend = async () => {
if (!input.trim() || streaming) return;


const question = input.trim();
setInput("");

Expand Down Expand Up @@ -218,7 +225,19 @@ export default function ChatPanel({ activeDoc, onCitationClick }: Props) {

const ws = new WebSocket(wsUrl);

let onAbort: (() => void) | null = null;

const wsDone = new Promise<void>((resolve, reject) => {
onAbort = () => {
try {
ws.close();
} catch {
// ignore
}
reject(new DOMException("The user aborted a request.", "AbortError"));
};
abortController.signal.addEventListener("abort", onAbort);

ws.onopen = () => {
// Send initial payload
ws.send(JSON.stringify({ question, document_id: activeDoc?.id || null, session_id: activeSessionId }));
Expand All @@ -228,7 +247,7 @@ export default function ChatPanel({ activeDoc, onCitationClick }: Props) {
const connectTimeout = setTimeout(() => {
try {
ws.close();
} catch (e) {
} catch {
// ignore
}
reject(new Error("WebSocket connection timeout"));
Expand Down Expand Up @@ -275,12 +294,12 @@ export default function ChatPanel({ activeDoc, onCitationClick }: Props) {
ws.close();
resolve();
}
} catch (err) {
} catch {
// ignore malformed messages
}
};

ws.onerror = (ev) => {
ws.onerror = () => {
clearTimeout(connectTimeout);
reject(new Error("WebSocket error"));
};
Expand All @@ -290,15 +309,28 @@ export default function ChatPanel({ activeDoc, onCitationClick }: Props) {
};
});

await wsDone;
try {
await wsDone;
} finally {
if (onAbort) {
abortController.signal.removeEventListener("abort", onAbort);
}
}
} catch (err) {
if (
err instanceof Error &&
(err.name === "AbortError" || err.message === "The user aborted a request.")
) {
return;
}

// Fallback to existing SSE stream if WebSocket fails
try {
const stream = api.streamPost("/api/v1/chat/ask/stream", {
question,
document_id: activeDoc?.id || null,
session_id: activeSessionId,
});
}, abortController.signal);

for await (const event of stream) {
if (event.type === "token") {
Expand Down Expand Up @@ -331,6 +363,17 @@ export default function ChatPanel({ activeDoc, onCitationClick }: Props) {
}
} catch (err2) {
setIsTyping(false);
if (
err2 instanceof Error &&
(err2.name === "AbortError" || err2.message === "The user aborted a request.")
) {
setMessages((prev) =>
prev.map((m) =>
m.id === assistantId ? { ...m, isStreaming: false } : m
)
);
return;
}
setMessages((prev) =>
prev.map((m) =>
m.id === assistantId
Expand Down Expand Up @@ -538,11 +581,9 @@ export default function ChatPanel({ activeDoc, onCitationClick }: Props) {

// Shortcut 2: Escape β†’ Abort SSE stream OR clear input OR close modal
if (e.key === "Escape") {
if (streaming && abortControllerRef.current) {
if (streaming) {
e.preventDefault();
abortControllerRef.current.abort();
setStreaming(false);
setIsTyping(false);
handleStop();
toast.info("Response cancelled");
} else if (document.activeElement === textareaRef.current) {
e.preventDefault();
Expand Down Expand Up @@ -593,6 +634,7 @@ export default function ChatPanel({ activeDoc, onCitationClick }: Props) {
return () => {
window.removeEventListener("keydown", handleGlobalKeyDown);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [input, streaming, showHelpModal, showExportMenu, messages]); // Dependencies updated to capture fresh state data

return (
Expand Down Expand Up @@ -801,10 +843,10 @@ export default function ChatPanel({ activeDoc, onCitationClick }: Props) {
<Button
id="send-btn"
size="icon"
onClick={handleSend}
disabled={!input.trim() || streaming}
onClick={streaming ? handleStop : handleSend}
disabled={!streaming && !input.trim()}
className="h-10 w-10 sm:h-[44px] sm:w-[44px]"
aria-label={streaming ? "Sending message" : "Send message"}
aria-label={streaming ? "Stop generating" : "Send message"}
>
{streaming ? (
<Loader2 className="w-4 h-4 animate-spin" />
Expand Down
77 changes: 64 additions & 13 deletions frontend/src/components/layout/Header.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { useState } from "react";
import { useEffect, useState, useSyncExternalStore } from "react";
import Link from "next/link";
import { useAuth } from "@/lib/auth";
import { useRouter } from "next/navigation";
Expand All @@ -13,12 +13,6 @@ import {
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
} from "@/components/ui/dropdown-menu";
import {
Brain,
Expand All @@ -29,17 +23,21 @@ import {
LogOut,
Menu,
X,
Palette,
Briefcase,
ChevronDown,
Sun,
Moon
Moon,
Settings,
} from "lucide-react";
import { useTheme } from "next-themes";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useWorkspaceStore, WORKSPACES, type WorkspaceId } from "@/store/workspace-store";
import { api } from "@/lib/api";
import { useTheme } from "next-themes";

import { useSyncExternalStore } from "react";

interface HeaderProps {
sidebarOpen: boolean;
Expand All @@ -64,7 +62,19 @@ export default function Header({
const { user, logout } = useAuth();
const router = useRouter();
const { theme, setTheme } = useTheme();
const mounted = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
const mounted = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); // ← replaces useState + useEffect
const [settingsOpen, setSettingsOpen] = useState(false);
const [temperature, setTemperature] = useState(() => {
if (typeof window !== "undefined") {
const saved = localStorage.getItem("temperature");
return saved ? Number(saved) : 0.5;
}
return 0.5;
});

useEffect(() => {
localStorage.setItem("temperature", temperature.toString());
}, [temperature]);
const [sheetOpen, setSheetOpen] = useState(false);
const [workspaceLoading, setWorkspaceLoading] = useState(false);
const workspace = useWorkspaceStore((s) => s.workspace);
Expand Down Expand Up @@ -157,6 +167,16 @@ export default function Header({
<PanelRightOpen className="w-4 h-4" />
)}
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => setSettingsOpen(true)}
title="LLM Settings"
aria-label="Open LLM settings"
>
<Settings className="w-4 h-4" />
</Button>

{mounted && (
<Button
Expand Down Expand Up @@ -281,6 +301,37 @@ export default function Header({

<div className="flex-1 overflow-hidden">{sheetOpen ? mobileSheetContent : null}</div>
</aside>

<Dialog
open={settingsOpen}
onOpenChange={setSettingsOpen}
>
<DialogContent>
<DialogHeader>
<DialogTitle>
LLM Settings
</DialogTitle>
</DialogHeader>

<div className="space-y-4">
<label className="text-sm">
Temperature: {temperature}
</label>

<input
type="range"
min="0"
max="1"
step="0.1"
value={temperature}
onChange={(e) =>
setTemperature(Number(e.target.value))
}
className="w-full"
/>
</div>
</DialogContent>
</Dialog>
</>
);
}
6 changes: 4 additions & 2 deletions frontend/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -313,21 +313,23 @@ class ApiClient {
* Stream a POST request as Server-Sent Events.
* Yields parsed SSE data objects.
*/
async *streamPost(path: string, body: unknown): AsyncGenerator<{ type: string; data?: unknown }> {
async *streamPost(path: string, body: unknown, signal?: AbortSignal): AsyncGenerator<{ type: string; data?: unknown }> {
let res = await this.fetchWithConnectionError(`${this.baseUrl}${path}`, {
method: "POST",
headers: this.getHeaders(),
body: JSON.stringify(body),
signal,
});

// Auto-refresh on 401
if (res.status === 401) {
const newToken = await this.tryRefreshToken();
if (newToken) {
res = await this.fetchWithConnectionError(`${this.baseUrl}${path}`, {
res = await this.fetchWithConnectionError(`${this.baseUrl}${path}`, {
method: "POST",
headers: this.getHeaders(newToken),
body: JSON.stringify(body),
signal,
});
}
}
Expand Down
Loading