diff --git a/.env.example b/.env.example index cf493c5..09d3f15 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,4 @@ BACKEND_URL=http://localhost:8000 GUARDRAILS_URL = http://localhost:8001 -GUARDRAILS_TOKEN = +GUARDRAILS_TOKEN = NEXT_PUBLIC_GOOGLE_CLIENT_ID=your-google-client-id.apps.googleusercontent.com diff --git a/app/(main)/chat/page.tsx b/app/(main)/chat/page.tsx new file mode 100644 index 0000000..5293579 --- /dev/null +++ b/app/(main)/chat/page.tsx @@ -0,0 +1,299 @@ +/** + * Chat - conversational interface. + */ + +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import Sidebar from "@/app/components/Sidebar"; +import PageHeader from "@/app/components/PageHeader"; +import { useApp } from "@/app/lib/context/AppContext"; +import { useAuth } from "@/app/lib/context/AuthContext"; +import { useToast } from "@/app/components/Toast"; +import { LoginModal } from "@/app/components/auth"; +import { + ChatConfigPicker, + ChatEmptyState, + ChatInput, + ChatMessageList, +} from "@/app/components/chat"; +import { useConfigs } from "@/app/hooks"; +import { + configToBlob, + createLLMCall, + extractAssistantText, + pollLLMCall, +} from "@/app/lib/chatClient"; +import { useChatStore } from "@/app/lib/store/chat"; +import { ChatMessage, LLMCallRequest } from "@/app/lib/types/chat"; + +function genId() { + if (typeof crypto !== "undefined" && "randomUUID" in crypto) { + return crypto.randomUUID(); + } + return `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; +} + +export default function ChatPage() { + const { sidebarCollapsed } = useApp(); + const { isAuthenticated, activeKey, isHydrated } = useAuth(); + const apiKey = activeKey?.key ?? ""; + const toast = useToast(); + const { configs, loadSingleVersion, allConfigMeta } = useConfigs({ + pageSize: 0, + }); + + const messages = useChatStore((s) => s.messages); + const conversationId = useChatStore((s) => s.conversationId); + const configId = useChatStore((s) => s.configId); + const configVersion = useChatStore((s) => s.configVersion); + const chatHydrated = useChatStore((s) => s.hasHydrated); + const appendMessages = useChatStore((s) => s.appendMessages); + const updateMessageInStore = useChatStore((s) => s.updateMessage); + const setConversationId = useChatStore((s) => s.setConversationId); + const setConfig = useChatStore((s) => s.setConfig); + const clearConversation = useChatStore((s) => s.clearConversation); + + const [draft, setDraft] = useState(""); + const [isPending, setIsPending] = useState(false); + const [showLoginModal, setShowLoginModal] = useState(false); + + const abortRef = useRef(null); + + // Trigger persisted-state rehydration on mount. + useEffect(() => { + useChatStore.persist.rehydrate(); + }, []); + + // Cancel any in-flight poll when leaving the page. + useEffect(() => { + return () => abortRef.current?.abort(); + }, []); + + const handleNewChat = useCallback(() => { + abortRef.current?.abort(); + abortRef.current = null; + clearConversation(); + setIsPending(false); + }, [clearConversation]); + + const handleConfigSelect = useCallback( + (newConfigId: string, newVersion: number) => { + const isDifferent = + newConfigId !== configId || newVersion !== configVersion; + setConfig(newConfigId, newVersion); + if (isDifferent) { + clearConversation(); + } + }, + [clearConversation, configId, configVersion, setConfig], + ); + + const sendMessage = useCallback( + async (text: string) => { + const trimmed = text.trim(); + if (!trimmed) return; + + if (!isAuthenticated) { + setShowLoginModal(true); + return; + } + if (!configId || !configVersion) { + if (allConfigMeta.length === 0) { + toast.error( + "No configurations yet — create one in Configurations → Prompt Editor first.", + ); + } else { + toast.error("Select a configuration before sending a message."); + } + return; + } + + const userMessage: ChatMessage = { + id: genId(), + role: "user", + content: trimmed, + createdAt: Date.now(), + status: "complete", + }; + const assistantMessage: ChatMessage = { + id: genId(), + role: "assistant", + content: "", + createdAt: Date.now(), + status: "pending", + }; + + appendMessages(userMessage, assistantMessage); + setDraft(""); + setIsPending(true); + + const controller = new AbortController(); + abortRef.current?.abort(); + abortRef.current = controller; + + try { + const cached = configs.find( + (c) => c.config_id === configId && c.version === configVersion, + ); + const fullConfig = + cached ?? (await loadSingleVersion(configId, configVersion)); + if (!fullConfig) { + throw new Error( + "Couldn't load the selected configuration. Try picking it again.", + ); + } + + const payload: LLMCallRequest = { + query: { + input: trimmed, + conversation: conversationId + ? { id: conversationId } + : { auto_create: true }, + }, + config: { blob: configToBlob(fullConfig) }, + include_provider_raw_response: true, + }; + + const created = await createLLMCall(payload, apiKey); + if (!created.success || !created.data?.job_id) { + throw new Error(created.error || "Failed to start the request"); + } + const jobId = created.data.job_id; + updateMessageInStore(assistantMessage.id, { jobId }); + + const result = await pollLLMCall(jobId, apiKey, { + signal: controller.signal, + }); + + const text = extractAssistantText(result.llm_response?.response); + const newConversationId = + result.llm_response?.response?.conversation_id ?? conversationId; + if (newConversationId && newConversationId !== conversationId) { + setConversationId(newConversationId); + } + + updateMessageInStore(assistantMessage.id, { + content: + text || + "(The assistant returned an empty response — try again or pick a different configuration.)", + status: "complete", + }); + } catch (err) { + if ((err as Error)?.name === "AbortError") { + updateMessageInStore(assistantMessage.id, { + status: "error", + content: "Cancelled.", + error: "Cancelled", + }); + return; + } + const message = + err instanceof Error ? err.message : "Something went wrong"; + updateMessageInStore(assistantMessage.id, { + status: "error", + content: message, + error: message, + }); + toast.error(message); + } finally { + if (abortRef.current === controller) { + abortRef.current = null; + } + setIsPending(false); + } + }, + [ + allConfigMeta, + apiKey, + appendMessages, + configId, + configVersion, + configs, + conversationId, + isAuthenticated, + loadSingleVersion, + setConversationId, + toast, + updateMessageInStore, + ], + ); + + const hasConversation = messages.length > 0; + const hasConfig = !!configId && !!configVersion; + const isReady = isHydrated && chatHydrated; + + return ( +
+
+ + +
+ + New chat + + ) : null + } + /> + + {!isReady ? ( +
+ ) : hasConversation ? ( + + ) : ( + { + if (!isAuthenticated) { + setShowLoginModal(true); + return; + } + sendMessage(text); + }} + /> + )} + + sendMessage(draft)} + isPending={isPending} + placeholder={ + !isAuthenticated + ? "Log in to start chatting…" + : !hasConfig + ? "Select a configuration to start chatting…" + : "Message your assistant…" + } + trailingAccessory={ + isAuthenticated ? ( + + ) : null + } + /> +
+
+ + setShowLoginModal(false)} + /> +
+ ); +} diff --git a/app/(main)/document/page.tsx b/app/(main)/document/page.tsx index 368547e..ac1b5a6 100644 --- a/app/(main)/document/page.tsx +++ b/app/(main)/document/page.tsx @@ -191,7 +191,7 @@ export default function DocumentPage() { />
-
+
+
@@ -192,7 +192,7 @@ export default function CredentialsPage() { selectedProvider={selectedProvider} credentials={credentials} onSelect={setSelectedProvider} - className="w-56 border-r border-border overflow-y-auto bg-bg-secondary" + className="w-56 border-r border-border overflow-y-auto bg-bg-primary" />
diff --git a/app/(main)/settings/onboarding/page.tsx b/app/(main)/settings/onboarding/page.tsx index b7e2197..d1d4eb5 100644 --- a/app/(main)/settings/onboarding/page.tsx +++ b/app/(main)/settings/onboarding/page.tsx @@ -81,6 +81,7 @@ export default function OnboardingPage() { isLoadingMore, hasMore, loadMore, + refetch: refetchOrganizations, } = usePaginatedList({ endpoint: "/api/organization", limit: DEFAULT_PAGE_LIMIT, @@ -147,6 +148,17 @@ export default function OnboardingPage() { setView("success"); }; + const handleOnboardAnother = () => { + setOnboardData(null); + setView("form"); + }; + + const handleBackToOrgsFromSuccess = () => { + setOnboardData(null); + refetchOrganizations(); + setView("list"); + }; + const handleSelectProject = (project: Project) => { setSelectedProject(project); setView("users"); @@ -165,7 +177,7 @@ export default function OnboardingPage() { }; return ( -
+
@@ -293,7 +305,11 @@ export default function OnboardingPage() { />
- + )}
diff --git a/app/api/llm/call/[job_id]/route.ts b/app/api/llm/call/[job_id]/route.ts new file mode 100644 index 0000000..4486e29 --- /dev/null +++ b/app/api/llm/call/[job_id]/route.ts @@ -0,0 +1,25 @@ +import { NextResponse } from "next/server"; +import { apiClient } from "@/app/lib/apiClient"; + +export async function GET( + request: Request, + { params }: { params: Promise<{ job_id: string }> }, +) { + const { job_id } = await params; + try { + const { status, data } = await apiClient( + request, + `/api/v1/llm/call/${job_id}`, + ); + return NextResponse.json(data, { status }); + } catch (error) { + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : String(error), + data: null, + }, + { status: 500 }, + ); + } +} diff --git a/app/api/llm/call/route.ts b/app/api/llm/call/route.ts new file mode 100644 index 0000000..33c5b37 --- /dev/null +++ b/app/api/llm/call/route.ts @@ -0,0 +1,27 @@ +/** + * The browser polls the resulting job via GET /api/llm/call/{job_id} until it + * completes. + */ + +import { NextResponse } from "next/server"; +import { apiClient } from "@/app/lib/apiClient"; + +export async function POST(request: Request) { + try { + const body = await request.json(); + const { status, data } = await apiClient(request, "/api/v1/llm/call", { + method: "POST", + body: JSON.stringify(body), + }); + return NextResponse.json(data, { status }); + } catch (error) { + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : String(error), + data: null, + }, + { status: 500 }, + ); + } +} diff --git a/app/components/Button.tsx b/app/components/Button.tsx index c36032a..dc8238e 100644 --- a/app/components/Button.tsx +++ b/app/components/Button.tsx @@ -1,6 +1,6 @@ import { ButtonHTMLAttributes, ReactNode } from "react"; -type ButtonVariant = "primary" | "outline" | "ghost" | "danger"; +type ButtonVariant = "primary" | "secondary" | "outline" | "ghost" | "danger"; type ButtonSize = "sm" | "md" | "lg"; interface ButtonProps extends ButtonHTMLAttributes { @@ -16,6 +16,10 @@ const variantStyles: Record = base: "bg-accent-primary text-white hover:bg-accent-hover", disabled: "bg-neutral-200 text-text-secondary cursor-not-allowed", }, + secondary: { + base: "bg-accent-secondary text-text-primary hover:bg-accent-secondary-hover", + disabled: "bg-neutral-200 text-text-secondary cursor-not-allowed", + }, outline: { base: "bg-white text-text-primary border border-border hover:bg-neutral-50", disabled: diff --git a/app/components/PageHeader.tsx b/app/components/PageHeader.tsx index 2d13227..3a733af 100644 --- a/app/components/PageHeader.tsx +++ b/app/components/PageHeader.tsx @@ -32,13 +32,15 @@ export default function PageHeader({ <>
- + {sidebarCollapsed && ( + + )} {children ?? (
{title && ( diff --git a/app/components/Sidebar.tsx b/app/components/Sidebar.tsx index 9fcd585..db14326 100644 --- a/app/components/Sidebar.tsx +++ b/app/components/Sidebar.tsx @@ -9,6 +9,7 @@ import React, { useState, useEffect, useRef, useCallback } from "react"; import { useRouter } from "next/navigation"; import Image from "next/image"; import { useAuth } from "@/app/lib/context/AuthContext"; +import { useApp } from "@/app/lib/context/AppContext"; import { ClipboardIcon, DocumentFileIcon, @@ -17,6 +18,8 @@ import { SlidersIcon, ShieldCheckIcon, ChevronRightIcon, + ChevronLeftIcon, + ChatIcon, } from "@/app/components/icons"; import { LoginModal } from "@/app/components/auth"; import { Branding, UserMenuPopover } from "@/app/components/user-menu"; @@ -24,15 +27,15 @@ import GatePopover from "@/app/components/GatePopover"; import { NAV_ITEMS } from "@/app/lib/navConfig"; import { MenuItem, SidebarProps } from "@/app/lib/types/nav"; -/** Routes that are always accessible without auth */ -const PUBLIC_ROUTES = new Set(["/evaluations"]); +const PUBLIC_ROUTES = new Set(["/", "/chat"]); export default function Sidebar({ collapsed, - activeRoute = "/evaluations", + activeRoute = "/chat", }: SidebarProps) { const router = useRouter(); const { currentUser, googleProfile, isAuthenticated, logout } = useAuth(); + const { setSidebarCollapsed } = useApp(); const [expandedMenus, setExpandedMenus] = useState>({ Evaluations: true, Configurations: false, @@ -109,6 +112,7 @@ export default function Sidebar({ }; const iconMap: Record = { + chat: , clipboard: , document: , book: , @@ -145,9 +149,18 @@ export default function Sidebar({ return (