Files
stack/apps/web/src/components/chat/Chat.tsx
Jason Woltje c45cec3bba
Some checks failed
ci/woodpecker/push/ci Pipeline failed
feat(chat): add guest chat mode for unauthenticated users
- Add POST /api/chat/guest endpoint (no auth required)
- Add proxyGuestChat() method using configurable LLM endpoint
- Add streamGuestChat() function to frontend chat API
- Modify useChat to fall back to guest mode on auth errors (403/401)
- Remove !user check from ChatInput disabled prop
- Configure guest LLM via env vars: GUEST_LLM_URL, GUEST_LLM_API_KEY, GUEST_LLM_MODEL
- Default guest LLM: http://10.1.1.42:11434/v1 (Ollama) with llama3.2 model
2026-03-03 11:16:23 -06:00

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}
inputRef={inputRef}
isStreaming={isStreaming}
onStopStreaming={abortStream}
onModelChange={setSelectedModel}
onTemperatureChange={setTemperature}
onMaxTokensChange={setMaxTokens}
{...(suggestionValue !== undefined ? { externalValue: suggestionValue } : {})}
/>
</div>
</div>
</div>
);
});