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
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
BACKEND_URL=http://localhost:8000
GUARDRAILS_URL = http://localhost:8001
GUARDRAILS_TOKEN =
GUARDRAILS_TOKEN =
Comment thread
Ayush8923 marked this conversation as resolved.
NEXT_PUBLIC_GOOGLE_CLIENT_ID=your-google-client-id.apps.googleusercontent.com
334 changes: 334 additions & 0 deletions app/(main)/chat/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,334 @@
/**
* 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 {
ChatMessage,
LLMCallRequest,
StoredSelection,
} from "@/app/lib/types/chat";

const SELECTION_STORAGE_KEY = "kaapi_chat_selection";

function loadStoredSelection(): StoredSelection | null {
if (typeof window === "undefined") return null;
try {
const raw = window.localStorage.getItem(SELECTION_STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as StoredSelection;
if (parsed && parsed.configId && parsed.version) return parsed;
} catch {
/* ignore */
}
return null;
}

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, setMessages] = useState<ChatMessage[]>([]);
const [draft, setDraft] = useState("");
const [isPending, setIsPending] = useState(false);
const [conversationId, setConversationId] = useState<string | null>(null);
const [configId, setConfigId] = useState("");
const [configVersion, setConfigVersion] = useState(0);
const [showLoginModal, setShowLoginModal] = useState(false);

const abortRef = useRef<AbortController | null>(null);

useEffect(() => {
const stored = loadStoredSelection();
if (stored) {
setConfigId(stored.configId);
setConfigVersion(stored.version);
}
}, []);

useEffect(() => {
if (!configId || !configVersion) return;
try {
window.localStorage.setItem(
SELECTION_STORAGE_KEY,
JSON.stringify({ configId, version: configVersion }),
);
} catch {
/* ignore quota errors */
}
}, [configId, configVersion]);

// Cancel any in-flight poll when leaving the page.
useEffect(() => {
return () => abortRef.current?.abort();
}, []);

const updateMessage = useCallback(
(id: string, patch: Partial<ChatMessage>) => {
setMessages((prev) =>
prev.map((m) => (m.id === id ? { ...m, ...patch } : m)),
);
},
[],
);

const handleNewChat = useCallback(() => {
abortRef.current?.abort();
abortRef.current = null;
setMessages([]);
setConversationId(null);
setIsPending(false);
}, []);

const handleConfigSelect = useCallback(
(newConfigId: string, newVersion: number) => {
const isDifferent =
newConfigId !== configId || newVersion !== configVersion;
setConfigId(newConfigId);
setConfigVersion(newVersion);
if (isDifferent) {
setConversationId(null);
setMessages([]);
}
},
[configId, configVersion],
);

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",
};

setMessages((prev) => [...prev, 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;
updateMessage(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);
}

updateMessage(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") {
updateMessage(assistantMessage.id, {
status: "error",
content: "Cancelled.",
error: "Cancelled",
});
return;
}
const message =
err instanceof Error ? err.message : "Something went wrong";
updateMessage(assistantMessage.id, {
status: "error",
content: message,
error: message,
});
toast.error(message);
} finally {
if (abortRef.current === controller) {
abortRef.current = null;
}
setIsPending(false);
}
},
[
allConfigMeta,
apiKey,
configId,
configVersion,
configs,
conversationId,
isAuthenticated,
loadSingleVersion,
toast,
updateMessage,
],
);

const hasConversation = messages.length > 0;
const hasConfig = !!configId && !!configVersion;

return (
<div className="w-full h-screen flex flex-col bg-bg-secondary">
<div className="flex flex-1 overflow-hidden">
<Sidebar collapsed={sidebarCollapsed} activeRoute="/chat" />

<div className="flex-1 flex flex-col overflow-hidden bg-bg-primary">
<PageHeader
title="Chat"
subtitle="Ask anything - answers come from your selected configuration"
actions={
hasConversation ? (
<button
type="button"
onClick={handleNewChat}
className="px-3 py-1.5 rounded-full text-xs font-medium border border-border bg-bg-primary text-text-primary hover:bg-neutral-50 transition-colors cursor-pointer"
>
New chat
</button>
) : null
}
/>

{!isHydrated ? (
<div className="flex-1" />
) : hasConversation ? (
<ChatMessageList messages={messages} />
) : (
<ChatEmptyState
hasConfig={hasConfig}
isAuthenticated={isAuthenticated}
onSuggestion={(text) => {
if (!isAuthenticated) {
setShowLoginModal(true);
return;
}
sendMessage(text);
}}
/>
)}

<ChatInput
value={draft}
onChange={setDraft}
onSend={() => sendMessage(draft)}
isPending={isPending}
placeholder={
!isAuthenticated
? "Log in to start chatting…"
: !hasConfig
? "Select a configuration to start chatting…"
: "Message your assistant…"
}
trailingAccessory={
isAuthenticated ? (
<ChatConfigPicker
configId={configId}
version={configVersion}
onSelect={handleConfigSelect}
disabled={isPending}
openUp
/>
) : null
}
/>
</div>
</div>

<LoginModal
open={showLoginModal}
onClose={() => setShowLoginModal(false)}
/>
</div>
);
}
2 changes: 1 addition & 1 deletion app/(main)/document/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ export default function DocumentPage() {
/>

<div className="flex-1 overflow-hidden flex bg-bg-secondary">
<div className="w-1/3 border-r overflow-hidden border-[hsl(0,0%,85%)]">
<div className="w-1/3 border-r border-r-status-default-border overflow-hidden">
<DocumentListing
documents={documents}
selectedDocument={selectedDocument}
Expand Down
4 changes: 2 additions & 2 deletions app/(main)/settings/credentials/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ export default function CredentialsPage() {
};

return (
<div className="w-full h-screen flex flex-col bg-bg-secondary">
<div className="w-full h-screen flex flex-col bg-bg-primary">
<div className="flex flex-1 overflow-hidden">
<SettingsSidebar />

Expand All @@ -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"
/>

<div className="flex-1 overflow-y-auto p-8">
Expand Down
2 changes: 1 addition & 1 deletion app/(main)/settings/onboarding/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ export default function OnboardingPage() {
};

return (
<div className="w-full h-screen flex flex-col bg-bg-secondary">
<div className="w-full h-screen flex flex-col bg-bg-primary">
<div className="flex flex-1 overflow-hidden">
<SettingsSidebar />

Expand Down
Loading
Loading