feat: add chat components from jarvis frontend
- Migrated Chat.tsx with message handling and UI structure - Migrated ChatInput.tsx with character limits and keyboard shortcuts - Migrated MessageList.tsx with thinking/reasoning display - Migrated ConversationSidebar.tsx (simplified placeholder) - Migrated BackendStatusBanner.tsx (simplified placeholder) - Created components/chat/index.ts barrel export - Created app/chat/page.tsx placeholder route These components are adapted from jarvis-fe but not yet fully functional: - API calls placeholder (need to wire up /api/brain/query) - Auth hooks stubbed (need useAuth implementation) - Project/conversation hooks stubbed (need implementation) - Imports changed from @jarvis/* to @mosaic/* Next steps: - Implement missing hooks (useAuth, useProjects, useConversations, useApi) - Wire up backend API endpoints - Add proper TypeScript types - Implement full conversation management
This commit is contained in:
99
apps/web/src/app/chat/page.tsx
Normal file
99
apps/web/src/app/chat/page.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { Chat, type ChatRef, ConversationSidebar, type ConversationSidebarRef } from "@/components/chat";
|
||||
|
||||
/**
|
||||
* Chat Page
|
||||
*
|
||||
* Placeholder route for the chat interface migrated from jarvis-fe.
|
||||
*
|
||||
* TODO:
|
||||
* - Integrate with authentication
|
||||
* - Connect to brain API endpoints (/api/brain/query)
|
||||
* - Implement conversation persistence
|
||||
* - Add project/workspace integration
|
||||
* - Wire up actual hooks (useAuth, useProjects, useConversations, useApi)
|
||||
*/
|
||||
export default function ChatPage() {
|
||||
const chatRef = useRef<ChatRef>(null);
|
||||
const sidebarRef = useRef<ConversationSidebarRef>(null);
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [currentConversationId, setCurrentConversationId] = useState<string | null>(null);
|
||||
|
||||
const handleConversationChange = (conversationId: string | null) => {
|
||||
setCurrentConversationId(conversationId);
|
||||
// TODO: Update sidebar when conversation changes
|
||||
};
|
||||
|
||||
const handleSelectConversation = (conversationId: string | null) => {
|
||||
// TODO: Load conversation from backend
|
||||
console.log("Select conversation:", conversationId);
|
||||
setCurrentConversationId(conversationId);
|
||||
};
|
||||
|
||||
const handleNewConversation = (projectId?: string | null) => {
|
||||
chatRef.current?.startNewConversation(projectId);
|
||||
setCurrentConversationId(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden" style={{ backgroundColor: "rgb(var(--color-background))" }}>
|
||||
{/* Conversation Sidebar */}
|
||||
<ConversationSidebar
|
||||
ref={sidebarRef}
|
||||
isOpen={sidebarOpen}
|
||||
onClose={() => setSidebarOpen(!sidebarOpen)}
|
||||
currentConversationId={currentConversationId}
|
||||
onSelectConversation={handleSelectConversation}
|
||||
onNewConversation={handleNewConversation}
|
||||
/>
|
||||
|
||||
{/* Main Chat Area */}
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<header
|
||||
className="border-b px-4 py-3 flex items-center gap-3"
|
||||
style={{
|
||||
backgroundColor: "rgb(var(--surface-0))",
|
||||
borderColor: "rgb(var(--border-default))",
|
||||
}}
|
||||
>
|
||||
{/* Toggle Sidebar Button */}
|
||||
<button
|
||||
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||
className="p-2 rounded-lg transition-colors hover:bg-[rgb(var(--surface-1))]"
|
||||
aria-label="Toggle sidebar"
|
||||
title="Toggle conversation history"
|
||||
>
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
style={{ color: "rgb(var(--text-muted))" }}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div className="flex-1">
|
||||
<h1 className="text-lg font-semibold" style={{ color: "rgb(var(--text-primary))" }}>
|
||||
AI Chat
|
||||
</h1>
|
||||
<p className="text-xs" style={{ color: "rgb(var(--text-muted))" }}>
|
||||
Migrated from Jarvis - Connect to brain API for full functionality
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Chat Component */}
|
||||
<Chat
|
||||
ref={chatRef}
|
||||
onConversationChange={handleConversationChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
98
apps/web/src/components/chat/BackendStatusBanner.tsx
Normal file
98
apps/web/src/components/chat/BackendStatusBanner.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
/**
|
||||
* Banner that displays when the backend is unavailable.
|
||||
* Shows error message, countdown to next retry, and manual retry button.
|
||||
*
|
||||
* TODO: Integrate with actual backend status checking hook
|
||||
*/
|
||||
export function BackendStatusBanner() {
|
||||
const [isAvailable, setIsAvailable] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [retryIn, setRetryIn] = useState(0);
|
||||
|
||||
// TODO: Replace with actual useBackendStatus hook
|
||||
// const { isAvailable, error, retryIn, manualRetry } = useBackendStatus();
|
||||
|
||||
const manualRetry = () => {
|
||||
// TODO: Implement manual retry logic
|
||||
console.log("Manual retry triggered");
|
||||
};
|
||||
|
||||
const handleSignOut = async () => {
|
||||
try {
|
||||
// TODO: Implement signOut
|
||||
// await signOut();
|
||||
} catch (error) {
|
||||
console.warn("Sign-out failed during backend unavailability:", error);
|
||||
}
|
||||
window.location.href = "/login";
|
||||
};
|
||||
|
||||
// Don't render if backend is available
|
||||
if (isAvailable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-between gap-4 px-4 py-2 text-sm"
|
||||
style={{
|
||||
backgroundColor: "#fef3c7", // amber-100
|
||||
borderBottom: "1px solid #fcd34d", // amber-300
|
||||
color: "#92400e", // amber-800
|
||||
}}
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-5 w-5 flex-shrink-0"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span>
|
||||
{error || "Backend temporarily unavailable."}
|
||||
{retryIn > 0 && (
|
||||
<span className="ml-1">
|
||||
Retrying in {retryIn}s...
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={manualRetry}
|
||||
className="rounded px-3 py-1 text-xs font-medium transition-colors hover:opacity-80"
|
||||
style={{
|
||||
backgroundColor: "#fcd34d", // amber-300
|
||||
color: "#92400e", // amber-800
|
||||
}}
|
||||
>
|
||||
Retry Now
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSignOut}
|
||||
className="rounded px-3 py-1 text-xs font-medium transition-colors hover:opacity-80"
|
||||
style={{
|
||||
backgroundColor: "transparent",
|
||||
color: "#92400e", // amber-800
|
||||
border: "1px solid #fcd34d", // amber-300
|
||||
}}
|
||||
>
|
||||
Sign in again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
378
apps/web/src/components/chat/Chat.tsx
Normal file
378
apps/web/src/components/chat/Chat.tsx
Normal file
@@ -0,0 +1,378 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState, useMemo, forwardRef, useImperativeHandle } from "react";
|
||||
// TODO: These hooks will need to be created or adapted
|
||||
// import { useAuth } from "@/lib/hooks/useAuth";
|
||||
// import { useProjects } from "@/lib/hooks/useProjects";
|
||||
// import { useConversations } from "@/lib/hooks/useConversations";
|
||||
// import { useApi } from "@/lib/hooks/useApi";
|
||||
import { MessageList } from "./MessageList";
|
||||
import { ChatInput } from "./ChatInput";
|
||||
// TODO: Import types need to be created
|
||||
// import type { ConversationDetail } from "@/lib/hooks/useConversations";
|
||||
// import { handleSessionExpired, isSessionExpiring } from "@/lib/api";
|
||||
// import type { LLMModel, DefaultModel } from "@/lib/api";
|
||||
|
||||
// Placeholder types until the actual types are created
|
||||
type ConversationDetail = any;
|
||||
type LLMModel = any;
|
||||
type DefaultModel = any;
|
||||
|
||||
export interface Message {
|
||||
id: string;
|
||||
role: "user" | "assistant" | "system";
|
||||
content: string;
|
||||
thinking?: string; // Chain of thought reasoning from thinking models
|
||||
createdAt: string;
|
||||
model?: string; // LLM model used for this response
|
||||
provider?: string; // LLM provider (ollama, claude, etc.)
|
||||
// Token usage info
|
||||
promptTokens?: number;
|
||||
completionTokens?: number;
|
||||
totalTokens?: number;
|
||||
}
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
|
||||
|
||||
// Friendly waiting messages (shown after a few seconds of loading)
|
||||
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...",
|
||||
];
|
||||
|
||||
// Error messages for actual timeouts
|
||||
const TIMEOUT_QUIPS = [
|
||||
"The AI got lost in thought. Literally. Try again?",
|
||||
"That took too long, even by AI standards. Give it another go?",
|
||||
"The model wandered off. Let's try to find it again.",
|
||||
"Response timed out. The AI may have fallen asleep. Retry?",
|
||||
"The LLM took an unexpected vacation. One more attempt?",
|
||||
];
|
||||
|
||||
// Error messages for connection failures
|
||||
const CONNECTION_QUIPS = [
|
||||
"I seem to have misplaced the server. Check your connection?",
|
||||
"The server and I are having communication issues. It's not you, it's us.",
|
||||
"Connection lost. Either the internet is down, or the server is playing hide and seek.",
|
||||
"Unable to reach the mothership. The tubes appear to be clogged.",
|
||||
"The server isn't responding. Perhaps it's giving us the silent treatment.",
|
||||
];
|
||||
|
||||
const getRandomQuip = (quips: string[]) => quips[Math.floor(Math.random() * quips.length)];
|
||||
|
||||
const WELCOME_MESSAGE: Message = {
|
||||
id: "welcome",
|
||||
role: "assistant",
|
||||
content: "Hello. I'm your AI assistant. How can I help you today?",
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
export interface ChatRef {
|
||||
loadConversation: (conversation: ConversationDetail) => 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;
|
||||
}
|
||||
|
||||
export const Chat = forwardRef<ChatRef, ChatProps>(function Chat({
|
||||
onConversationChange,
|
||||
onProjectChange: _onProjectChange,
|
||||
initialProjectId,
|
||||
onInitialProjectHandled,
|
||||
}, ref) {
|
||||
void _onProjectChange; // Kept for potential future use
|
||||
|
||||
// TODO: Replace with actual hooks once they're created
|
||||
const accessToken = null;
|
||||
const isLoading = false;
|
||||
const authLoading = false;
|
||||
const authError = null;
|
||||
const projects: any[] = [];
|
||||
// const { accessToken, isLoading: authLoading, error: authError } = useAuth();
|
||||
// const { projects } = useProjects();
|
||||
// const { updateConversationProject } = useConversations();
|
||||
// const api = useApi();
|
||||
|
||||
const [messages, setMessages] = useState<Message[]>([WELCOME_MESSAGE]);
|
||||
const [isChatLoading, setIsChatLoading] = useState(false);
|
||||
const [loadingQuip, setLoadingQuip] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [conversationId, setConversationId] = useState<string | null>(null);
|
||||
const [conversationTitle, setConversationTitle] = useState<string | null>(null);
|
||||
const [conversationProjectId, setConversationProjectId] = useState<string | null>(null);
|
||||
const [pendingProjectId, setPendingProjectId] = useState<string | null>(null);
|
||||
const [showProjectMenu, setShowProjectMenu] = useState(false);
|
||||
const [showModelMenu, setShowModelMenu] = useState(false);
|
||||
const [showFooterProjectMenu, setShowFooterProjectMenu] = useState(false);
|
||||
const [showFooterModelMenu, setShowFooterModelMenu] = useState(false);
|
||||
const [isMovingProject, setIsMovingProject] = useState(false);
|
||||
const [availableModels, setAvailableModels] = useState<LLMModel[]>([]);
|
||||
const [defaultModel, setDefaultModel] = useState<DefaultModel | null>(null);
|
||||
const [selectedModel, setSelectedModel] = useState<LLMModel | null>(null);
|
||||
const [modelLoadError, setModelLoadError] = useState<string | null>(null);
|
||||
const [isLoadingModels, setIsLoadingModels] = useState(false);
|
||||
const [useReasoning, setUseReasoning] = useState(false); // Toggle for reasoning/thinking mode
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const projectMenuRef = useRef<HTMLDivElement>(null);
|
||||
const modelMenuRef = useRef<HTMLDivElement>(null);
|
||||
const footerProjectMenuRef = useRef<HTMLDivElement>(null);
|
||||
const footerModelMenuRef = useRef<HTMLDivElement>(null);
|
||||
// Track conversation ID in ref to prevent stale closure issues
|
||||
const conversationIdRef = useRef<string | null>(conversationId);
|
||||
|
||||
// Expose methods to parent via ref
|
||||
useImperativeHandle(ref, () => ({
|
||||
loadConversation: (conversation: ConversationDetail) => {
|
||||
// TODO: Implement once ConversationDetail type is available
|
||||
console.log("loadConversation called with:", conversation);
|
||||
},
|
||||
startNewConversation: (projectId?: string | null) => {
|
||||
setConversationId(null);
|
||||
setConversationTitle(null);
|
||||
setConversationProjectId(null);
|
||||
setMessages([WELCOME_MESSAGE]);
|
||||
setError(null);
|
||||
setPendingProjectId(projectId || null);
|
||||
setShowProjectMenu(false);
|
||||
onConversationChange?.(null);
|
||||
},
|
||||
getCurrentConversationId: () => conversationId,
|
||||
}));
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messages, scrollToBottom]);
|
||||
|
||||
// Keep conversationIdRef in sync with state to prevent stale closures
|
||||
useEffect(() => {
|
||||
conversationIdRef.current = conversationId;
|
||||
}, [conversationId]);
|
||||
|
||||
// Handle auth errors
|
||||
useEffect(() => {
|
||||
if (authError === "RefreshAccessTokenError") {
|
||||
setError("Your session has expired. Please sign in again.");
|
||||
}
|
||||
}, [authError]);
|
||||
|
||||
// Global keyboard shortcut: Ctrl+/ to focus input
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "/") {
|
||||
e.preventDefault();
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, []);
|
||||
|
||||
// TODO: Implement click outside handlers for menus
|
||||
|
||||
const sendMessage = useCallback(
|
||||
async (content: string) => {
|
||||
if (!content.trim() || isChatLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Add user message immediately
|
||||
const userMessage: Message = {
|
||||
id: `user-${Date.now()}`,
|
||||
role: "user",
|
||||
content: content.trim(),
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
setMessages((prev) => [...prev, userMessage]);
|
||||
setIsChatLoading(true);
|
||||
setLoadingQuip(null);
|
||||
setError(null);
|
||||
|
||||
// Show a witty loading message after 3 seconds
|
||||
const quipTimerId = setTimeout(() => {
|
||||
setLoadingQuip(getRandomQuip(WAITING_QUIPS));
|
||||
}, 3000);
|
||||
|
||||
// Change quip every 5 seconds if still waiting
|
||||
const quipIntervalId = setInterval(() => {
|
||||
setLoadingQuip(getRandomQuip(WAITING_QUIPS));
|
||||
}, 5000);
|
||||
|
||||
try {
|
||||
// TODO: Implement actual API call to /api/brain/query
|
||||
const requestBody: {
|
||||
message: string;
|
||||
conversation_id: string | null;
|
||||
project_id?: string;
|
||||
provider_instance_id?: string;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
use_reasoning?: boolean;
|
||||
} = {
|
||||
message: content.trim(),
|
||||
conversation_id: conversationId,
|
||||
};
|
||||
|
||||
// Placeholder response for now
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
const assistantMessage: Message = {
|
||||
id: `assistant-${Date.now()}`,
|
||||
role: "assistant",
|
||||
content: "This is a placeholder response. The chat API integration is not yet complete.",
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
setMessages((prev) => [...prev, assistantMessage]);
|
||||
|
||||
// Clear quip timers on success
|
||||
clearTimeout(quipTimerId);
|
||||
clearInterval(quipIntervalId);
|
||||
setLoadingQuip(null);
|
||||
} catch (err) {
|
||||
// Clear quip timers on error
|
||||
clearTimeout(quipTimerId);
|
||||
clearInterval(quipIntervalId);
|
||||
setLoadingQuip(null);
|
||||
|
||||
console.error("Failed to send message:", err);
|
||||
const errorMsg = err instanceof Error ? err.message : "Failed to send message";
|
||||
setError(errorMsg);
|
||||
|
||||
const errorMessage: Message = {
|
||||
id: `error-${Date.now()}`,
|
||||
role: "assistant",
|
||||
content: errorMsg,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
setMessages((prev) => [...prev, errorMessage]);
|
||||
} finally {
|
||||
setIsChatLoading(false);
|
||||
}
|
||||
},
|
||||
[conversationId, isChatLoading]
|
||||
);
|
||||
|
||||
const dismissError = useCallback(() => {
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
// Show loading state while auth is loading
|
||||
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))" }}>
|
||||
{/* Messages Area */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="mx-auto max-w-4xl px-4 py-6 lg:px-8">
|
||||
<MessageList messages={messages} isLoading={isChatLoading} 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={dismissError}
|
||||
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={sendMessage}
|
||||
disabled={isChatLoading || !accessToken}
|
||||
inputRef={inputRef}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
189
apps/web/src/components/chat/ChatInput.tsx
Normal file
189
apps/web/src/components/chat/ChatInput.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState, useEffect, KeyboardEvent, RefObject } from "react";
|
||||
|
||||
interface ChatInputProps {
|
||||
onSend: (message: string) => void;
|
||||
disabled?: boolean;
|
||||
inputRef?: RefObject<HTMLTextAreaElement | null>;
|
||||
}
|
||||
|
||||
export function ChatInput({ onSend, disabled, inputRef }: ChatInputProps) {
|
||||
const [message, setMessage] = useState("");
|
||||
const [version, setVersion] = useState<string | null>(null);
|
||||
|
||||
// Fetch version from static version.json (generated at build time)
|
||||
useEffect(() => {
|
||||
fetch("/version.json")
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data.version) {
|
||||
// Format as "version+commit" for full build identification
|
||||
const fullVersion = data.commit
|
||||
? `${data.version}+${data.commit}`
|
||||
: data.version;
|
||||
setVersion(fullVersion);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Silently fail - version display is non-critical
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (message.trim() && !disabled) {
|
||||
onSend(message);
|
||||
setMessage("");
|
||||
}
|
||||
}, [message, onSend, disabled]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
// Enter to send (without Shift)
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
// Ctrl/Cmd + Enter to send (alternative)
|
||||
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
},
|
||||
[handleSubmit]
|
||||
);
|
||||
|
||||
const characterCount = message.length;
|
||||
const maxCharacters = 4000;
|
||||
const isNearLimit = characterCount > maxCharacters * 0.9;
|
||||
const isOverLimit = characterCount > maxCharacters;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Input Container */}
|
||||
<div
|
||||
className="relative rounded-lg border transition-all duration-150"
|
||||
style={{
|
||||
backgroundColor: "rgb(var(--surface-0))",
|
||||
borderColor: disabled
|
||||
? "rgb(var(--border-default))"
|
||||
: "rgb(var(--border-strong))",
|
||||
}}
|
||||
>
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Type a message..."
|
||||
disabled={disabled}
|
||||
rows={1}
|
||||
className="block w-full resize-none bg-transparent px-4 py-3 pr-24 text-sm outline-none placeholder:text-[rgb(var(--text-muted))] disabled:opacity-50"
|
||||
style={{
|
||||
color: "rgb(var(--text-primary))",
|
||||
minHeight: "48px",
|
||||
maxHeight: "200px",
|
||||
}}
|
||||
onInput={(e) => {
|
||||
const target = e.target as HTMLTextAreaElement;
|
||||
target.style.height = "auto";
|
||||
target.style.height = Math.min(target.scrollHeight, 200) + "px";
|
||||
}}
|
||||
aria-label="Message input"
|
||||
aria-describedby="input-help"
|
||||
/>
|
||||
|
||||
{/* Send Button */}
|
||||
<div className="absolute bottom-2 right-2 flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={disabled || !message.trim() || isOverLimit}
|
||||
className="btn-primary btn-sm rounded-md"
|
||||
style={{
|
||||
opacity: disabled || !message.trim() || isOverLimit ? 0.5 : 1,
|
||||
}}
|
||||
aria-label="Send message"
|
||||
>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path d="M5 12h14M12 5l7 7-7 7" />
|
||||
</svg>
|
||||
<span className="hidden sm:inline">Send</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer: Keyboard shortcuts + character count */}
|
||||
<div
|
||||
className="flex items-center justify-between text-xs"
|
||||
style={{ color: "rgb(var(--text-muted))" }}
|
||||
id="input-help"
|
||||
>
|
||||
{/* Keyboard Shortcuts */}
|
||||
<div className="hidden items-center gap-4 sm:flex">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="kbd">Enter</span>
|
||||
<span>to send</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="kbd">Shift</span>
|
||||
<span>+</span>
|
||||
<span className="kbd">Enter</span>
|
||||
<span>for new line</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile hint */}
|
||||
<div className="sm:hidden">
|
||||
Tap send or press Enter
|
||||
</div>
|
||||
|
||||
{/* Character Count */}
|
||||
<div
|
||||
className="flex items-center gap-2"
|
||||
style={{
|
||||
color: isOverLimit
|
||||
? "rgb(var(--semantic-error))"
|
||||
: isNearLimit
|
||||
? "rgb(var(--semantic-warning))"
|
||||
: "rgb(var(--text-muted))",
|
||||
}}
|
||||
>
|
||||
{characterCount > 0 && (
|
||||
<>
|
||||
<span>
|
||||
{characterCount.toLocaleString()}/{maxCharacters.toLocaleString()}
|
||||
</span>
|
||||
{isOverLimit && (
|
||||
<svg className="h-3.5 w-3.5" 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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AI Disclaimer & Version */}
|
||||
<div
|
||||
className="flex items-center justify-center gap-2 text-xs"
|
||||
style={{ color: "rgb(var(--text-muted))" }}
|
||||
>
|
||||
<span>AI may produce inaccurate information. Verify important details.</span>
|
||||
{version && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span>v{version}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
245
apps/web/src/components/chat/ConversationSidebar.tsx
Normal file
245
apps/web/src/components/chat/ConversationSidebar.tsx
Normal file
@@ -0,0 +1,245 @@
|
||||
"use client";
|
||||
|
||||
import { useState, forwardRef, useImperativeHandle } from "react";
|
||||
// import Link from "next/link";
|
||||
// TODO: Import hooks when they're created
|
||||
// import { useConversations, ConversationSummary } from "@/lib/hooks/useConversations";
|
||||
// import { useProjects } from "@/lib/hooks/useProjects";
|
||||
// import type { IsolationMode } from "@/lib/api";
|
||||
|
||||
// Placeholder types
|
||||
type ConversationSummary = {
|
||||
id: string;
|
||||
title: string | null;
|
||||
project_id: string | null;
|
||||
updated_at: string;
|
||||
message_count: number;
|
||||
};
|
||||
|
||||
export interface ConversationSidebarRef {
|
||||
refresh: () => void;
|
||||
addConversation: (conversation: ConversationSummary) => void;
|
||||
}
|
||||
|
||||
interface ConversationSidebarProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
currentConversationId: string | null;
|
||||
onSelectConversation: (conversationId: string | null) => void;
|
||||
onNewConversation: (projectId?: string | null) => void;
|
||||
}
|
||||
|
||||
export const ConversationSidebar = forwardRef<ConversationSidebarRef, ConversationSidebarProps>(function ConversationSidebar({
|
||||
isOpen,
|
||||
onClose,
|
||||
currentConversationId,
|
||||
onSelectConversation,
|
||||
onNewConversation,
|
||||
}, ref) {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
// Placeholder data
|
||||
const conversations: ConversationSummary[] = [];
|
||||
const projects: any[] = [];
|
||||
|
||||
// Expose methods to parent via ref
|
||||
useImperativeHandle(ref, () => ({
|
||||
refresh: () => {
|
||||
// TODO: Implement refresh logic
|
||||
console.log("Refresh called");
|
||||
},
|
||||
addConversation: (conversation: ConversationSummary) => {
|
||||
// TODO: Implement addConversation logic
|
||||
console.log("Add conversation called:", conversation);
|
||||
},
|
||||
}));
|
||||
|
||||
const filteredConversations = conversations.filter((conv) => {
|
||||
if (!searchQuery.trim()) return true;
|
||||
const title = conv.title || "Untitled conversation";
|
||||
return title.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
});
|
||||
|
||||
const formatRelativeTime = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 1) return "Just now";
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
return date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
||||
};
|
||||
|
||||
const truncateTitle = (title: string | null, maxLength = 32) => {
|
||||
const displayTitle = title || "Untitled conversation";
|
||||
if (displayTitle.length <= maxLength) return displayTitle;
|
||||
return displayTitle.substring(0, maxLength - 1) + "…";
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile overlay */}
|
||||
{isOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-40 md:hidden animate-fade-in"
|
||||
style={{ backgroundColor: "rgb(0 0 0 / 0.6)" }}
|
||||
onClick={onClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sidebar */}
|
||||
<aside
|
||||
className={`
|
||||
fixed left-0 top-0 z-50 h-screen transform border-r transition-all duration-200 ease-out flex flex-col
|
||||
md:sticky md:top-0 md:z-auto md:h-screen md:transform-none md:transition-[width]
|
||||
${isOpen ? "translate-x-0 w-72" : "-translate-x-full md:translate-x-0 md:w-16"}
|
||||
`}
|
||||
style={{
|
||||
backgroundColor: "rgb(var(--surface-0))",
|
||||
borderColor: "rgb(var(--border-default))",
|
||||
}}
|
||||
aria-label="Conversation history"
|
||||
>
|
||||
{/* Collapsed view - TODO: Implement */}
|
||||
{!isOpen && (
|
||||
<div className="hidden md:flex flex-col items-center py-3 h-full">
|
||||
<button
|
||||
onClick={() => onNewConversation()}
|
||||
className="p-3 rounded-lg transition-colors"
|
||||
title="New Conversation"
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Full sidebar content */}
|
||||
{isOpen && (
|
||||
<>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="flex items-center justify-between border-b px-4 py-3"
|
||||
style={{ borderColor: "rgb(var(--border-default))" }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
style={{ color: "rgb(var(--text-muted))" }}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||
</svg>
|
||||
<span className="text-sm font-semibold" style={{ color: "rgb(var(--text-primary))" }}>
|
||||
Conversations
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button onClick={onClose} className="btn-ghost rounded-md p-1.5" aria-label="Close sidebar">
|
||||
<svg className="h-5 w-5" style={{ color: "rgb(var(--text-muted))" }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* New Chat Button */}
|
||||
<div className="px-3 pt-3">
|
||||
<button
|
||||
onClick={() => onNewConversation()}
|
||||
className="w-full flex items-center justify-center gap-2 rounded-lg border border-dashed py-2.5 text-sm font-medium transition-all duration-150 hover:border-solid"
|
||||
style={{
|
||||
borderColor: "rgb(var(--border-strong))",
|
||||
color: "rgb(var(--text-secondary))",
|
||||
}}
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
<span>New Conversation</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="px-3 pt-3">
|
||||
<div className="relative">
|
||||
<svg
|
||||
className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2"
|
||||
style={{ color: "rgb(var(--text-muted))" }}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path d="M21 21l-4.35-4.35" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search conversations..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="input pl-9 pr-10 py-2 text-sm"
|
||||
style={{ backgroundColor: "rgb(var(--surface-1))" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Conversations List */}
|
||||
<div className="flex-1 overflow-y-auto px-3 pt-3 pb-3 space-y-1">
|
||||
{filteredConversations.length === 0 ? (
|
||||
<div className="text-center py-8" style={{ color: "rgb(var(--text-muted))" }}>
|
||||
<p className="text-sm">No conversations yet</p>
|
||||
<p className="text-xs mt-1">Start a new chat to begin</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredConversations.map((conv) => (
|
||||
<button
|
||||
key={conv.id}
|
||||
onClick={() => onSelectConversation(conv.id)}
|
||||
className={`w-full text-left px-3 py-2 rounded-lg transition-colors ${
|
||||
conv.id === currentConversationId
|
||||
? "bg-[rgb(var(--accent-primary-light))]"
|
||||
: "hover:bg-[rgb(var(--surface-2))]"
|
||||
}`}
|
||||
>
|
||||
<p
|
||||
className="text-sm font-medium truncate"
|
||||
style={{
|
||||
color: conv.id === currentConversationId
|
||||
? "rgb(var(--accent-primary))"
|
||||
: "rgb(var(--text-primary))",
|
||||
}}
|
||||
>
|
||||
{truncateTitle(conv.title)}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-0.5" style={{ color: "rgb(var(--text-muted))" }}>
|
||||
<span className="text-xs">{formatRelativeTime(conv.updated_at)}</span>
|
||||
{conv.message_count > 0 && (
|
||||
<>
|
||||
<span className="text-xs">·</span>
|
||||
<span className="text-xs">
|
||||
{conv.message_count} msg{conv.message_count !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
});
|
||||
319
apps/web/src/components/chat/MessageList.tsx
Normal file
319
apps/web/src/components/chat/MessageList.tsx
Normal file
@@ -0,0 +1,319 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import type { Message } from "./Chat";
|
||||
|
||||
interface MessageListProps {
|
||||
messages: Message[];
|
||||
isLoading: boolean;
|
||||
loadingQuip?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse thinking content from message.
|
||||
* Extracts <thinking>...</thinking> or <think>...</think> blocks.
|
||||
*/
|
||||
function parseThinking(content: string): { thinking: string | null; response: string } {
|
||||
// Match <thinking>...</thinking> or <think>...</think> blocks
|
||||
const thinkingRegex = /<(?:thinking|think)>([\s\S]*?)<\/(?:thinking|think)>/gi;
|
||||
const matches = content.match(thinkingRegex);
|
||||
|
||||
if (!matches) {
|
||||
return { thinking: null, response: content };
|
||||
}
|
||||
|
||||
// Extract thinking content
|
||||
let thinking = "";
|
||||
for (const match of matches) {
|
||||
const innerContent = match.replace(/<\/?(?:thinking|think)>/gi, "");
|
||||
thinking += innerContent.trim() + "\n";
|
||||
}
|
||||
|
||||
// Remove thinking blocks from response
|
||||
const response = content.replace(thinkingRegex, "").trim();
|
||||
|
||||
return {
|
||||
thinking: thinking.trim() || null,
|
||||
response,
|
||||
};
|
||||
}
|
||||
|
||||
export function MessageList({ messages, isLoading, loadingQuip }: MessageListProps) {
|
||||
return (
|
||||
<div className="space-y-6" role="log" aria-label="Chat messages">
|
||||
{messages.map((message) => (
|
||||
<MessageBubble key={message.id} message={message} />
|
||||
))}
|
||||
|
||||
{isLoading && <LoadingIndicator quip={loadingQuip} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageBubble({ message }: { message: Message }) {
|
||||
const isUser = message.role === "user";
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [thinkingExpanded, setThinkingExpanded] = useState(false);
|
||||
|
||||
// Parse thinking from content (or use pre-parsed thinking field)
|
||||
const { thinking, response } = message.thinking
|
||||
? { thinking: message.thinking, response: message.content }
|
||||
: parseThinking(message.content);
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(response);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (err) {
|
||||
console.error("Failed to copy:", err);
|
||||
}
|
||||
}, [response]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`group flex gap-4 message-animate ${isUser ? "flex-row-reverse" : ""}`}
|
||||
role="article"
|
||||
aria-label={`${isUser ? "Your" : "AI Assistant"} message`}
|
||||
>
|
||||
{/* Avatar */}
|
||||
<div
|
||||
className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg text-xs font-semibold"
|
||||
style={{
|
||||
backgroundColor: isUser
|
||||
? "rgb(var(--surface-2))"
|
||||
: "rgb(var(--accent-primary))",
|
||||
color: isUser ? "rgb(var(--text-secondary))" : "white",
|
||||
}}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{isUser ? "You" : "AI"}
|
||||
</div>
|
||||
|
||||
{/* Message Content */}
|
||||
<div className={`flex max-w-[85%] flex-col gap-1.5 ${isUser ? "items-end" : "items-start"}`}>
|
||||
{/* Message Header */}
|
||||
<div
|
||||
className={`flex items-center gap-2 text-xs ${isUser ? "flex-row-reverse" : ""}`}
|
||||
style={{ color: "rgb(var(--text-muted))" }}
|
||||
>
|
||||
<span className="font-medium" style={{ color: "rgb(var(--text-secondary))" }}>
|
||||
{isUser ? "You" : "AI Assistant"}
|
||||
</span>
|
||||
{/* Model indicator for assistant messages */}
|
||||
{!isUser && message.model && (
|
||||
<span
|
||||
className="px-1.5 py-0.5 rounded text-[10px] font-medium"
|
||||
style={{
|
||||
backgroundColor: "rgb(var(--surface-2))",
|
||||
color: "rgb(var(--text-tertiary))",
|
||||
}}
|
||||
title={message.provider ? `Provider: ${message.provider}` : undefined}
|
||||
>
|
||||
{message.model}
|
||||
</span>
|
||||
)}
|
||||
{/* Token usage indicator for assistant messages */}
|
||||
{!isUser && message.totalTokens !== undefined && message.totalTokens > 0 && (
|
||||
<span
|
||||
className="px-1.5 py-0.5 rounded text-[10px] font-medium"
|
||||
style={{
|
||||
backgroundColor: "rgb(var(--surface-2))",
|
||||
color: "rgb(var(--text-muted))",
|
||||
}}
|
||||
title={`Prompt: ${message.promptTokens?.toLocaleString() || 0} tokens, Completion: ${message.completionTokens?.toLocaleString() || 0} tokens`}
|
||||
>
|
||||
{formatTokenCount(message.totalTokens)} tokens
|
||||
</span>
|
||||
)}
|
||||
<span aria-label={`Sent at ${formatTime(message.createdAt)}`}>
|
||||
{formatTime(message.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Thinking Section - Collapsible */}
|
||||
{thinking && !isUser && (
|
||||
<div
|
||||
className="rounded-lg overflow-hidden"
|
||||
style={{
|
||||
backgroundColor: "rgb(var(--surface-1))",
|
||||
border: "1px solid rgb(var(--border-default))",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={() => setThinkingExpanded(!thinkingExpanded)}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-xs font-medium hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
|
||||
style={{ color: "rgb(var(--text-secondary))" }}
|
||||
aria-expanded={thinkingExpanded}
|
||||
>
|
||||
<svg
|
||||
className={`h-3.5 w-3.5 transition-transform duration-200 ${thinkingExpanded ? "rotate-90" : ""}`}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path d="m9 18 6-6-6-6" />
|
||||
</svg>
|
||||
<svg
|
||||
className="h-3.5 w-3.5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||
</svg>
|
||||
<span>Thinking</span>
|
||||
<span
|
||||
className="ml-auto text-xs"
|
||||
style={{ color: "rgb(var(--text-muted))" }}
|
||||
>
|
||||
{thinkingExpanded ? "Hide" : "Show"} reasoning
|
||||
</span>
|
||||
</button>
|
||||
{thinkingExpanded && (
|
||||
<div
|
||||
className="px-3 pb-3 text-xs leading-relaxed whitespace-pre-wrap font-mono"
|
||||
style={{
|
||||
color: "rgb(var(--text-secondary))",
|
||||
borderTop: "1px solid rgb(var(--border-default))",
|
||||
}}
|
||||
>
|
||||
{thinking}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Message Body */}
|
||||
<div
|
||||
className="relative rounded-lg px-4 py-3"
|
||||
style={{
|
||||
backgroundColor: isUser
|
||||
? "rgb(var(--accent-primary))"
|
||||
: "rgb(var(--surface-0))",
|
||||
color: isUser ? "white" : "rgb(var(--text-primary))",
|
||||
border: isUser ? "none" : "1px solid rgb(var(--border-default))",
|
||||
}}
|
||||
>
|
||||
<p className="whitespace-pre-wrap text-sm leading-relaxed">
|
||||
{response}
|
||||
</p>
|
||||
|
||||
{/* Copy Button - appears on hover */}
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="absolute -right-2 -top-2 rounded-md border p-1.5 opacity-0 transition-all group-hover:opacity-100 focus:opacity-100"
|
||||
style={{
|
||||
backgroundColor: "rgb(var(--surface-0))",
|
||||
borderColor: "rgb(var(--border-default))",
|
||||
color: copied ? "rgb(var(--semantic-success))" : "rgb(var(--text-muted))",
|
||||
}}
|
||||
aria-label={copied ? "Copied!" : "Copy message"}
|
||||
title={copied ? "Copied!" : "Copy to clipboard"}
|
||||
>
|
||||
{copied ? (
|
||||
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingIndicator({ quip }: { quip?: string | null }) {
|
||||
return (
|
||||
<div className="flex gap-4 message-animate" role="status" aria-label="AI is typing">
|
||||
{/* Avatar */}
|
||||
<div
|
||||
className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg text-xs font-semibold"
|
||||
style={{ backgroundColor: "rgb(var(--accent-primary))" }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span className="text-white">AI</span>
|
||||
</div>
|
||||
|
||||
{/* Loading Content */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div
|
||||
className="flex items-center gap-2 text-xs"
|
||||
style={{ color: "rgb(var(--text-muted))" }}
|
||||
>
|
||||
<span className="font-medium" style={{ color: "rgb(var(--text-secondary))" }}>
|
||||
AI Assistant
|
||||
</span>
|
||||
<span>thinking...</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="rounded-lg border px-4 py-3"
|
||||
style={{
|
||||
backgroundColor: "rgb(var(--surface-0))",
|
||||
borderColor: "rgb(var(--border-default))",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="h-1.5 w-1.5 rounded-full animate-bounce"
|
||||
style={{
|
||||
backgroundColor: "rgb(var(--accent-primary))",
|
||||
animationDelay: "0ms",
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
className="h-1.5 w-1.5 rounded-full animate-bounce"
|
||||
style={{
|
||||
backgroundColor: "rgb(var(--accent-primary))",
|
||||
animationDelay: "150ms",
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
className="h-1.5 w-1.5 rounded-full animate-bounce"
|
||||
style={{
|
||||
backgroundColor: "rgb(var(--accent-primary))",
|
||||
animationDelay: "300ms",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{quip && (
|
||||
<span
|
||||
className="text-sm italic animate-fade-in"
|
||||
style={{ color: "rgb(var(--text-muted))" }}
|
||||
>
|
||||
{quip}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatTime(isoString: string): string {
|
||||
try {
|
||||
const date = new Date(isoString);
|
||||
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function formatTokenCount(tokens: number): string {
|
||||
if (tokens >= 1000000) {
|
||||
return `${(tokens / 1000000).toFixed(1)}M`;
|
||||
} else if (tokens >= 1000) {
|
||||
return `${(tokens / 1000).toFixed(1)}K`;
|
||||
}
|
||||
return tokens.toString();
|
||||
}
|
||||
17
apps/web/src/components/chat/index.ts
Normal file
17
apps/web/src/components/chat/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Chat Components
|
||||
*
|
||||
* Migrated from jarvis-fe. These components provide the chat interface
|
||||
* for interacting with the AI brain service.
|
||||
*
|
||||
* Usage:
|
||||
* ```tsx
|
||||
* import { Chat, MessageList, ChatInput } from '@/components/chat';
|
||||
* ```
|
||||
*/
|
||||
|
||||
export { Chat, type ChatRef, type Message, type NewConversationData } from './Chat';
|
||||
export { ChatInput } from './ChatInput';
|
||||
export { MessageList } from './MessageList';
|
||||
export { ConversationSidebar, type ConversationSidebarRef } from './ConversationSidebar';
|
||||
export { BackendStatusBanner } from './BackendStatusBanner';
|
||||
Reference in New Issue
Block a user