Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
369 lines
12 KiB
TypeScript
369 lines
12 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useRef, useImperativeHandle, forwardRef, useState } from "react";
|
|
import { useAuth } from "@/lib/auth/auth-context";
|
|
import { useChat } from "@/hooks/useChat";
|
|
import { useOrchestratorCommands } from "@/hooks/useOrchestratorCommands";
|
|
import { useWebSocket } from "@/hooks/useWebSocket";
|
|
import { useWorkspaceId } from "@/lib/hooks";
|
|
import { MessageList } from "./MessageList";
|
|
import { ChatInput, type ModelId, DEFAULT_TEMPERATURE, DEFAULT_MAX_TOKENS } from "./ChatInput";
|
|
import { ChatEmptyState } from "./ChatEmptyState";
|
|
import type { Message } from "@/hooks/useChat";
|
|
|
|
export interface ChatRef {
|
|
loadConversation: (conversationId: string) => Promise<void>;
|
|
startNewConversation: (projectId?: string | null) => void;
|
|
getCurrentConversationId: () => string | null;
|
|
}
|
|
|
|
export interface NewConversationData {
|
|
id: string;
|
|
title: string | null;
|
|
project_id: string | null;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
interface ChatProps {
|
|
onConversationChange?: (
|
|
conversationId: string | null,
|
|
conversationData?: NewConversationData
|
|
) => void;
|
|
onProjectChange?: () => void;
|
|
initialProjectId?: string | null;
|
|
onInitialProjectHandled?: () => void;
|
|
}
|
|
|
|
const WAITING_QUIPS = [
|
|
"The AI is warming up... give it a moment.",
|
|
"Loading the neural pathways...",
|
|
"Waking up the LLM. It's not a morning model.",
|
|
"Brewing some thoughts...",
|
|
"The AI is stretching its parameters...",
|
|
"Summoning intelligence from the void...",
|
|
"Teaching electrons to think...",
|
|
"Consulting the silicon oracle...",
|
|
"The hamsters are spinning up the GPU...",
|
|
"Defragmenting the neural networks...",
|
|
];
|
|
|
|
export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
|
|
{
|
|
onConversationChange,
|
|
onProjectChange: _onProjectChange,
|
|
initialProjectId,
|
|
onInitialProjectHandled: _onInitialProjectHandled,
|
|
},
|
|
ref
|
|
) {
|
|
void _onProjectChange;
|
|
void _onInitialProjectHandled;
|
|
|
|
const { user, isLoading: authLoading } = useAuth();
|
|
|
|
// Model and params state — initialized from ChatInput's persisted values
|
|
const [selectedModel, setSelectedModel] = useState<ModelId>("llama3.2");
|
|
const [temperature, setTemperature] = useState<number>(DEFAULT_TEMPERATURE);
|
|
const [maxTokens, setMaxTokens] = useState<number>(DEFAULT_MAX_TOKENS);
|
|
|
|
// Suggestion fill value: controls ChatInput's textarea content
|
|
const [suggestionValue, setSuggestionValue] = useState<string | undefined>(undefined);
|
|
|
|
const {
|
|
messages,
|
|
isLoading: isChatLoading,
|
|
isStreaming,
|
|
error,
|
|
conversationId,
|
|
conversationTitle,
|
|
sendMessage,
|
|
abortStream,
|
|
loadConversation,
|
|
startNewConversation,
|
|
setMessages,
|
|
clearError,
|
|
} = useChat({
|
|
model: selectedModel,
|
|
temperature,
|
|
maxTokens,
|
|
...(initialProjectId !== undefined && { projectId: initialProjectId }),
|
|
});
|
|
|
|
// Read workspace ID from localStorage (set by auth-context after session check).
|
|
// Cookie-based auth (withCredentials) handles authentication, so no explicit
|
|
// token is needed here — pass an empty string as the token placeholder.
|
|
const workspaceId = useWorkspaceId() ?? "";
|
|
const { isConnected: isWsConnected } = useWebSocket(workspaceId, "", {});
|
|
|
|
const { isCommand, executeCommand } = useOrchestratorCommands();
|
|
|
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
|
const [loadingQuip, setLoadingQuip] = useState<string | null>(null);
|
|
const quipTimerRef = useRef<NodeJS.Timeout | null>(null);
|
|
const quipIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
|
|
|
// Identify the streaming message (last assistant message while streaming)
|
|
const streamingMessageId =
|
|
isStreaming && messages.length > 0 ? messages[messages.length - 1]?.id : undefined;
|
|
|
|
// Whether the conversation is empty (only welcome message or no messages)
|
|
const isEmptyConversation =
|
|
messages.length === 0 ||
|
|
(messages.length === 1 && messages[0]?.id === "welcome" && !isChatLoading && !isStreaming);
|
|
|
|
useImperativeHandle(ref, () => ({
|
|
loadConversation: async (cId: string): Promise<void> => {
|
|
await loadConversation(cId);
|
|
},
|
|
startNewConversation: (projectId?: string | null): void => {
|
|
startNewConversation(projectId);
|
|
},
|
|
getCurrentConversationId: (): string | null => conversationId,
|
|
}));
|
|
|
|
const scrollToBottom = useCallback(() => {
|
|
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
scrollToBottom();
|
|
}, [messages, scrollToBottom]);
|
|
|
|
useEffect(() => {
|
|
if (conversationId && conversationTitle) {
|
|
onConversationChange?.(conversationId, {
|
|
id: conversationId,
|
|
title: conversationTitle,
|
|
project_id: initialProjectId ?? null,
|
|
created_at: new Date().toISOString(),
|
|
updated_at: new Date().toISOString(),
|
|
});
|
|
} else {
|
|
onConversationChange?.(null);
|
|
}
|
|
}, [conversationId, conversationTitle, initialProjectId, onConversationChange]);
|
|
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent): void => {
|
|
// Cmd/Ctrl + / : Focus input
|
|
if ((e.ctrlKey || e.metaKey) && e.key === "/") {
|
|
e.preventDefault();
|
|
inputRef.current?.focus();
|
|
}
|
|
// Cmd/Ctrl + N : Start new conversation
|
|
if ((e.ctrlKey || e.metaKey) && (e.key === "n" || e.key === "N")) {
|
|
e.preventDefault();
|
|
startNewConversation(null);
|
|
inputRef.current?.focus();
|
|
}
|
|
// Cmd/Ctrl + L : Clear / start new conversation
|
|
if ((e.ctrlKey || e.metaKey) && (e.key === "l" || e.key === "L")) {
|
|
e.preventDefault();
|
|
startNewConversation(null);
|
|
inputRef.current?.focus();
|
|
}
|
|
};
|
|
document.addEventListener("keydown", handleKeyDown);
|
|
return (): void => {
|
|
document.removeEventListener("keydown", handleKeyDown);
|
|
};
|
|
}, [startNewConversation]);
|
|
|
|
// Show loading quips only during non-streaming load (initial fetch wait)
|
|
useEffect(() => {
|
|
if (isChatLoading && !isStreaming) {
|
|
quipTimerRef.current = setTimeout(() => {
|
|
setLoadingQuip(WAITING_QUIPS[Math.floor(Math.random() * WAITING_QUIPS.length)] ?? null);
|
|
}, 3000);
|
|
|
|
quipIntervalRef.current = setInterval(() => {
|
|
setLoadingQuip(WAITING_QUIPS[Math.floor(Math.random() * WAITING_QUIPS.length)] ?? null);
|
|
}, 5000);
|
|
} else {
|
|
if (quipTimerRef.current) {
|
|
clearTimeout(quipTimerRef.current);
|
|
quipTimerRef.current = null;
|
|
}
|
|
if (quipIntervalRef.current) {
|
|
clearInterval(quipIntervalRef.current);
|
|
quipIntervalRef.current = null;
|
|
}
|
|
setLoadingQuip(null);
|
|
}
|
|
|
|
return (): void => {
|
|
if (quipTimerRef.current) clearTimeout(quipTimerRef.current);
|
|
if (quipIntervalRef.current) clearInterval(quipIntervalRef.current);
|
|
};
|
|
}, [isChatLoading, isStreaming]);
|
|
|
|
const handleSendMessage = useCallback(
|
|
async (content: string) => {
|
|
if (isCommand(content)) {
|
|
// Add user message immediately
|
|
const userMessage: Message = {
|
|
id: `user-${Date.now().toString()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
role: "user",
|
|
content: content.trim(),
|
|
createdAt: new Date().toISOString(),
|
|
};
|
|
setMessages((prev) => [...prev, userMessage]);
|
|
|
|
// Execute orchestrator command
|
|
const result = await executeCommand(content);
|
|
if (result) {
|
|
setMessages((prev) => [...prev, result]);
|
|
}
|
|
return;
|
|
}
|
|
|
|
await sendMessage(content);
|
|
},
|
|
[isCommand, executeCommand, setMessages, sendMessage]
|
|
);
|
|
|
|
const handleSuggestionClick = useCallback((prompt: string): void => {
|
|
setSuggestionValue(prompt);
|
|
// Clear after a tick so input receives it, then focus
|
|
setTimeout(() => {
|
|
inputRef.current?.focus();
|
|
}, 0);
|
|
}, []);
|
|
|
|
if (authLoading) {
|
|
return (
|
|
<div
|
|
className="flex flex-1 items-center justify-center"
|
|
style={{ backgroundColor: "rgb(var(--color-background))" }}
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
<div
|
|
className="h-5 w-5 animate-spin rounded-full border-2 border-t-transparent"
|
|
style={{ borderColor: "rgb(var(--accent-primary))", borderTopColor: "transparent" }}
|
|
/>
|
|
<span style={{ color: "rgb(var(--text-secondary))" }}>Loading...</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className="flex flex-1 flex-col"
|
|
style={{ backgroundColor: "rgb(var(--color-background))" }}
|
|
>
|
|
{/* Connection Status Indicator */}
|
|
{user && !isWsConnected && (
|
|
<div
|
|
className="border-b px-4 py-2"
|
|
style={{
|
|
backgroundColor: "rgb(var(--surface-0))",
|
|
borderColor: "rgb(var(--border-default))",
|
|
}}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<div
|
|
className="h-2 w-2 rounded-full"
|
|
style={{ backgroundColor: "rgb(var(--semantic-warning))" }}
|
|
/>
|
|
<span className="text-sm" style={{ color: "rgb(var(--text-secondary))" }}>
|
|
Reconnecting to server...
|
|
</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Messages Area */}
|
|
<div className="flex-1 overflow-y-auto">
|
|
<div className="mx-auto max-w-4xl px-4 py-6 lg:px-8">
|
|
{isEmptyConversation ? (
|
|
<ChatEmptyState onSuggestionClick={handleSuggestionClick} />
|
|
) : (
|
|
<MessageList
|
|
messages={messages as (Message & { thinking?: string })[]}
|
|
isLoading={isChatLoading}
|
|
isStreaming={isStreaming}
|
|
{...(streamingMessageId != null ? { streamingMessageId } : {})}
|
|
loadingQuip={loadingQuip}
|
|
/>
|
|
)}
|
|
<div ref={messagesEndRef} />
|
|
</div>
|
|
</div>
|
|
|
|
{/* Error Alert */}
|
|
{error && (
|
|
<div className="mx-4 mb-2 lg:mx-auto lg:max-w-4xl lg:px-8">
|
|
<div
|
|
className="flex items-center justify-between rounded-lg border px-4 py-3"
|
|
style={{
|
|
backgroundColor: "rgb(var(--semantic-error-light))",
|
|
borderColor: "rgb(var(--semantic-error) / 0.3)",
|
|
}}
|
|
role="alert"
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
<svg
|
|
className="h-4 w-4 flex-shrink-0"
|
|
style={{ color: "rgb(var(--semantic-error))" }}
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
strokeWidth={2}
|
|
>
|
|
<circle cx="12" cy="12" r="10" />
|
|
<line x1="12" y1="8" x2="12" y2="12" />
|
|
<line x1="12" y1="16" x2="12.01" y2="16" />
|
|
</svg>
|
|
<span className="text-sm" style={{ color: "rgb(var(--semantic-error-dark))" }}>
|
|
{error}
|
|
</span>
|
|
</div>
|
|
<button
|
|
onClick={clearError}
|
|
className="rounded p-1 transition-colors hover:bg-black/5"
|
|
aria-label="Dismiss error"
|
|
>
|
|
<svg
|
|
className="h-4 w-4"
|
|
style={{ color: "rgb(var(--semantic-error))" }}
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
strokeWidth={2}
|
|
>
|
|
<path d="M18 6 6 18M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Input Area */}
|
|
<div
|
|
className="sticky bottom-0 border-t"
|
|
style={{
|
|
backgroundColor: "rgb(var(--surface-0))",
|
|
borderColor: "rgb(var(--border-default))",
|
|
}}
|
|
>
|
|
<div className="mx-auto max-w-4xl px-4 py-4 lg:px-8">
|
|
<ChatInput
|
|
onSend={handleSendMessage}
|
|
disabled={isChatLoading || !user}
|
|
inputRef={inputRef}
|
|
isStreaming={isStreaming}
|
|
onStopStreaming={abortStream}
|
|
onModelChange={setSelectedModel}
|
|
onTemperatureChange={setTemperature}
|
|
onMaxTokensChange={setMaxTokens}
|
|
{...(suggestionValue !== undefined ? { externalValue: suggestionValue } : {})}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
});
|